Coverage for paintmaskanimator/widgets.py: 49%

1122 statements  

« 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 

49 

50log = get_logger(__name__) 

51 

52 

53class TransformLineThicknessDialog(QDialog): 

54 """TPクオリティ変形の実線太さをライブ調整する。""" 

55 

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) 

61 

62 layout = QVBoxLayout(self) 

63 note = QLabel( 

64 tr("選択中の複数色を実線として残す太さをまとめて調整します。") 

65 ) 

66 note.setWordWrap(True) 

67 layout.addWidget(note) 

68 

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) 

83 

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) 

91 

92 def value(self): 

93 return int(self.slider.value()) 

94 

95 

96 

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

108 

109 

110class HoldShortcutEdit(QPushButton): 

111 """押している間だけ有効な操作用。修飾キー単独も登録できるキー入力欄。""" 

112 

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

121 

122 def _begin_capture(self): 

123 self._waiting = True 

124 self.setFocus(Qt.FocusReason.MouseFocusReason) 

125 self.setText(tr("キーを押してください…")) 

126 

127 def shortcutText(self): 

128 return self._shortcut_text 

129 

130 def setShortcutText(self, text): 

131 self._shortcut_text = str(text or "") 

132 self._waiting = False 

133 self._refresh_text() 

134 

135 def _refresh_text(self): 

136 self.setText(self._shortcut_text or tr("未設定")) 

137 self.setToolTip( 

138 tr("クリックしてキーを入力。BackspaceまたはDeleteで解除。") 

139 ) 

140 

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 ) 

155 

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

179 

180 def focusOutEvent(self, event): 

181 self._waiting = False 

182 self._refresh_text() 

183 super().focusOutEvent(event) 

184 

185 

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) 

228 

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

270 

271 

272class SwatchEyedropButton(_ScreenColorDragMixin, QPushButton): 

273 colorPicked = Signal(QColor) 

274 

275 def __init__(self, parent=None): 

276 super().__init__(parent) 

277 self._init_screen_color_drag() 

278 

279 

280class BrushSizeSpinBox(QDoubleSpinBox): 

281 """▲▼で0.5ずつ変更するブラシ/ラインサイズ表示。""" 

282 

283 pressureRequested = Signal() 

284 

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

300 

301 def _refresh_tooltip(self): 

302 self.setToolTip( 

303 tr("▲▼をクリック:0.5ずつ変更") 

304 + ( 

305 tr("/数値部分をクリック:筆圧設定") 

306 if self._pressure_popup_enabled else "" 

307 ) 

308 ) 

309 

310 def setPressurePopupEnabled(self, enabled): 

311 self._pressure_popup_enabled = bool(enabled) 

312 self._refresh_tooltip() 

313 

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

322 

323 def stepBy(self, steps): 

324 # ▲▼で変更した後、数値全体が青く選択される状態を残さない。 

325 super().stepBy(steps) 

326 QTimer.singleShot(0, self._clear_number_selection) 

327 

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) 

341 

342 def wheelEvent(self, event): 

343 event.ignore() 

344 

345 

346 

347class SliderValueSpinBox(QDoubleSpinBox): 

348 """RGB/HSVスライダーと連動する、編集可能な小型数値欄。""" 

349 

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) 

355 

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) 

370 

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 ) 

380 

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 ) 

392 

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 ) 

413 

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 ) 

420 

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

432 

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

439 

440 # ▲▼ボタンにフォーカスを残さず、編集欄の青い選択も解除する。 

441 self._up_button.clearFocus() 

442 self._down_button.clearFocus() 

443 

444 def _step_up_without_selection(self): 

445 self.stepUp() 

446 QTimer.singleShot(0, self._clear_number_selection) 

447 

448 def _step_down_without_selection(self): 

449 self.stepDown() 

450 QTimer.singleShot(0, self._clear_number_selection) 

451 

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

468 

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 ) 

478 

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 ) 

499 

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 ) 

514 

515 try: 

516 value = float(text) 

517 except ValueError: 

518 return ( 

519 QValidator.State.Intermediate, 

520 text, 

521 position, 

522 ) 

523 

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 ) 

530 

531 state = ( 

532 QValidator.State.Intermediate 

533 if text.endswith(".") 

534 else QValidator.State.Acceptable 

535 ) 

536 return (state, text, position) 

537 

538 def valueFromText(self, text): 

539 try: 

540 return float(str(text)) 

541 except ValueError: 

542 return float(self.value()) 

543 

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

548 

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) 

557 

558 def _slider_changed(self, value): 

559 self.blockSignals(True) 

560 self.setValue(float(value) / self.scale) 

561 self.blockSignals(False) 

562 

563 def setDisplayValue(self, value): 

564 self.blockSignals(True) 

565 self.setValue(float(value)) 

566 self.blockSignals(False) 

567 

568 def wheelEvent(self, event): 

569 event.ignore() 

570 

571 

572 

573class ClickableValueLabel(QLabel): 

574 """数値表示をクリックした時だけ設定ポップアップを開くラベル。""" 

575 

576 clicked = Signal(QPoint) 

577 

578 def __init__(self, text="", parent=None): 

579 super().__init__(text, parent) 

580 self.setCursor(Qt.CursorShape.PointingHandCursor) 

581 

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) 

588 

589 

590class LineTaperCurvePopup(QDialog): 

591 """ラインの入り/抜きカーブをその場で調整するポップアップ。""" 

592 

593 curveChanged = Signal(int) 

594 

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

619 

620 def _value_changed(self, value): 

621 self.value_label.setText(f"{value / 100:.2f}") 

622 self.curveChanged.emit(int(value)) 

623 

624 

625class TweenCommandPopup(QDialog): 

626 """自由変形/メッシュ変形トゥイーン中の操作パネル。""" 

627 

628 commitRequested = Signal() 

629 cancelRequested = Signal() 

630 rotateLeftRequested = Signal() 

631 rotateRightRequested = Signal() 

632 reverseChanged = Signal(bool) 

633 

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) 

655 

656 layout = QVBoxLayout(self) 

657 layout.setContentsMargins(8, 8, 8, 8) 

658 layout.setSpacing(5) 

659 

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) 

672 

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) 

684 

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 ) 

700 

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) 

709 

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) 

716 

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 ) 

729 

730 

731class HSVColorWheel(QWidget): 

732 colorChanged = Signal(QColor) 

733 modeChanged = Signal(str) 

734 hueModeChanged = Signal(str) 

735 

736 #: 内側のピッカー形状と色相ピッカーの形状は独立して選択できる。 

737 MODES = ("HSV", "HLS") 

738 HUE_MODES = ("RING", "BAR") 

739 DEFAULT_MODE = "HSV" 

740 DEFAULT_HUE_MODE = "RING" 

741 

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

757 

758 def mode(self): 

759 return self._mode 

760 

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) 

774 

775 def hueMode(self): 

776 return self._hue_mode 

777 

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) 

787 

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

796 

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

803 

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 

844 

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 

850 

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

858 

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 

866 

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 

885 

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

895 

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 ) 

925 

926 def resizeEvent(self, event): 

927 self._cache_key = None 

928 super().resizeEvent(event) 

929 

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 

1001 

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 ) 

1070 

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 

1088 

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

1105 

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

1147 

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) 

1156 

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) 

1164 

1165 def mouseReleaseEvent(self, event): 

1166 self._drag_part = None 

1167 super().mouseReleaseEvent(event) 

1168 

1169 

1170def _list_value(value): 

1171 """Normalize an untrusted parser field to a plain list.""" 

1172 return list(value) if isinstance(value, (list, tuple)) else [] 

1173 

1174 

1175def sheet_columns_from_parsed(parsed) -> "list[dict[str, Any]]": 

1176 """解析したタイムシートを、レイヤーへ紐づけられる「列」の一覧にする。 

1177 

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 }] 

1226 

1227 

1228def default_layer_bindings(columns, layers, active_layer_index): 

1229 """列→レイヤーの既定の紐づけ ``{列uid: レイヤー番号}``。 

1230 

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 

1255 

1256 

1257class TimeRemapPasteDialog(QDialog): 

1258 """コピー情報をタイムシート表へ貼り付けて確認する画面。""" 

1259 

1260 def __init__(self, initial_text="", parent=None, *, parse_text=None, canvas=None): 

1261 """``parse_text`` and ``canvas`` are the collaborators this dialog needs. 

1262 

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) 

1281 

1282 layout = QVBoxLayout(self) 

1283 layout.setContentsMargins(8, 8, 8, 8) 

1284 layout.setSpacing(5) 

1285 

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) 

1302 

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) 

1315 

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) 

1327 

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) 

1370 

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) 

1387 

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) 

1396 

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) 

1407 

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) 

1419 

1420 def _paste_clipboard(self): 

1421 self.text_edit.setPlainText( 

1422 QApplication.clipboard().text() 

1423 ) 

1424 self.text_edit.setFocus() 

1425 

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 

1435 

1436 def dragEnterEvent(self, event): 

1437 if self._dropped_xdts_path(event.mimeData()): 

1438 event.acceptProposedAction() 

1439 return 

1440 super().dragEnterEvent(event) 

1441 

1442 def dragMoveEvent(self, event): 

1443 if self._dropped_xdts_path(event.mimeData()): 

1444 event.acceptProposedAction() 

1445 return 

1446 super().dragMoveEvent(event) 

1447 

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) 

1455 

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) 

1466 

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 

1479 

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);") 

1492 

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 [] 

1497 

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

1502 

1503 def _sheet_columns(self): 

1504 return sheet_columns_from_parsed(self._parsed_source) 

1505 

1506 @staticmethod 

1507 def _column_uid(column): 

1508 return str(column.get("uid", "")) 

1509 

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 ] 

1524 

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

1532 

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 ) 

1543 

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

1558 

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

1597 

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 ) 

1629 

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) 

1640 

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) 

1646 

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

1652 

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"]) 

1679 

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 

1693 

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

1748 

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 

1759 

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 

1765 

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

1795 

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 

1848 

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