Coverage for paintmaskanimator/widgets.py: 49%
1122 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
1import math
2from pathlib import Path
3from PySide6.QtCore import QPoint, QPointF, QRectF, QTimer, Qt, Signal
4from PySide6.QtGui import (
5 QColor,
6 QCursor,
7 QImage,
8 QKeySequence,
9 QPainter,
10 QPen,
11 QPolygonF,
12 QValidator,
13)
14from PySide6.QtWidgets import (
15 QAbstractItemView,
16 QAbstractSpinBox,
17 QApplication,
18 QCheckBox,
19 QDialog,
20 QDialogButtonBox,
21 QDoubleSpinBox,
22 QFileDialog,
23 QFormLayout,
24 QHBoxLayout,
25 QHeaderView,
26 QKeySequenceEdit,
27 QLabel,
28 QMenu,
29 QMessageBox,
30 QPlainTextEdit,
31 QPushButton,
32 QSizePolicy,
33 QSlider,
34 QSpinBox,
35 QTabWidget,
36 QTableWidget,
37 QTableWidgetItem,
38 QToolButton,
39 QVBoxLayout,
40 QWidget,
41)
42from typing import Any
43from . import theme, timesheet_file
44from .constants import MAX_IMAGE_DIMENSION
45from .errors import OperationError
46from .i18n import tr
47from .utils import _ScreenColorDragMixin
48from .logging_setup import get_logger
50log = get_logger(__name__)
53class TransformLineThicknessDialog(QDialog):
54 """TPクオリティ変形の実線太さをライブ調整する。"""
56 def __init__(self, value=96, parent=None):
57 super().__init__(parent)
58 self.setWindowTitle(tr("太さを調整"))
59 self.setModal(True)
60 self.resize(380, 130)
62 layout = QVBoxLayout(self)
63 note = QLabel(
64 tr("選択中の複数色を実線として残す太さをまとめて調整します。")
65 )
66 note.setWordWrap(True)
67 layout.addWidget(note)
69 row = QHBoxLayout()
70 self.slider = QSlider(Qt.Orientation.Horizontal)
71 self.slider.setRange(1, 254)
72 self.slider.setInvertedAppearance(True)
73 self.slider.setValue(max(1, min(254, int(value))))
74 self.value_label = QLabel(f"{255 - self.slider.value()}")
75 self.value_label.setFixedWidth(42)
76 self.value_label.setAlignment(Qt.AlignmentFlag.AlignRight)
77 self.slider.valueChanged.connect(
78 lambda current: self.value_label.setText(str(255 - current))
79 )
80 row.addWidget(self.slider, 1)
81 row.addWidget(self.value_label)
82 layout.addLayout(row)
84 buttons = QDialogButtonBox(
85 QDialogButtonBox.StandardButton.Ok
86 | QDialogButtonBox.StandardButton.Cancel
87 )
88 buttons.accepted.connect(self.accept)
89 buttons.rejected.connect(self.reject)
90 layout.addWidget(buttons)
92 def value(self):
93 return int(self.slider.value())
97class CanvasSizeDialog(QDialog):
98 def __init__(self, width, height, title, parent=None):
99 super().__init__(parent); self.setWindowTitle(title)
100 l = QFormLayout(self)
101 # 上限は画像読み込みと同じ MAX_IMAGE_DIMENSION(10000×10000 も作れる)。
102 self.w = QSpinBox(); self.w.setRange(64, MAX_IMAGE_DIMENSION); self.w.setValue(width)
103 self.h = QSpinBox(); self.h.setRange(64, MAX_IMAGE_DIMENSION); self.h.setValue(height)
104 l.addRow(tr("幅"), self.w); l.addRow(tr("高さ"), self.h)
105 b = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
106 b.accepted.connect(self.accept); b.rejected.connect(self.reject); l.addRow(b)
107 def values(self): return self.w.value(), self.h.value()
110class HoldShortcutEdit(QPushButton):
111 """押している間だけ有効な操作用。修飾キー単独も登録できるキー入力欄。"""
113 def __init__(self, shortcut_text="", parent=None):
114 super().__init__(parent)
115 self._shortcut_text = str(shortcut_text or "")
116 self._waiting = False
117 self.setMinimumWidth(180)
118 self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
119 self.clicked.connect(self._begin_capture)
120 self._refresh_text()
122 def _begin_capture(self):
123 self._waiting = True
124 self.setFocus(Qt.FocusReason.MouseFocusReason)
125 self.setText(tr("キーを押してください…"))
127 def shortcutText(self):
128 return self._shortcut_text
130 def setShortcutText(self, text):
131 self._shortcut_text = str(text or "")
132 self._waiting = False
133 self._refresh_text()
135 def _refresh_text(self):
136 self.setText(self._shortcut_text or tr("未設定"))
137 self.setToolTip(
138 tr("クリックしてキーを入力。BackspaceまたはDeleteで解除。")
139 )
141 @staticmethod
142 def _key_name(event):
143 key_map = {
144 Qt.Key.Key_Control: "Ctrl",
145 Qt.Key.Key_Shift: "Shift",
146 Qt.Key.Key_Alt: "Alt",
147 Qt.Key.Key_Meta: "Meta",
148 Qt.Key.Key_Space: "Space",
149 }
150 if event.key() in key_map:
151 return key_map[event.key()]
152 return QKeySequence(int(event.key())).toString(
153 QKeySequence.SequenceFormat.PortableText
154 )
156 def keyPressEvent(self, event):
157 if event.key() in (Qt.Key.Key_Backspace, Qt.Key.Key_Delete):
158 self.setShortcutText("")
159 event.accept()
160 return
161 key_name = self._key_name(event)
162 if not key_name:
163 super().keyPressEvent(event)
164 return
165 tokens = []
166 modifiers = event.modifiers()
167 for flag, name in (
168 (Qt.KeyboardModifier.ControlModifier, "Ctrl"),
169 (Qt.KeyboardModifier.ShiftModifier, "Shift"),
170 (Qt.KeyboardModifier.AltModifier, "Alt"),
171 (Qt.KeyboardModifier.MetaModifier, "Meta"),
172 ):
173 if modifiers & flag:
174 tokens.append(name)
175 if key_name not in tokens:
176 tokens.append(key_name)
177 self.setShortcutText("+".join(tokens))
178 event.accept()
180 def focusOutEvent(self, event):
181 self._waiting = False
182 self._refresh_text()
183 super().focusOutEvent(event)
186class ShortcutDialog(QDialog):
187 def __init__(self, categories, parent=None):
188 super().__init__(parent)
189 self.setWindowTitle(tr("ショートカット設定"))
190 self.resize(700, 620)
191 self.categories = categories
192 layout = QVBoxLayout(self)
193 note = QLabel(
194 tr("ショートカット欄を選び、実際のキーまたはキーの組み合わせを押してください。"
195 "文字列入力ではなくキー入力として認識します。"
196 "「キャンバス操作」では修飾キー単独も登録できます。")
197 )
198 note.setWordWrap(True)
199 layout.addWidget(note)
200 self.tabs = QTabWidget()
201 self.editors = []
202 for title, actions in categories:
203 page = QWidget()
204 form = QFormLayout(page)
205 page_editors = []
206 for name, action in actions:
207 if bool(action.property("holdOperation")): 207 ↛ 208line 207 didn't jump to line 208 because the condition on line 207 was never true
208 stored = action.property("holdShortcutText")
209 editor = HoldShortcutEdit(
210 "" if stored is None else str(stored)
211 )
212 else:
213 editor = QKeySequenceEdit(action.shortcut())
214 editor.setClearButtonEnabled(True)
215 form.addRow(name, editor)
216 page_editors.append((name, action, editor))
217 form.addRow(QWidget())
218 self.tabs.addTab(page, title)
219 self.editors.extend(page_editors)
220 layout.addWidget(self.tabs)
221 buttons = QDialogButtonBox(
222 QDialogButtonBox.StandardButton.Ok
223 | QDialogButtonBox.StandardButton.Cancel
224 )
225 buttons.accepted.connect(self.apply)
226 buttons.rejected.connect(self.reject)
227 layout.addWidget(buttons)
229 def apply(self):
230 used = {}
231 duplicates = []
232 pending = []
233 for name, action, editor in self.editors:
234 if isinstance(editor, HoldShortcutEdit): 234 ↛ 235line 234 didn't jump to line 235 because the condition on line 234 was never true
235 normalized = editor.shortcutText().strip()
236 sequence = QKeySequence(normalized)
237 is_hold = True
238 else:
239 sequence = editor.keySequence()
240 normalized = sequence.toString(
241 QKeySequence.SequenceFormat.PortableText
242 )
243 is_hold = False
244 if normalized: 244 ↛ 249line 244 didn't jump to line 249 because the condition on line 244 was always true
245 if normalized in used: 245 ↛ 246line 245 didn't jump to line 246 because the condition on line 245 was never true
246 duplicates.append((normalized, used[normalized], name))
247 else:
248 used[normalized] = name
249 pending.append((action, sequence, is_hold, normalized))
250 if duplicates: 250 ↛ 251line 250 didn't jump to line 251 because the condition on line 250 was never true
251 details = "\n".join(
252 f"{shortcut}: {first} / {second}"
253 for shortcut, first, second in duplicates[:12]
254 )
255 QMessageBox.warning(
256 self,
257 tr("ショートカットの重複"),
258 "同じショートカットが複数の機能に登録されています。\n\n" + details,
259 )
260 return
261 for action, sequence, is_hold, normalized in pending:
262 if is_hold: 262 ↛ 263line 262 didn't jump to line 263 because the condition on line 262 was never true
263 action.setProperty("holdShortcutText", normalized)
264 elif sequence == action.shortcut(): 264 ↛ 268line 264 didn't jump to line 268 because the condition on line 264 was always true
265 # 変えていない欄は触らない。setShortcut すると Ctrl+Shift+Z などの
266 # 2つ目のキーが消えてしまうため。
267 continue
268 action.setShortcut(sequence)
269 self.accept()
272class SwatchEyedropButton(_ScreenColorDragMixin, QPushButton):
273 colorPicked = Signal(QColor)
275 def __init__(self, parent=None):
276 super().__init__(parent)
277 self._init_screen_color_drag()
280class BrushSizeSpinBox(QDoubleSpinBox):
281 """▲▼で0.5ずつ変更するブラシ/ラインサイズ表示。"""
283 pressureRequested = Signal()
285 def __init__(self, parent=None):
286 super().__init__(parent)
287 self.setRange(0.5, 400.0)
288 self.setDecimals(1)
289 self.setSingleStep(0.5)
290 self.setReadOnly(False)
291 self.setButtonSymbols(
292 QAbstractSpinBox.ButtonSymbols.UpDownArrows
293 )
294 self.setFocusPolicy(Qt.FocusPolicy.NoFocus)
295 self._pressure_popup_enabled = True
296 editor = self.lineEdit()
297 editor.setReadOnly(True)
298 editor.setFocusPolicy(Qt.FocusPolicy.NoFocus)
299 self._refresh_tooltip()
301 def _refresh_tooltip(self):
302 self.setToolTip(
303 tr("▲▼をクリック:0.5ずつ変更")
304 + (
305 tr("/数値部分をクリック:筆圧設定")
306 if self._pressure_popup_enabled else ""
307 )
308 )
310 def setPressurePopupEnabled(self, enabled):
311 self._pressure_popup_enabled = bool(enabled)
312 self._refresh_tooltip()
314 def _clear_number_selection(self):
315 editor = self.lineEdit()
316 if editor is None:
317 return
318 editor.deselect()
319 editor.setCursorPosition(len(editor.text()))
320 editor.clearFocus()
321 self.clearFocus()
323 def stepBy(self, steps):
324 # ▲▼で変更した後、数値全体が青く選択される状態を残さない。
325 super().stepBy(steps)
326 QTimer.singleShot(0, self._clear_number_selection)
328 def mouseReleaseEvent(self, event):
329 if (
330 self._pressure_popup_enabled
331 and event.button() == Qt.MouseButton.LeftButton
332 and self.lineEdit().geometry().contains(
333 event.position().toPoint()
334 )
335 ):
336 self.pressureRequested.emit()
337 event.accept()
338 return
339 super().mouseReleaseEvent(event)
340 QTimer.singleShot(0, self._clear_number_selection)
342 def wheelEvent(self, event):
343 event.ignore()
347class SliderValueSpinBox(QDoubleSpinBox):
348 """RGB/HSVスライダーと連動する、編集可能な小型数値欄。"""
350 def __init__(self, slider, scale=1.0, step=1.0, parent=None):
351 super().__init__(parent)
352 self.slider = slider
353 self.scale = max(1e-9, float(scale))
354 self._ascii_step = float(step)
356 self.setDecimals(
357 1 if abs(float(step) - round(float(step))) > 1e-9 else 0
358 )
359 self.setSingleStep(float(step))
360 self.setRange(
361 float(slider.minimum()) / self.scale,
362 float(slider.maximum()) / self.scale,
363 )
364 self.setReadOnly(False)
365 self.setKeyboardTracking(True)
366 self.setButtonSymbols(
367 QAbstractSpinBox.ButtonSymbols.NoButtons
368 )
369 self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
371 # 0~255の数字と小型▲▼だけが収まる幅に圧縮する。
372 self.setFixedWidth(48)
373 self.setFixedHeight(22)
374 self.setStyleSheet(
375 "QDoubleSpinBox{font-size:10px;padding-left:1px;"
376 "padding-right:10px;"
377 "selection-background-color:palette(highlight);"
378 "selection-color:palette(highlighted-text);}"
379 )
381 editor = self.lineEdit()
382 editor.setReadOnly(False)
383 editor.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
384 editor.setMaxLength(5 if self.decimals() else 3)
385 editor.setAlignment(Qt.AlignmentFlag.AlignRight)
386 editor.setTextMargins(0, 0, 9, 0)
387 editor.setToolTip(
388 tr("半角数字だけ入力できます。")
389 if self.decimals() == 0
390 else tr("半角数字と小数点だけ入力できます。")
391 )
393 # 標準の四角いスピンボタンを使わず、文字の▲▼を直接表示する。
394 self._up_button = QToolButton(self)
395 self._down_button = QToolButton(self)
396 for button, label in (
397 (self._up_button, "▲"),
398 (self._down_button, "▼"),
399 ):
400 button.setText(label)
401 button.setAutoRepeat(True)
402 button.setAutoRepeatDelay(350)
403 button.setAutoRepeatInterval(70)
404 button.setFocusPolicy(Qt.FocusPolicy.NoFocus)
405 button.setCursor(Qt.CursorShape.PointingHandCursor)
406 button.setStyleSheet(
407 "QToolButton{border:none;background:transparent;"
408 "padding:0px;margin:0px;font-size:7px;}"
409 "QToolButton:hover{background:palette(alternate-base);}"
410 "QToolButton:pressed{background:palette(highlight);"
411 "color:palette(highlighted-text);}"
412 )
414 self._up_button.clicked.connect(
415 self._step_up_without_selection
416 )
417 self._down_button.clicked.connect(
418 self._step_down_without_selection
419 )
421 self.setToolTip(
422 tr("▲▼をクリック:{float:g}ずつ変更/").format(float=float(step))
423 + (
424 tr("半角整数を直接入力できます。")
425 if self.decimals() == 0
426 else tr("半角数値を直接入力できます。")
427 )
428 )
429 self.valueChanged.connect(self._spin_changed)
430 slider.valueChanged.connect(self._slider_changed)
431 self._slider_changed(slider.value())
433 def _clear_number_selection(self):
434 editor = self.lineEdit()
435 if editor is None:
436 return
437 editor.deselect()
438 editor.setCursorPosition(len(editor.text()))
440 # ▲▼ボタンにフォーカスを残さず、編集欄の青い選択も解除する。
441 self._up_button.clearFocus()
442 self._down_button.clearFocus()
444 def _step_up_without_selection(self):
445 self.stepUp()
446 QTimer.singleShot(0, self._clear_number_selection)
448 def _step_down_without_selection(self):
449 self.stepDown()
450 QTimer.singleShot(0, self._clear_number_selection)
452 def resizeEvent(self, event):
453 super().resizeEvent(event)
454 button_width = 9
455 half_height = max(8, self.height() // 2)
456 x = self.width() - button_width - 1
457 self._up_button.setGeometry(
458 x, 1, button_width, half_height - 1
459 )
460 self._down_button.setGeometry(
461 x,
462 half_height,
463 button_width,
464 self.height() - half_height - 1,
465 )
466 self._up_button.raise_()
467 self._down_button.raise_()
469 def validate(self, input_text, position):
470 """全角文字・英字・記号を拒否し、ASCII数値だけ許可する。"""
471 text = str(input_text)
472 if text == "": 472 ↛ 473line 472 didn't jump to line 473 because the condition on line 472 was never true
473 return (
474 QValidator.State.Intermediate,
475 text,
476 position,
477 )
479 allowed_characters = (
480 "0123456789."
481 if self.decimals() > 0
482 else "0123456789"
483 )
484 if any( 484 ↛ 488line 484 didn't jump to line 488 because the condition on line 484 was never true
485 character not in allowed_characters
486 for character in text
487 ):
488 return (
489 QValidator.State.Invalid,
490 text,
491 position,
492 )
493 if text.count(".") > 1: 493 ↛ 494line 493 didn't jump to line 494 because the condition on line 493 was never true
494 return (
495 QValidator.State.Invalid,
496 text,
497 position,
498 )
500 if "." in text: 500 ↛ 501line 500 didn't jump to line 501 because the condition on line 500 was never true
501 integer_part, fraction_part = text.split(".", 1)
502 if len(fraction_part) > self.decimals():
503 return (
504 QValidator.State.Invalid,
505 text,
506 position,
507 )
508 if integer_part == "":
509 return (
510 QValidator.State.Intermediate,
511 text,
512 position,
513 )
515 try:
516 value = float(text)
517 except ValueError:
518 return (
519 QValidator.State.Intermediate,
520 text,
521 position,
522 )
524 if value < self.minimum() or value > self.maximum(): 524 ↛ 525line 524 didn't jump to line 525 because the condition on line 524 was never true
525 return (
526 QValidator.State.Invalid,
527 text,
528 position,
529 )
531 state = (
532 QValidator.State.Intermediate
533 if text.endswith(".")
534 else QValidator.State.Acceptable
535 )
536 return (state, text, position)
538 def valueFromText(self, text):
539 try:
540 return float(str(text))
541 except ValueError:
542 return float(self.value())
544 def textFromValue(self, value):
545 if self.decimals() > 0: 545 ↛ 546line 545 didn't jump to line 546 because the condition on line 545 was never true
546 return f"{float(value):.1f}"
547 return str(int(round(float(value))))
549 def _spin_changed(self, value):
550 slider_value = int(round(float(value) * self.scale))
551 slider_value = max(
552 self.slider.minimum(),
553 min(self.slider.maximum(), slider_value),
554 )
555 if self.slider.value() != slider_value: 555 ↛ exitline 555 didn't return from function '_spin_changed' because the condition on line 555 was always true
556 self.slider.setValue(slider_value)
558 def _slider_changed(self, value):
559 self.blockSignals(True)
560 self.setValue(float(value) / self.scale)
561 self.blockSignals(False)
563 def setDisplayValue(self, value):
564 self.blockSignals(True)
565 self.setValue(float(value))
566 self.blockSignals(False)
568 def wheelEvent(self, event):
569 event.ignore()
573class ClickableValueLabel(QLabel):
574 """数値表示をクリックした時だけ設定ポップアップを開くラベル。"""
576 clicked = Signal(QPoint)
578 def __init__(self, text="", parent=None):
579 super().__init__(text, parent)
580 self.setCursor(Qt.CursorShape.PointingHandCursor)
582 def mouseReleaseEvent(self, event):
583 if event.button() == Qt.MouseButton.LeftButton:
584 self.clicked.emit(event.globalPosition().toPoint())
585 event.accept()
586 return
587 super().mouseReleaseEvent(event)
590class LineTaperCurvePopup(QDialog):
591 """ラインの入り/抜きカーブをその場で調整するポップアップ。"""
593 curveChanged = Signal(int)
595 def __init__(self, title, value, parent=None):
596 super().__init__(
597 parent,
598 Qt.WindowType.Popup | Qt.WindowType.FramelessWindowHint,
599 )
600 self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
601 self.setMinimumWidth(260)
602 layout = QVBoxLayout(self)
603 layout.setContentsMargins(8, 8, 8, 8)
604 layout.setSpacing(5)
605 layout.addWidget(QLabel(f"<b>{title}</b>"))
606 self.value_label = QLabel()
607 self.value_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
608 layout.addWidget(self.value_label)
609 self.slider = QSlider(Qt.Orientation.Horizontal)
610 self.slider.setRange(20, 400)
611 self.slider.setValue(max(20, min(400, int(value))))
612 self.slider.valueChanged.connect(self._value_changed)
613 layout.addWidget(self.slider)
614 note = QLabel(tr("小さいほど緩やか、大きいほど先端付近で急に変化します。"))
615 note.setWordWrap(True)
616 note.setStyleSheet("font-size:10px;")
617 layout.addWidget(note)
618 self._value_changed(self.slider.value())
620 def _value_changed(self, value):
621 self.value_label.setText(f"{value / 100:.2f}")
622 self.curveChanged.emit(int(value))
625class TweenCommandPopup(QDialog):
626 """自由変形/メッシュ変形トゥイーン中の操作パネル。"""
628 commitRequested = Signal()
629 cancelRequested = Signal()
630 rotateLeftRequested = Signal()
631 rotateRightRequested = Signal()
632 reverseChanged = Signal(bool)
634 def __init__(
635 self,
636 mode="free",
637 mesh_cols=4,
638 mesh_rows=4,
639 parent=None,
640 reverse=False,
641 ):
642 super().__init__(
643 parent,
644 Qt.WindowType.Tool
645 | Qt.WindowType.WindowStaysOnTopHint
646 | Qt.WindowType.CustomizeWindowHint
647 | Qt.WindowType.WindowTitleHint,
648 )
649 self.mode = "mesh" if mode == "mesh" else "free"
650 mode_name = tr("メッシュ変形") if self.mode == "mesh" else tr("自由変形")
651 self.setWindowTitle(tr("長方形選択:{name}トゥイーン").format(name=mode_name))
652 self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
653 self.setModal(False)
654 self.setMinimumWidth(260)
656 layout = QVBoxLayout(self)
657 layout.setContentsMargins(8, 8, 8, 8)
658 layout.setSpacing(5)
660 if self.mode == "mesh": 660 ↛ 661line 660 didn't jump to line 661 because the condition on line 660 was never true
661 description = (
662 tr("<b>メッシュ変形トゥイーン</b><br>横{int}×縦{int2}の格子点を操作して、最後のコマの形を指定してください。").format(int=int(mesh_cols), int2=int(mesh_rows))
663 )
664 else:
665 description = (
666 "<b>自由変形トゥイーン</b><br>"
667 "白い点や枠を操作して、最後のコマの形を指定してください。"
668 )
669 label = QLabel(description)
670 label.setWordWrap(True)
671 layout.addWidget(label)
673 self.reverse_generation = QCheckBox(
674 tr("逆生成(◆ーーーー│)")
675 )
676 self.reverse_generation.setChecked(
677 bool(reverse)
678 )
679 self.reverse_generation.setToolTip(
680 tr("ON:操作中の変形形状をキーフレーム側へ置き、"
681 "元の初期形状をラストコマ側へ置きます。")
682 )
683 layout.addWidget(self.reverse_generation)
685 self.direction_note = QLabel()
686 self.direction_note.setWordWrap(True)
687 self.direction_note.setStyleSheet(
688 "font-size:10px;"
689 )
690 layout.addWidget(self.direction_note)
691 self.reverse_generation.toggled.connect(
692 self._sync_direction_note
693 )
694 self.reverse_generation.toggled.connect(
695 self.reverseChanged
696 )
697 self._sync_direction_note(
698 self.reverse_generation.isChecked()
699 )
701 rotate_row = QHBoxLayout()
702 rotate_left = QPushButton(tr("左へ90°"))
703 rotate_right = QPushButton(tr("右へ90°"))
704 rotate_left.clicked.connect(self.rotateLeftRequested)
705 rotate_right.clicked.connect(self.rotateRightRequested)
706 rotate_row.addWidget(rotate_left)
707 rotate_row.addWidget(rotate_right)
708 layout.addLayout(rotate_row)
710 commit = QPushButton(tr("{name}を確定してトゥイーン作成").format(name=mode_name))
711 cancel = QPushButton(tr("キャンセル"))
712 commit.clicked.connect(self.commitRequested)
713 cancel.clicked.connect(self.cancelRequested)
714 layout.addWidget(commit)
715 layout.addWidget(cancel)
717 def _sync_direction_note(self, reverse):
718 if reverse: 718 ↛ 719line 718 didn't jump to line 719 because the condition on line 718 was never true
719 self.direction_note.setText(
720 tr("逆生成:現在操作している変形形状を"
721 "キーフレーム側の◆へ配置し、"
722 "ラストコマ側を元の初期形状にします。")
723 )
724 else:
725 self.direction_note.setText(
726 tr("通常生成:キーフレーム側を元の初期形状、"
727 "ラストコマ側を現在操作している変形形状にします。")
728 )
731class HSVColorWheel(QWidget):
732 colorChanged = Signal(QColor)
733 modeChanged = Signal(str)
734 hueModeChanged = Signal(str)
736 #: 内側のピッカー形状と色相ピッカーの形状は独立して選択できる。
737 MODES = ("HSV", "HLS")
738 HUE_MODES = ("RING", "BAR")
739 DEFAULT_MODE = "HSV"
740 DEFAULT_HUE_MODE = "RING"
742 def __init__(self, parent=None):
743 super().__init__(parent)
744 self._color = QColor("black")
745 self._mode = self.DEFAULT_MODE
746 self._hue_mode = self.DEFAULT_HUE_MODE
747 self._cache_key = None
748 self._cache_image = QImage()
749 self._drag_part = None
750 self.setMinimumSize(160, 160)
751 self.setMaximumHeight(200)
752 self.setSizePolicy(
753 QSizePolicy.Policy.Expanding,
754 QSizePolicy.Policy.Fixed,
755 )
756 self._update_tooltip()
758 def mode(self):
759 return self._mode
761 def setMode(self, mode):
762 mode = str(mode).upper()
763 # 以前の一体型モード名も読み替えられるようにする。
764 if mode == "HSV_RING": 764 ↛ 765line 764 didn't jump to line 765 because the condition on line 764 was never true
765 self.setHueMode("RING")
766 mode = "HSV"
767 if mode not in self.MODES or mode == self._mode:
768 return
769 self._mode = mode
770 self._cache_key = None
771 self._update_tooltip()
772 self.update()
773 self.modeChanged.emit(self._mode)
775 def hueMode(self):
776 return self._hue_mode
778 def setHueMode(self, mode):
779 mode = str(mode).upper()
780 if mode not in self.HUE_MODES or mode == self._hue_mode:
781 return
782 self._hue_mode = mode
783 self._cache_key = None
784 self._update_tooltip()
785 self.update()
786 self.hueModeChanged.emit(self._hue_mode)
788 def _update_tooltip(self):
789 hue_picker = tr("外側の色相リング") if self._hue_mode == "RING" else tr("上の色相バー")
790 inner_picker = (
791 tr("内側の三角で輝度(L)と彩度(S)")
792 if self._mode == "HLS" else
793 tr("内側の四角で彩度と明度")
794 )
795 self.setToolTip(tr("{picker}と、{picker2}を選択します。").format(picker=hue_picker, picker2=inner_picker))
797 def setColor(self, color):
798 color = QColor(color)
799 if not color.isValid(): 799 ↛ 800line 799 didn't jump to line 800 because the condition on line 799 was never true
800 return
801 self._color = color
802 self.update()
804 def _wheel_geometry(self):
805 if self._hue_mode == "RING":
806 available_height = max(1.0, self.height() - 4.0)
807 diameter = min(
808 max(1.0, self.width() - 4.0),
809 max(1.0, available_height - 2.0),
810 )
811 outer = QRectF(
812 (self.width() - diameter) / 2.0,
813 2.0 + (available_height - diameter) / 2.0,
814 diameter,
815 diameter,
816 )
817 outer_radius = diameter / 2.0
818 ring_width = max(12.0, min(22.0, outer_radius * 0.20))
819 inner_radius = max(1.0, outer_radius - ring_width - 3.0)
820 square_side = inner_radius * math.sqrt(2.0)
821 square = QRectF(
822 outer.center().x() - square_side / 2.0,
823 outer.center().y() - square_side / 2.0,
824 square_side,
825 square_side,
826 )
827 return outer, square
828 hue_bar = QRectF(
829 4.0,
830 3.0,
831 max(24.0, self.width() - 8.0),
832 18.0,
833 )
834 available_width = max(1.0, self.width() - 8.0)
835 available_height = max(1.0, self.height() - 29.0)
836 square_side = min(available_width, available_height)
837 square = QRectF(
838 (self.width() - square_side) / 2.0,
839 25.0,
840 square_side,
841 square_side,
842 )
843 return hue_bar, square
845 def _ring_metrics(self):
846 outer, _square = self._wheel_geometry()
847 outer_radius = outer.width() / 2.0
848 ring_width = max(12.0, min(22.0, outer_radius * 0.20))
849 return outer.center(), outer_radius, outer_radius - ring_width
851 @staticmethod
852 def _ring_hue(center, position):
853 angle = math.atan2(
854 position.x() - center.x(),
855 center.y() - position.y(),
856 )
857 return int(round((math.degrees(angle) % 360.0) * 359.0 / 360.0))
859 def _triangle_vertices(self):
860 """HLS三角形の頂点。上=純色、左下=黒、右下=白。"""
861 _hue_bar, square = self._wheel_geometry()
862 top = QPointF(square.center().x(), square.top())
863 bottom_left = QPointF(square.left(), square.bottom())
864 bottom_right = QPointF(square.right(), square.bottom())
865 return top, bottom_left, bottom_right
867 @staticmethod
868 def _barycentric(point, top, bottom_left, bottom_right):
869 denom = (
870 (bottom_left.y() - bottom_right.y()) * (top.x() - bottom_right.x())
871 + (bottom_right.x() - bottom_left.x()) * (top.y() - bottom_right.y())
872 )
873 if abs(denom) < 1e-9: 873 ↛ 874line 873 didn't jump to line 874 because the condition on line 873 was never true
874 return 0.0, 0.0, 1.0
875 w_top = (
876 (bottom_left.y() - bottom_right.y()) * (point.x() - bottom_right.x())
877 + (bottom_right.x() - bottom_left.x()) * (point.y() - bottom_right.y())
878 ) / denom
879 w_left = (
880 (bottom_right.y() - top.y()) * (point.x() - bottom_right.x())
881 + (top.x() - bottom_right.x()) * (point.y() - bottom_right.y())
882 ) / denom
883 w_right = 1.0 - w_top - w_left
884 return w_top, w_left, w_right
886 def _triangle_color(self, w_top, w_left, w_right):
887 """頂点重みから色を合成する(黒は寄与なし)。"""
888 hue = max(0, self._color.hsvHue())
889 pure = QColor.fromHsv(hue, 255, 255)
890 red = w_top * pure.red() + w_right * 255.0
891 green = w_top * pure.green() + w_right * 255.0
892 blue = w_top * pure.blue() + w_right * 255.0
893 clamp = lambda v: max(0, min(255, int(round(v))))
894 return QColor(clamp(red), clamp(green), clamp(blue))
896 def _triangle_point_for_color(self):
897 """現在色に対応する三角形内の座標を最小二乗で求める。"""
898 top, bottom_left, bottom_right = self._triangle_vertices()
899 hue = max(0, self._color.hsvHue())
900 pure = QColor.fromHsv(hue, 255, 255)
901 hr, hg, hb = pure.red(), pure.green(), pure.blue()
902 cr, cg, cb = self._color.red(), self._color.green(), self._color.blue()
903 s_aa = hr * hr + hg * hg + hb * hb
904 s_ac = (hr + hg + hb) * 255.0
905 s_cc = 3.0 * 255.0 * 255.0
906 b1 = hr * cr + hg * cg + hb * cb
907 b2 = 255.0 * (cr + cg + cb)
908 det = s_aa * s_cc - s_ac * s_ac
909 if abs(det) < 1e-6:
910 w_top, w_right = 0.0, 1.0
911 else:
912 w_top = (b1 * s_cc - b2 * s_ac) / det
913 w_right = (s_aa * b2 - s_ac * b1) / det
914 w_top = max(0.0, w_top)
915 w_right = max(0.0, w_right)
916 total = w_top + w_right
917 if total > 1.0:
918 w_top /= total
919 w_right /= total
920 w_left = 1.0 - w_top - w_right
921 return QPointF(
922 w_top * top.x() + w_left * bottom_left.x() + w_right * bottom_right.x(),
923 w_top * top.y() + w_left * bottom_left.y() + w_right * bottom_right.y(),
924 )
926 def resizeEvent(self, event):
927 self._cache_key = None
928 super().resizeEvent(event)
930 def _wheel_image(self):
931 hue_bar, square = self._wheel_geometry()
932 width, height = self.width(), self.height()
933 hue = max(0, self._color.hsvHue())
934 cache_key = (width, height, hue, self._mode, self._hue_mode)
935 if self._cache_key == cache_key:
936 return self._cache_image
937 image = QImage(width, height, QImage.Format.Format_ARGB32)
938 image.fill(Qt.GlobalColor.transparent)
939 if self._mode == "HLS":
940 top, bottom_left, bottom_right = self._triangle_vertices()
941 if self._hue_mode == "RING":
942 ring_center, ring_outer, ring_inner = self._ring_metrics()
943 for y in range(height):
944 for x in range(width):
945 point = QPointF(x + 0.5, y + 0.5)
946 if self._hue_mode == "RING":
947 distance = math.hypot(
948 point.x() - ring_center.x(),
949 point.y() - ring_center.y(),
950 )
951 if ring_inner <= distance <= ring_outer:
952 image.setPixelColor(
953 x, y, QColor.fromHsv(
954 self._ring_hue(ring_center, point), 255, 255
955 )
956 )
957 continue
958 elif hue_bar.contains(point):
959 bar_hue = int(round(
960 359.0
961 * (x + 0.5 - hue_bar.left())
962 / hue_bar.width()
963 ))
964 image.setPixelColor(
965 x, y, QColor.fromHsv(bar_hue, 255, 255)
966 )
967 continue
968 if self._mode == "HLS":
969 if not square.contains(x + 0.5, y + 0.5):
970 continue
971 w_top, w_left, w_right = self._barycentric(
972 QPointF(x + 0.5, y + 0.5),
973 top,
974 bottom_left,
975 bottom_right,
976 )
977 if w_top < 0 or w_left < 0 or w_right < 0:
978 continue
979 image.setPixelColor(
980 x, y, self._triangle_color(w_top, w_left, w_right)
981 )
982 elif square.contains(point):
983 saturation = int(round(
984 255.0 * (x + 0.5 - square.left()) / square.width()
985 ))
986 value = int(round(
987 255.0 * (square.bottom() - (y + 0.5)) / square.height()
988 ))
989 image.setPixelColor(
990 x,
991 y,
992 QColor.fromHsv(
993 hue,
994 max(0, min(255, saturation)),
995 max(0, min(255, value)),
996 ),
997 )
998 self._cache_key = cache_key
999 self._cache_image = image
1000 return image
1002 def paintEvent(self, event):
1003 painter = QPainter(self)
1004 painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
1005 painter.drawImage(0, 0, self._wheel_image())
1006 hue_bar, square = self._wheel_geometry()
1007 hue = max(0, self._color.hsvHue())
1008 hue_x = (
1009 hue_bar.left()
1010 + hue_bar.width() * hue / 359.0
1011 )
1012 if self._mode == "HLS": 1012 ↛ 1013line 1012 didn't jump to line 1013 because the condition on line 1012 was never true
1013 marker_point = self._triangle_point_for_color()
1014 else:
1015 marker_point = QPointF(
1016 square.left()
1017 + square.width() * self._color.hsvSaturation() / 255.0,
1018 square.bottom()
1019 - square.height() * self._color.value() / 255.0,
1020 )
1021 painter.setPen(QPen(QColor("#555555"), 1))
1022 painter.setBrush(Qt.BrushStyle.NoBrush)
1023 if self._hue_mode == "RING": 1023 ↛ 1028line 1023 didn't jump to line 1028 because the condition on line 1023 was always true
1024 ring_center, ring_outer, ring_inner = self._ring_metrics()
1025 painter.drawEllipse(hue_bar)
1026 painter.drawEllipse(ring_center, ring_inner, ring_inner)
1027 else:
1028 painter.drawRect(hue_bar.adjusted(-1, -1, 1, 1))
1029 if self._mode == "HLS": 1029 ↛ 1030line 1029 didn't jump to line 1030 because the condition on line 1029 was never true
1030 top, bottom_left, bottom_right = self._triangle_vertices()
1031 painter.drawPolygon(QPolygonF([top, bottom_left, bottom_right]))
1032 else:
1033 painter.drawRect(square.adjusted(-1, -1, 1, 1))
1034 painter.setBrush(Qt.BrushStyle.NoBrush)
1035 painter.setPen(QPen(QColor("white"), 2))
1036 if self._hue_mode == "RING": 1036 ↛ 1045line 1036 didn't jump to line 1045 because the condition on line 1036 was always true
1037 angle = math.radians(hue)
1038 marker_radius = (ring_outer + ring_inner) / 2.0 # pyright: ignore[reportOperatorIssue]
1039 hue_point = QPointF(
1040 ring_center.x() + math.sin(angle) * marker_radius,
1041 ring_center.y() - math.cos(angle) * marker_radius,
1042 )
1043 painter.drawEllipse(hue_point, 4, 4)
1044 else:
1045 painter.drawRect(QRectF(
1046 hue_x - 3.0,
1047 hue_bar.top() - 2.0,
1048 6.0,
1049 hue_bar.height() + 4.0,
1050 ))
1051 painter.drawEllipse(marker_point, 5, 5)
1052 painter.setPen(QPen(QColor("black"), 1))
1053 if self._hue_mode == "RING": 1053 ↛ 1056line 1053 didn't jump to line 1056 because the condition on line 1053 was always true
1054 painter.drawEllipse(hue_point, 5, 5)
1055 else:
1056 painter.drawRect(QRectF(
1057 hue_x - 4.0,
1058 hue_bar.top() - 3.0,
1059 8.0,
1060 hue_bar.height() + 6.0,
1061 ))
1062 painter.drawEllipse(marker_point, 6, 6)
1063 if self._hue_mode != "RING": 1063 ↛ 1064line 1063 didn't jump to line 1064 because the condition on line 1063 was never true
1064 painter.setPen(QColor("#333333"))
1065 painter.drawText(
1066 QRectF(0, 4, 18, 22),
1067 Qt.AlignmentFlag.AlignCenter,
1068 "H",
1069 )
1071 def _part_at(self, position):
1072 hue_bar, square = self._wheel_geometry()
1073 if self._hue_mode == "RING": 1073 ↛ 1083line 1073 didn't jump to line 1083 because the condition on line 1073 was always true
1074 center, outer_radius, inner_radius = self._ring_metrics()
1075 distance = math.hypot(
1076 position.x() - center.x(), position.y() - center.y()
1077 )
1078 if inner_radius - 3.0 <= distance <= outer_radius + 3.0:
1079 return "hue"
1080 if square.contains(position): 1080 ↛ 1082line 1080 didn't jump to line 1082 because the condition on line 1080 was always true
1081 return "sv"
1082 return None
1083 if hue_bar.adjusted(-3, -4, 3, 4).contains(position):
1084 return "hue"
1085 if square.contains(position):
1086 return "sv"
1087 return None
1089 def _select_triangle(self, position):
1090 top, bottom_left, bottom_right = self._triangle_vertices()
1091 w_top, w_left, w_right = self._barycentric(
1092 position, top, bottom_left, bottom_right
1093 )
1094 # 三角形の外をドラッグしても近い辺へ射影する。
1095 w_top = max(0.0, w_top)
1096 w_left = max(0.0, w_left)
1097 w_right = max(0.0, w_right)
1098 total = w_top + w_left + w_right
1099 if total <= 1e-9:
1100 return
1101 w_top, w_left, w_right = w_top / total, w_left / total, w_right / total
1102 self._color = self._triangle_color(w_top, w_left, w_right)
1103 self.update()
1104 self.colorChanged.emit(QColor(self._color))
1106 def _select_at(self, position, part=None):
1107 if not self.isEnabled():
1108 return
1109 hue_bar, square = self._wheel_geometry()
1110 part = part or self._part_at(position)
1111 if part == "sv" and self._mode == "HLS":
1112 self._select_triangle(position)
1113 return
1114 if part == "hue":
1115 if self._hue_mode == "RING":
1116 hue = self._ring_hue(hue_bar.center(), position)
1117 else:
1118 hue = int(round(
1119 359.0
1120 * (position.x() - hue_bar.left())
1121 / hue_bar.width()
1122 ))
1123 hue = max(0, min(359, hue))
1124 saturation = self._color.hsvSaturation()
1125 value = self._color.value() or 255
1126 elif part == "sv":
1127 hue = max(0, self._color.hsvHue())
1128 saturation = int(round(
1129 255.0
1130 * (position.x() - square.left())
1131 / square.width()
1132 ))
1133 value = int(round(
1134 255.0
1135 * (square.bottom() - position.y())
1136 / square.height()
1137 ))
1138 saturation = max(0, min(255, saturation))
1139 value = max(0, min(255, value))
1140 else:
1141 return
1142 self._color = QColor.fromHsv(hue, saturation, value)
1143 if part == "hue":
1144 self._cache_key = None
1145 self.update()
1146 self.colorChanged.emit(QColor(self._color))
1148 def mousePressEvent(self, event):
1149 if event.button() == Qt.MouseButton.LeftButton:
1150 self._drag_part = self._part_at(event.position())
1151 if self._drag_part is not None:
1152 self._select_at(event.position(), self._drag_part)
1153 event.accept()
1154 return
1155 super().mousePressEvent(event)
1157 def mouseMoveEvent(self, event):
1158 if event.buttons() & Qt.MouseButton.LeftButton:
1159 if self._drag_part is not None:
1160 self._select_at(event.position(), self._drag_part)
1161 event.accept()
1162 return
1163 super().mouseMoveEvent(event)
1165 def mouseReleaseEvent(self, event):
1166 self._drag_part = None
1167 super().mouseReleaseEvent(event)
1170def _list_value(value):
1171 """Normalize an untrusted parser field to a plain list."""
1172 return list(value) if isinstance(value, (list, tuple)) else []
1175def sheet_columns_from_parsed(parsed) -> "list[dict[str, Any]]":
1176 """解析したタイムシートを、レイヤーへ紐づけられる「列」の一覧にする。
1178 XDTS は列をそのまま、東映の複数トラックはトラックごと、1列だけの形式
1179 (After Effects など)は1列として返す。貼り付けダイアログと、タイムライン
1180 への直接の貼り付けで共通。
1181 """
1182 if parsed is None: 1182 ↛ 1183line 1182 didn't jump to line 1183 because the condition on line 1182 was never true
1183 return []
1184 columns = [
1185 dict(item) for item in _list_value(parsed.get("sheet_columns"))
1186 if isinstance(item, dict)
1187 ]
1188 if columns: 1188 ↛ 1189line 1188 didn't jump to line 1189 because the condition on line 1188 was never true
1189 return columns
1190 tracks = [
1191 dict(item) for item in _list_value(parsed.get("tracks"))
1192 if isinstance(item, dict)
1193 ]
1194 if tracks: 1194 ↛ 1195line 1194 didn't jump to line 1195 because the condition on line 1194 was never true
1195 return [
1196 dict(
1197 track,
1198 uid=f"cell:{int(track.get('track_no', index))}",
1199 field_id=0,
1200 group="CELL",
1201 bindable=True,
1202 display_values=[
1203 "×" if state is None else str(int(state))
1204 for state in _list_value(track.get("states"))
1205 ],
1206 )
1207 for index, track in enumerate(tracks)
1208 ]
1209 states = _list_value(parsed.get("states"))
1210 if not states: 1210 ↛ 1211line 1210 didn't jump to line 1211 because the condition on line 1210 was never true
1211 return []
1212 return [{
1213 "uid": "cell:0",
1214 "field_id": 0,
1215 "track_no": 0,
1216 "group": "CELL",
1217 "name": str(parsed.get("track_name", tr("セル"))),
1218 "states": states,
1219 "display_values": [
1220 "×" if state is None else str(int(state))
1221 for state in states
1222 ],
1223 "bindable": True,
1224 "blank_label_count": int(parsed.get("blank_label_count", 0)),
1225 }]
1228def default_layer_bindings(columns, layers, active_layer_index):
1229 """列→レイヤーの既定の紐づけ ``{列uid: レイヤー番号}``。
1231 列名と同じ名前のレイヤーへ紐づける。1つも合わなければ、最初の列を
1232 選択中のレイヤーへ紐づける。``layers`` は ``[(レイヤー番号, 名前), …]``。
1233 """
1234 bindings = {}
1235 bindable = [column for column in columns if bool(column.get("bindable", False))]
1236 used_layers = set()
1237 for column in bindable:
1238 name = str(column.get("name", "")).strip().casefold()
1239 match = next(
1240 (
1241 index for index, layer_name in layers
1242 if index not in used_layers
1243 and layer_name.strip().casefold() == name
1244 ),
1245 None,
1246 )
1247 if match is not None:
1248 bindings[str(column.get("uid", ""))] = match
1249 used_layers.add(match)
1250 if not bindings and bindable and any(
1251 index == active_layer_index for index, _name in layers
1252 ):
1253 bindings[str(bindable[0].get("uid", ""))] = active_layer_index
1254 return bindings
1257class TimeRemapPasteDialog(QDialog):
1258 """コピー情報をタイムシート表へ貼り付けて確認する画面。"""
1260 def __init__(self, initial_text="", parent=None, *, parse_text=None, canvas=None):
1261 """``parse_text`` and ``canvas`` are the collaborators this dialog needs.
1263 They used to be discovered by name off ``self.parent()``. That coupling
1264 was invisible to every tool: moving or renaming the owning method left
1265 the dialog silently reporting "解析機能を取得できません" instead of
1266 failing. They are now passed in, so a missing collaborator is a
1267 construction-site decision rather than a runtime surprise.
1268 """
1269 super().__init__(parent)
1270 self._parse_text = parse_text
1271 self._canvas = canvas
1272 self.setWindowTitle(tr("タイムリマップをタイムシートへ貼り付け"))
1273 self.resize(760, 650)
1274 self._parsed_preview = None
1275 self._parsed_source: dict[str, Any] | None = None
1276 self._parsed_raw_text = None
1277 self._included_rows = []
1278 self._column_layer_bindings = {}
1279 self._updating_preview_items = False
1280 self.setAcceptDrops(True)
1282 layout = QVBoxLayout(self)
1283 layout.setContentsMargins(8, 8, 8, 8)
1284 layout.setSpacing(5)
1286 header = QHBoxLayout()
1287 title = QLabel(tr("<b>読み込みデータ</b>"))
1288 paste_button = QPushButton(
1289 tr("クリップボードをタイムシートへ貼付")
1290 )
1291 xdts_button = QPushButton(tr("XDTS/TDTSを読み込む…"))
1292 clear_button = QPushButton(tr("クリア"))
1293 paste_button.clicked.connect(self._paste_clipboard)
1294 xdts_button.clicked.connect(self._load_xdts_file)
1295 clear_button.clicked.connect(self.text_clear)
1296 header.addWidget(title)
1297 header.addStretch(1)
1298 header.addWidget(xdts_button)
1299 header.addWidget(paste_button)
1300 header.addWidget(clear_button)
1301 layout.addLayout(header)
1303 self.text_edit = QPlainTextEdit()
1304 self.text_edit.setPlaceholderText(
1305 tr("After Effects/ToeiDigitalTimeSheetのコピー情報を貼り付けるか、"
1306 "XDTSファイルを読み込みます。XDTSはこの画面へドロップできます。")
1307 )
1308 self.text_edit.setLineWrapMode(
1309 QPlainTextEdit.LineWrapMode.NoWrap
1310 )
1311 self.text_edit.setMaximumHeight(150)
1312 # ファイルドロップはダイアログ全体で一貫して処理する。
1313 self.text_edit.setAcceptDrops(False)
1314 layout.addWidget(self.text_edit)
1316 sheet_header = QHBoxLayout()
1317 sheet_title = QLabel(tr("<b>タイムシートプレビュー</b>"))
1318 self.preview_status = QLabel(tr("データ待機中"))
1319 self.preview_status.setAlignment(
1320 Qt.AlignmentFlag.AlignRight
1321 | Qt.AlignmentFlag.AlignVCenter
1322 )
1323 sheet_header.addWidget(sheet_title)
1324 sheet_header.addStretch(1)
1325 sheet_header.addWidget(self.preview_status)
1326 layout.addLayout(sheet_header)
1328 self.preview_table = QTableWidget(0, 2)
1329 self.preview_table.setHorizontalHeaderLabels([tr("使用"), "F"])
1330 self.preview_table.verticalHeader().setVisible(False)
1331 self.preview_table.setEditTriggers(
1332 QAbstractItemView.EditTrigger.NoEditTriggers
1333 )
1334 self.preview_table.setSelectionBehavior(
1335 QAbstractItemView.SelectionBehavior.SelectRows
1336 )
1337 self.preview_table.setAlternatingRowColors(True)
1338 self.preview_table.setShowGrid(True)
1339 self.preview_table.horizontalHeader().setSectionResizeMode(
1340 0, QHeaderView.ResizeMode.Fixed
1341 )
1342 self.preview_table.horizontalHeader().setSectionResizeMode(
1343 1, QHeaderView.ResizeMode.Fixed
1344 )
1345 self.preview_table.setColumnWidth(0, 48)
1346 self.preview_table.setColumnWidth(1, 60)
1347 self.preview_table.horizontalHeader().setMinimumHeight(58)
1348 self.preview_table.horizontalHeader().setSectionsClickable(True)
1349 self.preview_table.horizontalHeader().setStretchLastSection(True)
1350 self.preview_table.horizontalHeader().sectionClicked.connect(
1351 self._preview_header_clicked
1352 )
1353 self.preview_table.verticalHeader().setDefaultSectionSize(22)
1354 self.preview_table.setAcceptDrops(False)
1355 self.preview_table.viewport().setAcceptDrops(False)
1356 c = theme.palette()
1357 self.preview_table.setStyleSheet(
1358 "QTableWidget{gridline-color:%s;background:%s;"
1359 "alternate-background-color:%s;}"
1360 "QHeaderView::section{background:%s;padding:3px;"
1361 "border:1px solid %s;}"
1362 "QTableWidget::item:selected{background:%s;color:%s;}"
1363 % (c["border"], c["surface"], c["surface_alt"], c["surface_alt"],
1364 c["border"], c["accent"], c["accent_text"])
1365 )
1366 self.preview_table.itemChanged.connect(
1367 self._preview_item_changed
1368 )
1369 layout.addWidget(self.preview_table, 1)
1371 filter_row = QHBoxLayout()
1372 exclude_button = QPushButton(tr("選択行を除外"))
1373 include_button = QPushButton(tr("選択行を使用"))
1374 include_all_button = QPushButton(tr("全て使用"))
1375 exclude_button.clicked.connect(
1376 lambda: self._set_selected_rows_included(False)
1377 )
1378 include_button.clicked.connect(
1379 lambda: self._set_selected_rows_included(True)
1380 )
1381 include_all_button.clicked.connect(self._include_all_rows)
1382 filter_row.addWidget(exclude_button)
1383 filter_row.addWidget(include_button)
1384 filter_row.addWidget(include_all_button)
1385 filter_row.addStretch(1)
1386 layout.addLayout(filter_row)
1388 note = QLabel(
1389 tr("ACTION・CELL・CAMをXDTSの列ごとに表示します。"
1390 "CELL名をクリックするとPaintMaskAnimatorのレイヤーへ紐づけできます。"
1391 "「使用」を外した行は取り除き、後続を詰めて反映します。")
1392 )
1393 note.setWordWrap(True)
1394 note.setStyleSheet("font-size:10px;")
1395 layout.addWidget(note)
1397 buttons = QDialogButtonBox(
1398 QDialogButtonBox.StandardButton.Ok
1399 | QDialogButtonBox.StandardButton.Cancel
1400 )
1401 buttons.button(
1402 QDialogButtonBox.StandardButton.Ok
1403 ).setText(tr("このタイムシートをタイムラインへ反映"))
1404 buttons.accepted.connect(self._accept_if_valid)
1405 buttons.rejected.connect(self.reject)
1406 layout.addWidget(buttons)
1408 self._preview_timer = QTimer(self)
1409 self._preview_timer.setSingleShot(True)
1410 self._preview_timer.setInterval(140)
1411 self._preview_timer.timeout.connect(
1412 self._update_timesheet_preview
1413 )
1414 self.text_edit.textChanged.connect(
1415 self._preview_timer.start
1416 )
1417 self.text_edit.setPlainText(str(initial_text or ""))
1418 QTimer.singleShot(0, self._update_timesheet_preview)
1420 def _paste_clipboard(self):
1421 self.text_edit.setPlainText(
1422 QApplication.clipboard().text()
1423 )
1424 self.text_edit.setFocus()
1426 @staticmethod
1427 def _dropped_xdts_path(mime_data):
1428 if mime_data is None or not mime_data.hasUrls():
1429 return None
1430 for url in mime_data.urls():
1431 path = str(url.toLocalFile() or "")
1432 if Path(path).suffix.lower() in timesheet_file.SUFFIXES:
1433 return path
1434 return None
1436 def dragEnterEvent(self, event):
1437 if self._dropped_xdts_path(event.mimeData()):
1438 event.acceptProposedAction()
1439 return
1440 super().dragEnterEvent(event)
1442 def dragMoveEvent(self, event):
1443 if self._dropped_xdts_path(event.mimeData()):
1444 event.acceptProposedAction()
1445 return
1446 super().dragMoveEvent(event)
1448 def dropEvent(self, event):
1449 path = self._dropped_xdts_path(event.mimeData())
1450 if path:
1451 self._load_xdts_path(path)
1452 event.acceptProposedAction()
1453 return
1454 super().dropEvent(event)
1456 def _load_xdts_file(self):
1457 path, _ = QFileDialog.getOpenFileName(
1458 self,
1459 tr("タイムシートを読み込む(XDTS/TDTS)"),
1460 "",
1461 tr("タイムシート (*.xdts *.xtds *.tdts);;すべてのファイル (*)"),
1462 )
1463 if not path:
1464 return
1465 self._load_xdts_path(path)
1467 def _load_xdts_path(self, path):
1468 try:
1469 text = Path(path).read_text(encoding="utf-8-sig")
1470 except (OSError, UnicodeError) as exc:
1471 QMessageBox.warning(
1472 self, tr("XDTS読み込み"), tr("読み込めませんでした。\n\n{exc}").format(exc=exc)
1473 )
1474 return False
1475 self.text_edit.setPlainText(text)
1476 self.text_edit.setFocus()
1477 self._update_timesheet_preview()
1478 return True
1480 def text_clear(self):
1481 self.text_edit.clear()
1482 self.preview_table.setRowCount(0)
1483 self.preview_table.setColumnCount(2)
1484 self.preview_table.setHorizontalHeaderLabels([tr("使用"), "F"])
1485 self._parsed_preview = None
1486 self._parsed_source = None
1487 self._parsed_raw_text = None
1488 self._included_rows.clear()
1489 self._column_layer_bindings.clear()
1490 self.preview_status.setText(tr("データ待機中"))
1491 self.preview_status.setStyleSheet("color:palette(placeholder-text);")
1493 @staticmethod
1494 def _list_value(value):
1495 """Normalize an untrusted parser field to a plain list."""
1496 return list(value) if isinstance(value, (list, tuple)) else []
1498 @classmethod
1499 def _mapping_list(cls, value):
1500 """Keep only mapping-shaped entries from an untrusted parser field."""
1501 return [dict(item) for item in cls._list_value(value) if isinstance(item, dict)]
1503 def _sheet_columns(self):
1504 return sheet_columns_from_parsed(self._parsed_source)
1506 @staticmethod
1507 def _column_uid(column):
1508 return str(column.get("uid", ""))
1510 def _available_layers(self):
1511 canvas = self._canvas
1512 if canvas is None:
1513 return []
1514 frames = getattr(canvas, "frames", [])
1515 if not frames:
1516 return []
1517 frame_index = max(
1518 0, min(int(getattr(canvas, "current_frame", 0)), len(frames) - 1)
1519 )
1520 return [
1521 (index, str(layer.name))
1522 for index, layer in enumerate(frames[frame_index].layers)
1523 ]
1525 def _initialize_layer_bindings(self):
1526 self._column_layer_bindings.clear()
1527 self._column_layer_bindings.update(default_layer_bindings(
1528 self._sheet_columns(),
1529 self._available_layers(),
1530 int(getattr(self._canvas, "active_layer_index", 0)),
1531 ))
1533 def _save_current_included_rows(self):
1534 if self.preview_table.rowCount() <= 0:
1535 return
1536 self._included_rows = []
1537 for row in range(self.preview_table.rowCount()):
1538 item = self.preview_table.item(row, 0)
1539 self._included_rows.append(
1540 item is not None
1541 and item.checkState() == Qt.CheckState.Checked
1542 )
1544 def _set_column_layer_binding(self, uid, layer_index):
1545 uid = str(uid)
1546 if layer_index is None:
1547 self._column_layer_bindings.pop(uid, None)
1548 else:
1549 layer_index = int(layer_index)
1550 for other_uid, other_index in list(
1551 self._column_layer_bindings.items()
1552 ):
1553 if other_uid != uid and other_index == layer_index:
1554 self._column_layer_bindings.pop(other_uid, None)
1555 self._column_layer_bindings[uid] = layer_index
1556 self._update_preview_headers()
1557 self._update_preview_status()
1559 def _preview_header_clicked(self, section):
1560 column_index = int(section) - 2
1561 columns = self._sheet_columns()
1562 if not (0 <= column_index < len(columns)):
1563 return
1564 column = columns[column_index]
1565 if not bool(column.get("bindable", False)):
1566 return
1567 uid = self._column_uid(column)
1568 current = self._column_layer_bindings.get(uid)
1569 menu = QMenu(self)
1570 title = menu.addAction(
1571 tr("「{get}」の紐づけ先").format(get=column.get('name', 'セル'))
1572 )
1573 title.setEnabled(False)
1574 menu.addSeparator()
1575 clear_action = menu.addAction(tr("紐づけを解除(読み込まない)"))
1576 clear_action.setCheckable(True)
1577 clear_action.setChecked(current is None)
1578 clear_action.triggered.connect(
1579 lambda _checked=False, key=uid:
1580 self._set_column_layer_binding(key, None)
1581 )
1582 layers = self._available_layers()
1583 if layers:
1584 menu.addSeparator()
1585 for layer_index, layer_name in layers:
1586 action = menu.addAction(layer_name)
1587 action.setCheckable(True)
1588 action.setChecked(current == layer_index)
1589 action.triggered.connect(
1590 lambda _checked=False, key=uid, index=layer_index:
1591 self._set_column_layer_binding(key, index)
1592 )
1593 if not layers:
1594 unavailable = menu.addAction(tr("紐づけ可能なレイヤーがありません"))
1595 unavailable.setEnabled(False)
1596 menu.exec(QCursor.pos())
1598 def _update_preview_headers(self):
1599 columns = self._sheet_columns()
1600 layers = dict(self._available_layers())
1601 labels = [tr("使用"), "F"]
1602 for column in columns:
1603 group = str(column.get("group", "CELL"))
1604 name = str(column.get("name", ""))
1605 uid = self._column_uid(column)
1606 layer_index = self._column_layer_bindings.get(uid)
1607 if bool(column.get("bindable", False)):
1608 linked = layers.get(layer_index) if layer_index is not None else None
1609 link_text = f"→ {linked}" if linked else tr("クリックで紐づけ")
1610 labels.append(f"{group}\n{name}\n{link_text}")
1611 else:
1612 labels.append(f"{group}\n{name}")
1613 self.preview_table.setHorizontalHeaderLabels(labels)
1614 colors = {
1615 "ACTION": QColor("#e5edf7"),
1616 "CELL": QColor("#f7dddd"),
1617 "CAM": QColor("#dff3df"),
1618 }
1619 for index, column in enumerate(columns, 2):
1620 item = self.preview_table.horizontalHeaderItem(index)
1621 if item is None:
1622 continue
1623 group = str(column.get("group", "CELL"))
1624 item.setBackground(colors.get(group, QColor("#e7ecef")))
1625 if bool(column.get("bindable", False)):
1626 item.setToolTip(
1627 tr("クリックして読み込み先レイヤーを選択します。")
1628 )
1630 def _set_selected_rows_included(self, included):
1631 rows = sorted({index.row() for index in self.preview_table.selectedIndexes()})
1632 if not rows:
1633 row = self.preview_table.currentRow()
1634 rows = [row] if row >= 0 else []
1635 state = Qt.CheckState.Checked if included else Qt.CheckState.Unchecked
1636 for row in rows:
1637 item = self.preview_table.item(row, 0)
1638 if item is not None:
1639 item.setCheckState(state)
1641 def _include_all_rows(self):
1642 for row in range(self.preview_table.rowCount()):
1643 item = self.preview_table.item(row, 0)
1644 if item is not None:
1645 item.setCheckState(Qt.CheckState.Checked)
1647 def _preview_item_changed(self, item):
1648 if self._updating_preview_items or item.column() != 0:
1649 return
1650 self._save_current_included_rows()
1651 self._update_preview_status()
1653 def _update_preview_status(self):
1654 source = self._parsed_source
1655 if source is None:
1656 return
1657 columns = self._sheet_columns()
1658 if not columns:
1659 return
1660 duration = max(
1661 [len(self._list_value(column.get("display_values"))) for column in columns]
1662 + [len(self._included_rows)]
1663 )
1664 included = list(self._included_rows)
1665 if len(included) != duration:
1666 included = [True] * duration
1667 used_count = sum(bool(value) for value in included)
1668 linked_count = sum(
1669 1 for column in columns
1670 if self._column_uid(column) in self._column_layer_bindings
1671 )
1672 format_name = str(
1673 source.get("format", tr("タイムリマップ"))
1674 )
1675 self.preview_status.setText(
1676 tr("{name}/使用 {count}/除外 {max}/レイヤー紐づけ {count2}").format(name=format_name, count=used_count, max=max(0, duration - used_count), count2=linked_count)
1677 )
1678 self.preview_status.setStyleSheet("color:%s;" % theme.palette()["success"])
1680 def _populate_preview_table(self):
1681 source = self._parsed_source
1682 if source is None:
1683 return
1684 columns = self._sheet_columns()
1685 if not columns:
1686 return
1687 duration = max(
1688 len(self._list_value(column.get("display_values"))) for column in columns
1689 )
1690 start_frame = int(source.get("start_frame", 0))
1691 if len(self._included_rows) != duration:
1692 self._included_rows = [True] * duration
1694 self._updating_preview_items = True
1695 self.preview_table.setUpdatesEnabled(False)
1696 self.preview_table.blockSignals(True)
1697 try:
1698 self.preview_table.setColumnCount(2 + len(columns))
1699 self.preview_table.setRowCount(duration)
1700 self.preview_table.horizontalHeader().setSectionResizeMode(
1701 0, QHeaderView.ResizeMode.Fixed
1702 )
1703 self.preview_table.horizontalHeader().setSectionResizeMode(
1704 1, QHeaderView.ResizeMode.Fixed
1705 )
1706 for column_index in range(2, 2 + len(columns)):
1707 self.preview_table.horizontalHeader().setSectionResizeMode(
1708 column_index, QHeaderView.ResizeMode.Interactive
1709 )
1710 self.preview_table.setColumnWidth(column_index, 112)
1711 self._update_preview_headers()
1712 for row in range(duration):
1713 use_item = QTableWidgetItem()
1714 use_item.setFlags(
1715 Qt.ItemFlag.ItemIsEnabled
1716 | Qt.ItemFlag.ItemIsSelectable
1717 | Qt.ItemFlag.ItemIsUserCheckable
1718 )
1719 use_item.setCheckState(
1720 Qt.CheckState.Checked
1721 if self._included_rows[row]
1722 else Qt.CheckState.Unchecked
1723 )
1724 frame_item = QTableWidgetItem(str(start_frame + row + 1))
1725 for item in (use_item, frame_item):
1726 item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
1727 self.preview_table.setItem(row, 0, use_item)
1728 self.preview_table.setItem(row, 1, frame_item)
1729 for offset, column in enumerate(columns, 2):
1730 values = self._list_value(column.get("display_values"))
1731 value = values[row] if row < len(values) else ""
1732 cell_item = QTableWidgetItem(str(value))
1733 cell_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
1734 if str(value) in ("×", "○", "●"):
1735 cell_item.setForeground(QColor("#777777"))
1736 self.preview_table.setItem(row, offset, cell_item)
1737 if not self._included_rows[row]:
1738 for column_index in range(1, 2 + len(columns)):
1739 item = self.preview_table.item(row, column_index)
1740 if item is not None:
1741 item.setForeground(QColor("#999999"))
1742 item.setBackground(QColor("#eeeeee"))
1743 finally:
1744 self.preview_table.blockSignals(False)
1745 self.preview_table.setUpdatesEnabled(True)
1746 self._updating_preview_items = False
1747 self._update_preview_status()
1749 def _update_timesheet_preview(self):
1750 raw_text = self.text_edit.toPlainText()
1751 if not raw_text.strip(): 1751 ↛ 1752line 1751 didn't jump to line 1752 because the condition on line 1751 was never true
1752 self.text_clear()
1753 return
1754 if ( 1754 ↛ 1758line 1754 didn't jump to line 1758 because the condition on line 1754 was never true
1755 self._parsed_source is not None
1756 and raw_text == self._parsed_raw_text
1757 ):
1758 return
1760 parser = self._parse_text
1761 if not callable(parser): 1761 ↛ 1762line 1761 didn't jump to line 1762 because the condition on line 1761 was never true
1762 self.preview_status.setText(tr("解析機能を取得できません"))
1763 self.preview_status.setStyleSheet("color:%s;" % theme.palette()["error"])
1764 return
1766 try:
1767 parsed = parser(raw_text)
1768 if not isinstance(parsed, dict): 1768 ↛ 1784line 1768 didn't jump to line 1784 because the condition on line 1768 was always true
1769 raise TypeError("time-remap parse result must be a mapping")
1770 except (OperationError, ValueError, KeyError, IndexError, TypeError) as exc:
1771 # Malformed clipboard/timesheet data is expected here; show the
1772 # first line of the error in the preview status and log details.
1773 log.info("time-remap preview parse failed: %s", exc)
1774 self._parsed_preview = None
1775 self._parsed_source = None
1776 self._parsed_raw_text = None
1777 self._included_rows.clear()
1778 self._column_layer_bindings.clear()
1779 self.preview_table.setRowCount(0)
1780 message = str(exc).splitlines()[0]
1781 self.preview_status.setText(message)
1782 self.preview_status.setStyleSheet("color:%s;" % theme.palette()["error"])
1783 return
1784 self._parsed_source = parsed
1785 self._parsed_raw_text = raw_text
1786 self._parsed_preview = parsed
1787 columns = self._sheet_columns()
1788 duration = max(
1789 [len(self._list_value(column.get("display_values"))) for column in columns]
1790 + [0]
1791 )
1792 self._included_rows = [True] * duration
1793 self._initialize_layer_bindings()
1794 self._populate_preview_table()
1796 def parsed_result(self):
1797 if self._parsed_source is None:
1798 return None
1799 self._save_current_included_rows()
1800 columns = self._sheet_columns()
1801 if not columns:
1802 return None
1803 if not any(self._included_rows):
1804 return None
1805 result = dict(self._parsed_source)
1806 filtered_columns = []
1807 for column in columns:
1808 filtered_column = dict(column)
1809 states = self._list_value(column.get("states"))
1810 values = self._list_value(column.get("display_values"))
1811 if states:
1812 filtered_column["states"] = [
1813 state for state, keep in zip(states, self._included_rows)
1814 if keep
1815 ]
1816 filtered_column["display_values"] = [
1817 value for value, keep in zip(values, self._included_rows)
1818 if keep
1819 ]
1820 filtered_columns.append(filtered_column)
1821 result["sheet_columns"] = filtered_columns
1822 result["layer_bindings"] = dict(self._column_layer_bindings)
1823 primary = next(
1824 (
1825 column for column in filtered_columns
1826 if self._column_uid(column) in self._column_layer_bindings
1827 and column.get("states")
1828 ),
1829 next(
1830 (
1831 column for column in filtered_columns
1832 if column.get("states")
1833 ),
1834 None,
1835 ),
1836 )
1837 if primary is not None:
1838 result["states"] = list(primary.get("states", []))
1839 result["blank_label_count"] = int(
1840 primary.get("blank_label_count", 0)
1841 )
1842 result["track_name"] = str(primary.get("name", "セル欄"))
1843 start_frame = int(self._parsed_source.get("start_frame", 0))
1844 result["start_frame"] = start_frame
1845 used_count = sum(bool(value) for value in self._included_rows)
1846 result["end_frame"] = start_frame + used_count - 1
1847 return result
1849 def _accept_if_valid(self):
1850 self._update_timesheet_preview()
1851 self._parsed_preview = self.parsed_result()
1852 if self._parsed_preview is None:
1853 QMessageBox.warning(
1854 self,
1855 tr("タイムリマップ"),
1856 tr("有効なタイムシートを貼り付け、使用する行を残してください。"),
1857 )
1858 return
1859 if (
1860 self._parsed_preview.get("format", "").startswith("XDTS")
1861 and not self._parsed_preview.get("layer_bindings")
1862 ):
1863 QMessageBox.warning(
1864 self,
1865 tr("XDTS読み込み"),
1866 tr("読み込むCELL名をクリックし、紐づけ先レイヤーを選択してください。"),
1867 )
1868 return
1869 self.accept()