Coverage for paintmaskanimator/main_window.py: 51%

677 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-11-02 04:50 +0000

1"""The ``MainWindow`` — application shell, menu/dock wiring, and top-level actions. 

2 

3Architecture: each cohesive feature lives in ``main_window_<topic>.py`` as a 

4*controller* the window owns and reaches by name -- ``window.export``, 

5``window.project``, ``window.timeline_ops`` and the rest, constructed in 

6``__init__`` below. A controller takes the window in its constructor, so its 

7dependencies are visible as ``self.window.…`` rather than appearing out of 

8nowhere, and it can be built against a stub window in a test. 

9 

10These were mixins until they were migrated one by one. Composing them all onto 

11one class put fifteen unrelated feature namespaces into a single ``self``, where 

12two siblings could silently claim the same method name and no checker could see 

13which one provided any given attribute. 

14 

15Three mixins remain, because their methods genuinely belong to the window 

16object: ``UIBuildMixin`` constructs the window's own widgets, and ``InputMixin`` 

17and ``DockingMixin`` implement Qt overrides (``eventFilter``) that Qt calls on 

18the window itself. 

19 

20What remains here is the irreducible shell: application state held on the 

21window, the ``PaintCanvas`` <-> timeline glue, playback, and document lifecycle 

22(``new_doc``/``replace_doc``). Type-only member declarations for the remaining 

23mixins live in ``_main_window_members.py``; the shared error set is 

24``errors.OPERATION_ERRORS`` (aliased ``_OPERATION_ERRORS``). 

25""" 

26import math 

27import numpy as np 

28import subprocess 

29import sys 

30import tempfile 

31import time 

32from pathlib import Path 

33from typing import Any 

34from PySide6.QtCore import QPointF, QRectF, QTimer, Qt 

35from PySide6.QtGui import QCursor, QImage, QImageReader, QPainter 

36from PySide6.QtWidgets import ( 

37 QApplication, 

38 QDialog, 

39 QFileDialog, 

40 QMainWindow, 

41 QMessageBox, 

42 QProgressDialog, 

43) 

44from .i18n import tr 

45from .optional_deps import PILImage 

46from .constants import APP_DISPLAY_NAME, OUTSIDE_MARGIN 

47from . import color_ops, constants, imaging, layout_paper, preferences, pressure_settings, project_io, session_state, theme, updater 

48from .actionpanel import ActionPanel 

49from .canvas import PaintCanvas 

50from .errors import OPERATION_ERRORS 

51from .color_panel import UsedColorPanel 

52from .color_chart import ColorChartPanel, empty_color_chart 

53from .history_panel import HistoryPanel 

54from .memo_window import MemoWindow 

55from .subview import SubViewWidget 

56from .color_reduction import ColorReductionDialog 

57from .models import Frame, Layer, make_frame 

58from .pressure import PressureDialog 

59from .timeline import TimelineWidget 

60from .toolpanel import ToolPanel, ToolSelectorPanel 

61from .utils import blank_image, disable_windows_ink_feedback, install_app_event_filter, remove_app_event_filter, workspace_size 

62from .new_document_dialog import NewDocumentDialog 

63from .widgets import (CanvasSizeDialog, ShortcutDialog) 

64from .progress import close_counter, create_counter, update_counter 

65from .logging_setup import get_logger 

66from .main_window_autosave import AutosaveController 

67from .main_window_shortcuts import ShortcutStore 

68from .main_window_color_interaction import ColorInteractionController 

69from .main_window_color_chart import ColorChartController 

70from .main_window_docking import DockingMixin 

71from .main_window_export import ExportController 

72from .main_window_import import ImportController 

73from .main_window_input import InputMixin 

74from .main_window_layer_ops import LayerOpsController 

75from .main_window_line_ops import LineOpsController 

76from .main_window_view_preview import ViewPreviewController 

77from .main_window_tool_hold import ToolHoldController 

78from .main_window_update_notice import UpdateNoticeController 

79from .main_window_onion import OnionSkinController 

80from .main_window_project_io import ProjectIOController 

81from .main_window_time_remap import TimeRemapController 

82from .main_window_timeline_ops import TimelineOpsController 

83from .main_window_tween import TweenController 

84from .main_window_ui_build import UIBuildMixin 

85from .main_window_used_color import UsedColorController 

86from .main_window_scope_ops import ScopeOpsController 

87from .main_window_reference_check import ReferenceCheckController 

88from .main_window_auto_paint import AutoPaintController 

89from .main_window_initial_state import InitialStateController 

90from .main_window_workspace import WorkspaceController 

91 

92log = get_logger(__name__) 

93 

94# Realistic failure set for the top-level user-action handlers (shared with the 

95# extracted mixins); see errors.py. 

96_OPERATION_ERRORS = OPERATION_ERRORS 

97 

98 

99class MainWindow( 

100 UIBuildMixin, InputMixin, DockingMixin, QMainWindow 

101): 

102 def __init__(self): 

103 super().__init__();self.setWindowTitle(APP_DISPLAY_NAME);self._fit_window_to_screen(1500,960);self.setAcceptDrops(True) 

104 self.canvas=PaintCanvas();self.tool_selector=ToolSelectorPanel();self.tools=ToolPanel();self.timeline=TimelineWidget();self.palette=UsedColorPanel();self.history_panel=HistoryPanel();self.subview=SubViewWidget(self);self.color_chart=ColorChartPanel(self);self.color_chart_data=empty_color_chart();self.timer=QTimer(self);self.timer.timeout.connect(self.advance) 

105 # Feature controllers the window owns; see this module's docstring. 

106 self.export=ExportController(self) 

107 self.workspace=WorkspaceController(self) 

108 self.autosave=AutosaveController(self) 

109 self.colors=ColorInteractionController(self) 

110 self.used_color=UsedColorController(self) 

111 self.line_ops=LineOpsController(self) 

112 self.view_preview=ViewPreviewController(self) 

113 self.tool_hold=ToolHoldController(self) 

114 self.update_notice=UpdateNoticeController(self) 

115 self.timeline_ops=TimelineOpsController(self) 

116 self.layers=LayerOpsController(self) 

117 self.color_chart_ops=ColorChartController(self) 

118 self.onion=OnionSkinController(self) 

119 self.tween=TweenController(self) 

120 self.time_remap=TimeRemapController(self) 

121 self.scope=ScopeOpsController(self) 

122 self.reference_check=ReferenceCheckController(self) 

123 self.auto_paint=AutoPaintController(self) 

124 self.initial_state=InitialStateController(self) 

125 self.project=ProjectIOController(self) 

126 self.importer=ImportController(self) 

127 # palette_state のUndo/Redoでパネル状態を復元できるよう相互参照を張る。 

128 self.canvas._palette=self.palette 

129 self.history_panel.set_canvas(self.canvas) 

130 self.timer.setTimerType(Qt.TimerType.PreciseTimer) 

131 self._playback_started_at=None 

132 self._playback_emitted_steps=0 

133 self._used_color_cache = {} 

134 self._used_color_layer_cache = {} 

135 self._used_color_request = 0 

136 self._used_color_timer = QTimer(self) 

137 self._used_color_timer.setSingleShot(True) 

138 self._used_color_timer.setInterval(180) 

139 self._used_color_timer.timeout.connect(self.used_color.refresh) 

140 self._pending_visible_colors = None 

141 self._pending_palette_categories = None 

142 self._suppress_used_color_refresh_once = False 

143 self._tween_command_popup = None 

144 self._onion_settings_browser = None 

145 self._onion_settings_window = None 

146 self._held_canvas_shortcut_tokens = set() 

147 self._space_tap_started = None 

148 self._ui_hold_drag_mode = None 

149 self._ui_hold_scroll_area = None 

150 self._ui_hold_grab_widget = None 

151 self._ui_hold_start_global = QPointF() 

152 self._ui_hold_last_global = QPointF() 

153 self._ui_hold_start_scroll = (0, 0) 

154 self._visible_color_timer = QTimer(self) 

155 self._visible_color_timer.setSingleShot(True) 

156 self._visible_color_timer.setInterval(20) 

157 self._visible_color_timer.timeout.connect(self.colors._apply_pending_visible_colors) 

158 self._pending_tool_selector_snap = None 

159 self._tool_selector_resize_drag_active = False 

160 self._tool_selector_snap_timer = QTimer(self) 

161 self._tool_selector_snap_timer.setSingleShot(True) 

162 self._tool_selector_snap_timer.setInterval(180) 

163 self._tool_selector_snap_timer.timeout.connect( 

164 self._apply_tool_selector_snap 

165 ) 

166 self._split_drop_candidate = None 

167 self._split_drop_dragged_dock = None 

168 self._split_drop_source_area = None 

169 self._split_drop_press_pos = None 

170 self._dock_menu_builders = {} 

171 self.current_project_path = None 

172 # カット袋・シートのメモ欄(Issue #25)。窓は開いたときに作る。 

173 self.project_memo = "" 

174 self._memo_window = None 

175 # 保存・自動保存で、変更のないセルのPNGを使い回す。 

176 self.png_cache = project_io.PngCache() 

177 self.build_actions();self.action_panel=ActionPanel(self);self.build_action_panel();self.build_menu();self.build_ui();self.connect_signals();self.refresh_ui();self.action_panel.reload_python_actions() 

178 # 保存したショートカットを、既定を覚えてから反映する。 

179 self.shortcut_store = ShortcutStore(self) 

180 self.shortcut_store.register_actions() 

181 self.shortcut_store.load() 

182 self._refresh_theme_dependent_ui() 

183 self.colors._sync_tool_selector_swatch() 

184 install_app_event_filter(self) 

185 self.project.update_title() 

186 QTimer.singleShot(0,self.fit_canvas) 

187 QTimer.singleShot( 

188 0, 

189 lambda: disable_windows_ink_feedback( 

190 self, 

191 self.canvas, 

192 ), 

193 ) 

194 self.apply_pressure_settings() 

195 session_state.restore(self) 

196 self.project.mark_saved() 

197 self._title_timer = QTimer(self) 

198 self._title_timer.setInterval(500) 

199 self._title_timer.timeout.connect(self.project.update_title) 

200 self._title_timer.start() 

201 self.autosave.start() 

202 QTimer.singleShot(0, self.autosave.maybe_restore) 

203 def status(self, message, level="info", timeout=4000): 

204 """Show a severity-coloured message in the bottom status bar. 

205 

206 ``level`` is one of ``info`` / ``success`` / ``warning`` / ``error``. 

207 Falls back to the plain status bar if the styled one is unavailable. 

208 """ 

209 bar: Any = self.statusBar() 

210 show = getattr(bar, "show_message", None) 

211 if callable(show): 

212 show(message, level, timeout) 

213 else: 

214 bar.showMessage(message, timeout) 

215 

216 def set_theme(self, name): 

217 """Switch the light/dark theme, persist it, and refresh the UI.""" 

218 app = QApplication.instance() 

219 if app is not None: 219 ↛ 221line 219 didn't jump to line 221 because the condition on line 219 was always true

220 theme.apply_theme(app, name, persist=True) 

221 self._refresh_theme_dependent_ui() 

222 

223 def _on_canvas_cell_changed(self, frame_index, layer_index): 

224 self.canvas.sync_numbered_image_from_cell( 

225 frame_index, 

226 layer_index, 

227 ) 

228 # Existing brush cells do not change timeline structure. Rebuilding the 

229 # entire table here made brush release scale with the number of imported images. 

230 structure_dirty = bool( 

231 getattr(self.canvas, "_cell_structure_dirty", True) 

232 ) 

233 self.canvas._cell_structure_dirty = True 

234 if structure_dirty: 

235 self.timeline.update_cell( 

236 self.canvas.frames, frame_index, layer_index 

237 ) 

238 

239 # ブラシは常に100%不透明で、色は colorUsed で即時追加される。 

240 # 通常ストロークのたびに全フレーム走査するのを避ける。 

241 normal_opaque_brush = ( 

242 self.canvas.effective_tool() == "brush" 

243 and not self.canvas.is_pseudo_transparent_color( 

244 self.canvas.paint_source_color() 

245 ) 

246 ) 

247 if not normal_opaque_brush: 

248 self.used_color.schedule_refresh() 

249 

250 def refresh_ui(self): 

251 self.canvas.coalesce_numbered_images() 

252 self.canvas.link_layer_tracks() 

253 self.timeline.sequence_archive = self.canvas._sequence_archive 

254 self.timeline.playback_range_start = int(self.canvas.playback_range_start) 

255 self.timeline.playback_range_end = self.canvas.playback_range_end 

256 self.timeline.refresh( 

257 self.canvas.frames, 

258 self.canvas.current_frame, 

259 self.canvas.active_layer_index, 

260 getattr(self.canvas, "tween_pending", None), 

261 ) 

262 if self._suppress_used_color_refresh_once: 

263 self._suppress_used_color_refresh_once = False 

264 else: 

265 self.used_color.schedule_refresh() 

266 

267 

268 def refresh_selection(self): 

269 if getattr(self, "_closing", False) or not self.canvas.frames: 269 ↛ 270line 269 didn't jump to line 270 because the condition on line 269 was never true

270 return 

271 self.canvas.current_frame = max(0, min(self.canvas.current_frame, len(self.canvas.frames) - 1)) 

272 layers = self.canvas.layers 

273 if not layers: 273 ↛ 274line 273 didn't jump to line 274 because the condition on line 273 was never true

274 return 

275 self.canvas.active_layer_index = max(0, min(self.canvas.active_layer_index, len(layers) - 1)) 

276 self.timeline.select_current(self.canvas.current_exposure(), self.canvas.active_layer_index) 

277 self.timeline.layer_list.blockSignals(True) 

278 self.timeline.layer_list.setCurrentRow(len(layers) - 1 - self.canvas.active_layer_index) 

279 self.timeline.layer_list.blockSignals(False) 

280 self.timeline.duration.blockSignals(True) 

281 layer = self.canvas.frames[self.canvas.current_frame].layers[self.canvas.active_layer_index] 

282 if not layer.has_content: 

283 source = self.canvas.resolve_key_frame(self.canvas.current_frame, self.canvas.active_layer_index) 

284 layer = self.canvas.frames[source].layers[self.canvas.active_layer_index] if source is not None else layer 

285 self.timeline.duration.setValue(max(1, layer.exposure)) 

286 self.timeline.duration.blockSignals(False) 

287 self.timeline._sync_layer_opacity_slider(layer.opacity) 

288 

289 

290 def prepare_color_reduction(self, paths): 

291 paths = sorted( 

292 [str(path) for path in paths], 

293 key=self.canvas._natural_path_key, 

294 ) 

295 if not paths: 

296 return None, "" 

297 

298 first_image, error = self.canvas._read_image_file( 

299 paths[0] 

300 ) 

301 if first_image is None: 

302 return None, ( 

303 f"{Path(paths[0]).name}\n{error}" 

304 ) 

305 

306 # パレットは代表フレームからまとめて作る。1枚目だけで決めると、 

307 # 中間で色が増えるカットやフラッシュで色が潰れる(Issue #10)。 

308 reference_images = [] 

309 for index in color_ops.reference_frame_indices(len(paths)): 

310 image, _error = self.canvas._read_image_file(paths[index]) 

311 if image is not None and not image.isNull(): 

312 reference_images.append(image) 

313 

314 try: 

315 color_count = ( 

316 self.canvas.opaque_rgb_color_count(first_image) 

317 ) 

318 alpha_statistics = ( 

319 self.canvas.image_alpha_statistics(first_image) 

320 ) 

321 background_rgb = ( 

322 self.canvas.detect_opaque_border_background( 

323 first_image 

324 ) 

325 ) 

326 opaque_background = background_rgb is not None 

327 except _OPERATION_ERRORS as exc: 

328 log.warning("first-image color/alpha inspection failed: %s", exc, exc_info=True) 

329 return None, ( 

330 tr("1枚目の色と透明度を確認できませんでした。\n{exc}").format(exc=exc) 

331 ) 

332 

333 semi_transparent_count = int( 

334 alpha_statistics["semi_transparent"] 

335 ) 

336 

337 # 多色、半透明AA、または白背景画像を調整対象にする。 

338 if ( 

339 color_count < 100 

340 and semi_transparent_count <= 0 

341 and not opaque_background 

342 ): 

343 return None, "" 

344 

345 try: 

346 dialog = ColorReductionDialog( 

347 first_image, 

348 color_count, 

349 semi_transparent_count, 

350 opaque_background, 

351 background_rgb, 

352 self, 

353 reference_images=reference_images, 

354 ) 

355 if ( 

356 dialog.exec() 

357 != QDialog.DialogCode.Accepted 

358 ): 

359 return None, "__cancelled__" 

360 if not dialog.reduction_enabled: 

361 return None, "" 

362 palette = dialog.selected_palette() 

363 except _OPERATION_ERRORS as exc: 

364 log.warning("binarization preparation failed: %s", exc, exc_info=True) 

365 return None, ( 

366 tr("2値化の準備中にエラーが発生しました。\n{exc}").format(exc=exc) 

367 ) 

368 

369 return { 

370 "palette": palette, 

371 "extraction_mode": dialog.selected_extraction_mode(), 

372 "target_colors": int( 

373 dialog.color_count.value() 

374 ), 

375 "source_color_count": int(color_count), 

376 "alpha_threshold": int( 

377 dialog.alpha_threshold_255() 

378 ), 

379 "semi_transparent_source_pixels": ( 

380 semi_transparent_count 

381 ), 

382 "opaque_background": bool( 

383 dialog.opaque_background 

384 ), 

385 "background_rgb": ( 

386 tuple(dialog.background_rgb) 

387 if dialog.background_rgb is not None 

388 else None 

389 ), 

390 "tone_curve_points": [ 

391 (float(x), float(y)) 

392 for x, y in dialog.tone_curve_points() 

393 ], 

394 }, "" 

395 

396 def prepare_image_import(self, paths): 

397 # Keep this step light: inspect dimensions only. Color analysis is deferred 

398 # until after the image is visible and is cached per cell. 

399 max_width = constants.CANVAS_WIDTH 

400 max_height = constants.CANVAS_HEIGHT 

401 for path in paths: 

402 reader = QImageReader(str(path)) 

403 if hasattr(reader, "setDecideFormatFromContent"): 

404 reader.setDecideFormatFromContent(True) 

405 size = reader.size() 

406 if size.isValid(): 

407 width, height = size.width(), size.height() 

408 elif PILImage is not None: 

409 try: 

410 with PILImage.open(path) as pil: 

411 width, height = pil.size 

412 except (OSError, ValueError, TypeError) as exc: 

413 log.info("PIL size read of %s failed: %s", path, exc) 

414 return False, tr("{Path}\n画像サイズを取得できませんでした。\n{exc}").format(Path=Path(path).name, exc=exc) 

415 else: 

416 image, error = self.canvas._read_image_file(path) 

417 if image is None: 

418 return False, f"{Path(path).name}\n{error}" 

419 width, height = image.width(), image.height() 

420 max_width = max(max_width, width) 

421 max_height = max(max_height, height) 

422 if max_width > constants.CANVAS_WIDTH or max_height > constants.CANVAS_HEIGHT: 

423 self.canvas.push_doc_undo() 

424 self.replace_doc(max_width, max_height, preserve=True) 

425 self.statusBar().showMessage( 

426 tr("画像に合わせてキャンバスを {width} × {height}px に拡張しました。").format(width=max_width, height=max_height), 3500) 

427 return True, "" 

428 

429 def import_dropped_image(self, path): 

430 color_reduction, error = ( 

431 self.prepare_color_reduction([path]) 

432 ) 

433 if error: 

434 if error != "__cancelled__": 

435 QMessageBox.warning( 

436 self, 

437 tr("画像読み込み"), 

438 tr("{Path} を読み込めませんでした。\n\n{error}").format(Path=Path(path).name, error=error), 

439 ) 

440 return 

441 

442 prepared, error = self.prepare_image_import([path]) 

443 if not prepared: 

444 if error != "__cancelled__": 

445 QMessageBox.warning(self, tr("画像読み込み"), tr("{Path} を読み込めませんでした。\n\n{error}").format(Path=Path(path).name, error=error)) 

446 return 

447 ok, error = self.canvas.import_image( 

448 path, 

449 color_reduction=color_reduction, 

450 ) 

451 if not ok: 

452 QMessageBox.warning(self,tr("画像読み込み"),tr("{Path} を読み込めませんでした。\n\n{error}").format(Path=Path(path).name, error=error)) 

453 else: 

454 self.timeline_ops.set_mode("sequence") 

455 self._used_color_cache.clear() 

456 self.used_color.schedule_refresh() 

457 

458 def import_dropped_images(self, paths, layer_name=None): 

459 color_reduction, error = ( 

460 self.prepare_color_reduction(paths) 

461 ) 

462 if error: 

463 if error != "__cancelled__": 

464 QMessageBox.warning( 

465 self, 

466 tr("連番画像読み込み"), 

467 tr("画像を読み込めませんでした。\n\n{error}").format(error=error), 

468 ) 

469 return 

470 

471 prepared, error = self.prepare_image_import(paths) 

472 if not prepared: 

473 if error != "__cancelled__": 

474 QMessageBox.warning(self, tr("連番画像読み込み"), tr("画像を読み込めませんでした。\n\n{error}").format(error=error)) 

475 return 

476 # 画像配置だけでなく、その直後の使用色認識まで同じカウンターで表示する。 

477 estimated_frames = max(1, len(self.canvas.frames) + len(paths)) 

478 combined_total = max(1, len(paths) + estimated_frames) 

479 progress = create_counter( 

480 self, 

481 tr("連番画像読み込み"), 

482 combined_total, 

483 tr("画像を読み込んでいます"), 

484 ) 

485 ok = False 

486 error = "" 

487 try: 

488 ok, error = self.canvas.import_image_sequence( 

489 paths, 

490 lambda value, total, label: update_counter( 

491 progress, 

492 value, 

493 combined_total, 

494 tr("{label}(画像 {value}/{total})").format(label=label, value=value, total=total), 

495 ), 

496 color_reduction=color_reduction, 

497 layer_name=layer_name, 

498 ) 

499 if ok: 

500 self.timeline_ops.set_mode("sequence") 

501 self._used_color_timer.stop() 

502 self._used_color_request += 1 

503 self._used_color_cache.clear() 

504 

505 actual_frames = len(self.canvas.frames) 

506 combined_total = max(1, len(paths) + actual_frames) 

507 progress.setMaximum(combined_total) 

508 update_counter( 

509 progress, 

510 len(paths), 

511 combined_total, 

512 tr("画像配置完了。使用色を認識しています"), 

513 ) 

514 self.used_color.refresh( 

515 progress=progress, 

516 progress_offset=len(paths), 

517 progress_total=combined_total, 

518 progress_label=tr("使用色を認識しています"), 

519 ) 

520 finally: 

521 close_counter(progress) 

522 

523 if not ok: 

524 QMessageBox.warning( 

525 self, 

526 tr("連番画像読み込み"), 

527 tr("画像を読み込めませんでした。\n\n{error}").format(error=error), 

528 ) 

529 elif len(paths) > 1: 

530 reduction_note = "" 

531 if color_reduction: 

532 reduction_method = ( 

533 tr("元画像へトーンカーブ適用後に2値化") 

534 if color_reduction.get("opaque_background") 

535 else tr("半透明を二値化") 

536 ) 

537 reduction_note = ( 

538 tr("、{method}して1枚目の共通パレット{int}色を適用").format(method=reduction_method, int=int(color_reduction['target_colors'])) 

539 ) 

540 self.statusBar().showMessage( 

541 tr("{len}枚の画像をタイムラインへ連番配置{note}し、使用色認識まで完了しました。").format(len=len(paths), note=reduction_note), 

542 3600, 

543 ) 

544 

545 

546 def sync_canvas_view_controls(self, zoom_value, rotation_value): 

547 zoom_percent = max(self.zoom.minimum(), min(self.zoom.maximum(), int(round(float(zoom_value) * 100)))) 

548 rotation_degrees = max(-180, min(180, int(round(float(rotation_value))))) 

549 self.zoom.blockSignals(True); self.zoom.setValue(zoom_percent); self.zoom.blockSignals(False) 

550 self.rot.blockSignals(True); self.rot.setValue(rotation_degrees); self.rot.blockSignals(False) 

551 self.zoom_label.setText(f"{zoom_percent}%") 

552 self.rot_label.setText(f"{rotation_degrees}°") 

553 

554 def paste_from_clipboard(self): 

555 """Ctrl+V。タイムラインの上ならタイムシートをそのまま反映、それ以外は画像を貼る。""" 

556 if self._pointer_or_focus_on_timeline() and self.time_remap.paste_on_timeline(): 

557 return 

558 self.canvas.paste_clipboard() 

559 

560 def _pointer_or_focus_on_timeline(self): 

561 timeline = self.timeline 

562 if not timeline.isVisible(): 562 ↛ 563line 562 didn't jump to line 563 because the condition on line 562 was never true

563 return False 

564 focus = QApplication.focusWidget() 

565 if focus is not None and (focus is timeline or timeline.isAncestorOf(focus)): 565 ↛ 567line 565 didn't jump to line 567 because the condition on line 565 was always true

566 return True 

567 return timeline.rect().contains(timeline.mapFromGlobal(QCursor.pos())) 

568 

569 def step_canvas_zoom(self, factor): 

570 """Ctrl++/Ctrl+− で、マウスカーソルの位置を中心に拡大縮小する。 

571 

572 ホイールと同じく、見ている所をそのまま拡大できるように。カーソルが 

573 キャンバスの外(パネルの上など)にあっても、その位置を中心にする。 

574 """ 

575 zoom = self.canvas.zoom * float(factor) 

576 cursor = self.canvas.mapFromGlobal(QCursor.pos()) 

577 self.canvas.set_zoom_around_widget_point(zoom, QPointF(cursor)) 

578 self.sync_canvas_view_controls(self.canvas.zoom, self.canvas.rotation) 

579 self.canvas.update() 

580 

581 def set_zoom(self,v): 

582 self.canvas.set_zoom_around_canvas_center(v / 100) 

583 self.sync_canvas_view_controls(self.canvas.zoom, self.canvas.rotation) 

584 self.canvas.update() 

585 def show_canvas_at_100_percent(self): 

586 self.canvas.zoom = 1.0 

587 self.canvas.center_canvas() 

588 self.sync_canvas_view_controls(self.canvas.zoom, self.canvas.rotation) 

589 self.canvas.update() 

590 def set_rot(self,v): 

591 self.canvas.rotation=float(v) 

592 self.sync_canvas_view_controls(self.canvas.zoom, self.canvas.rotation) 

593 self.canvas.update() 

594 def fit_canvas(self): 

595 w,h=workspace_size();availw=max(100,self.canvas.width()-40);availh=max(100,self.canvas.height()-40);z=min(availw/w,availh/h);self.canvas.zoom=z;self.canvas.pan=QPointF((self.canvas.width()-w*z)/2,(self.canvas.height()-h*z)/2);self.zoom.blockSignals(True);self.zoom.setValue(int(z*100));self.zoom.blockSignals(False);self.zoom_label.setText(f"{z*100:.0f}%");self.canvas.update() 

596 

597 def new_doc(self): 

598 d=NewDocumentDialog(*preferences.new_canvas_size(),self) 

599 if d.exec(): 599 ↛ exitline 599 didn't return from function 'new_doc' because the condition on line 599 was always true

600 self.replace_doc(*d.values()) 

601 layout_paper.set_last_used(d.paper_id()) 

602 if d.paper_id() is not None: 602 ↛ 603line 602 didn't jump to line 603 because the condition on line 602 was never true

603 self.insert_layout_paper(layout_paper.paper(d.paper_id())) 

604 self.current_project_path = None 

605 self.project.update_title() 

606 self.project.mark_saved() 

607 

608 def insert_layout_paper(self, entry): 

609 """Put a layout paper under every layer of a fresh document, as a draft.""" 

610 image = layout_paper.load_image(entry) if entry else None 

611 if image is None: 611 ↛ 612line 611 didn't jump to line 612 because the condition on line 611 was never true

612 self.status(tr("レイアウト用紙の画像を読み込めませんでした。"), "warning") 

613 return 

614 for frame in self.canvas.frames: 

615 layer = Layer(layout_paper.layer_name(), blank_image(), is_draft=True) 

616 self.canvas._place_imported_image(image, layer.image) 

617 layer.has_content = True 

618 frame.layers.insert(0, layer) 

619 self.canvas.active_layer_index = 1 

620 self.refresh_ui() 

621 

622 def check_for_updates_interactive(self): 

623 # Reads the public GitHub Releases API; no token/credential is required. 

624 QApplication.setOverrideCursor(QCursor(Qt.CursorShape.WaitCursor)) 

625 try: 

626 result = updater.check_for_update() 

627 finally: 

628 QApplication.restoreOverrideCursor() 

629 # メニューバーのお知らせも、確かめた結果に合わせる。 

630 self.update_notice.apply_result(result) 

631 

632 status = result.get("status") 

633 if status == "error": 633 ↛ 634line 633 didn't jump to line 634 because the condition on line 633 was never true

634 QMessageBox.warning( 

635 self, tr("更新確認エラー"), result.get("message", tr("不明なエラー")) 

636 ) 

637 return 

638 if status == "up_to_date": 638 ↛ 646line 638 didn't jump to line 646 because the condition on line 638 was always true

639 QMessageBox.information( 

640 self, 

641 tr("更新の確認"), 

642 tr("最新版を使用しています。(現在: v{VERSION})").format(VERSION=constants.APP_VERSION), 

643 ) 

644 return 

645 

646 latest = result.get("latest", "") 

647 asset = result.get("asset") 

648 if not asset: 

649 QMessageBox.information( 

650 self, 

651 tr("更新あり"), 

652 tr("新しいバージョン {latest} が利用可能ですが、この環境向けの\nインストーラが見つかりませんでした。リリースページを確認してください。\n\n{url}").format(latest=latest, url=constants.RELEASES_URL), 

653 ) 

654 return 

655 

656 answer = QMessageBox.question( 

657 self, 

658 tr("更新があります"), 

659 tr("新しいバージョン {latest} が利用可能です。\n(現在: v{VERSION})\n\nダウンロードしてインストールしますか?").format(latest=latest, VERSION=constants.APP_VERSION), 

660 ) 

661 if answer != QMessageBox.StandardButton.Yes: 

662 return 

663 self._download_and_run_installer(asset, latest) 

664 

665 def _download_and_run_installer(self, asset, latest): 

666 dest = Path(tempfile.gettempdir()) / str( 

667 asset.get("name", f"PaintMaskAnimator-Setup-{latest}.exe") 

668 ) 

669 dialog = QProgressDialog( 

670 tr("更新をダウンロードしています…"), tr("キャンセル"), 0, 100, self 

671 ) 

672 dialog.setWindowTitle(tr("更新のダウンロード")) 

673 dialog.setAutoClose(False) 

674 dialog.setMinimumDuration(0) 

675 cancelled = {"flag": False} 

676 dialog.canceled.connect(lambda: cancelled.__setitem__("flag", True)) 

677 

678 def on_progress(downloaded, total): 

679 if total > 0: 

680 dialog.setValue(int(downloaded * 100 / total)) 

681 QApplication.processEvents() 

682 if cancelled["flag"]: 

683 raise RuntimeError("cancelled") 

684 

685 try: 

686 updater.download_asset(asset, dest, progress=on_progress) 

687 except RuntimeError: 

688 dialog.close() 

689 return 

690 except (OSError, ValueError) as error: 

691 log.warning("update download failed: %s", error, exc_info=True) 

692 dialog.close() 

693 QMessageBox.warning( 

694 self, tr("ダウンロード失敗"), tr("更新を取得できませんでした。\n\n{error}").format(error=error) 

695 ) 

696 return 

697 dialog.close() 

698 

699 answer = QMessageBox.question( 

700 self, 

701 tr("インストール"), 

702 tr("ダウンロードが完了しました。インストーラを起動して\n" 

703 "アプリを終了します。よろしいですか?"), 

704 ) 

705 if answer != QMessageBox.StandardButton.Yes: 

706 return 

707 try: 

708 import os 

709 if sys.platform == "win32": 

710 os.startfile(str(dest)) # noqa: SLF001 - Windows installer launch 

711 elif sys.platform == "darwin": 

712 subprocess.Popen(["open", str(dest)]) 

713 else: 

714 subprocess.Popen(["xdg-open", str(dest)]) 

715 except (OSError, ValueError) as error: 

716 log.warning("installer launch failed: %s", error, exc_info=True) 

717 QMessageBox.warning( 

718 self, tr("起動失敗"), tr("インストーラを起動できませんでした。\n\n{error}").format(error=error) 

719 ) 

720 return 

721 self.close() 

722 

723 

724 def build_project_metadata(self): 

725 """Assemble the project metadata dict from current widget state.""" 

726 return { 

727 "format": "PaintMaskAnimatorProject", 

728 "format_version": 1, 

729 "mask_format": project_io.CURRENT_MASK_FORMAT, 

730 "application_version": 100, 

731 "canvas": { 

732 "width": int(constants.CANVAS_WIDTH), 

733 "height": int(constants.CANVAS_HEIGHT), 

734 }, 

735 "fps": int(self.timeline.fps.value()), 

736 "playback_range": [ 

737 int(self.canvas.playback_range_start), 

738 int(self.canvas.playback_range_end) 

739 if self.canvas.playback_range_end is not None else None, 

740 ], 

741 "timeline_mode": str(self.canvas.timeline_mode), 

742 # カット袋・シートのメモ欄(Issue #25)。 

743 "memo": str(self.project_memo), 

744 "clip_studio_source": getattr( 

745 self.canvas, "clip_studio_source_metadata", None 

746 ), 

747 "current_frame": int(self.canvas.current_frame), 

748 "active_layer_index": int(self.canvas.active_layer_index), 

749 "colors": { 

750 "main": self.colors.image_color_hex(self.canvas.main_color), 

751 "sub": self.colors.image_color_hex(self.canvas.sub_color), 

752 "mode": self.canvas.color_mode, 

753 "background": self.colors.image_color_hex( 

754 self.canvas.transparent_display_color 

755 ), 

756 }, 

757 "display": { 

758 "silhouette_non_background": bool( 

759 self.canvas.silhouette_non_background 

760 ), 

761 "onion_skin": bool(self.canvas.onion_skin), 

762 "onion_previous_count": int(self.canvas.onion_previous_count), 

763 "onion_next_count": int(self.canvas.onion_next_count), 

764 "onion_previous_opacity": float(self.canvas.onion_previous_opacity), 

765 "onion_next_opacity": float(self.canvas.onion_next_opacity), 

766 "onion_previous_levels": [ 

767 int(value) 

768 for value in self.canvas.onion_previous_levels 

769 ], 

770 "onion_next_levels": [ 

771 int(value) 

772 for value in self.canvas.onion_next_levels 

773 ], 

774 "onion_center_percent": float( 

775 self.canvas.onion_center_percent 

776 ), 

777 "onion_previous_color": self.colors.image_color_hex( 

778 self.canvas.onion_previous_color 

779 ), 

780 "onion_next_color": self.colors.image_color_hex( 

781 self.canvas.onion_next_color 

782 ), 

783 "onion_previous_color_enabled": bool( 

784 self.canvas.onion_previous_color_enabled 

785 ), 

786 "onion_next_color_enabled": bool( 

787 self.canvas.onion_next_color_enabled 

788 ), 

789 "onion_selected_colors_only": bool( 

790 self.canvas.onion_selected_colors_only 

791 ), 

792 "onion_previous_shift_x": float( 

793 self.canvas.onion_previous_shift_x 

794 ), 

795 "onion_previous_shift_y": float( 

796 self.canvas.onion_previous_shift_y 

797 ), 

798 "onion_previous_rotation": float( 

799 self.canvas.onion_previous_rotation 

800 ), 

801 "onion_previous_scale": float( 

802 self.canvas.onion_previous_scale 

803 ), 

804 "onion_next_shift_x": float( 

805 self.canvas.onion_next_shift_x 

806 ), 

807 "onion_next_shift_y": float( 

808 self.canvas.onion_next_shift_y 

809 ), 

810 "onion_next_rotation": float( 

811 self.canvas.onion_next_rotation 

812 ), 

813 "onion_next_scale": float( 

814 self.canvas.onion_next_scale 

815 ), 

816 "onion_tu_tb_scale": float( 

817 self.canvas.onion_tu_tb_scale 

818 ), 

819 }, 

820 "pressure": { 

821 "enabled": bool(self.canvas.pressure_enabled), 

822 "minimum": float(self.canvas.pressure_min), 

823 "maximum": float(self.canvas.pressure_max), 

824 "curve": float(self.canvas.pressure_curve), 

825 "points": getattr(self.canvas, "pressure_curve_points", [[0.0,0.0],[1.0,1.0]]), 

826 }, 

827 "used_color_categories": self.palette.serialize_categories(), 

828 "color_chart": self.color_chart_ops._normalize( 

829 self.color_chart_data 

830 ), 

831 "frames": [], 

832 } 

833 

834 

835 def dragEnterEvent(self, event): 

836 urls = ( 

837 event.mimeData().urls() 

838 if event.mimeData().hasUrls() else [] 

839 ) 

840 if any( 

841 Path(url.toLocalFile()).suffix.lower() 

842 in (".pman", ".clip", ".xdts", ".xtds") 

843 for url in urls 

844 ): 

845 event.acceptProposedAction() 

846 return 

847 super().dragEnterEvent(event) 

848 

849 def dropEvent(self, event): 

850 project_paths = [ 

851 url.toLocalFile() 

852 for url in event.mimeData().urls() 

853 if Path(url.toLocalFile()).suffix.lower() == ".pman" 

854 ] if event.mimeData().hasUrls() else [] 

855 remap_paths = [ 

856 url.toLocalFile() 

857 for url in event.mimeData().urls() 

858 if Path(url.toLocalFile()).suffix.lower() 

859 in (".xdts", ".xtds") 

860 ] if event.mimeData().hasUrls() else [] 

861 clip_paths = [ 

862 url.toLocalFile() 

863 for url in event.mimeData().urls() 

864 if Path(url.toLocalFile()).suffix.lower() == ".clip" 

865 ] if event.mimeData().hasUrls() else [] 

866 if project_paths: 

867 self.project.open_dropped(project_paths[0]) 

868 event.acceptProposedAction() 

869 return 

870 if remap_paths: 

871 self.time_remap.open_dropped(remap_paths[0]) 

872 event.acceptProposedAction() 

873 return 

874 if clip_paths: 

875 self.importer.clip_animation(clip_paths[0]) 

876 event.acceptProposedAction() 

877 return 

878 super().dropEvent(event) 

879 

880 

881 def import_images_dialog(self): 

882 paths, _ = QFileDialog.getOpenFileNames( 

883 self, 

884 tr("画像を読み込む"), 

885 "", 

886 tr("画像 (*.png *.jpg *.jpeg *.tga);;" 

887 "PNG (*.png);;JPEG (*.jpg *.jpeg);;TGA (*.tga)"), 

888 ) 

889 if not paths: 

890 return 

891 if len(paths) == 1: 

892 self.import_dropped_image(paths[0]) 

893 else: 

894 self.import_dropped_images(paths) 

895 

896 def import_image_folder_dialog(self): 

897 folder = QFileDialog.getExistingDirectory( 

898 self, 

899 tr("画像フォルダーを読み込む"), 

900 "", 

901 ) 

902 if not folder: 

903 return 

904 

905 folder_path = Path(folder) 

906 supported_suffixes = {".png", ".jpg", ".jpeg", ".tga"} 

907 paths = sorted( 

908 ( 

909 path 

910 for path in folder_path.iterdir() 

911 if path.is_file() 

912 and path.suffix.lower() in supported_suffixes 

913 ), 

914 key=self.canvas._natural_path_key, 

915 ) 

916 if not paths: 

917 QMessageBox.information( 

918 self, 

919 tr("画像フォルダーを読み込む"), 

920 tr("選択したフォルダーに対応画像がありません。\n\n" 

921 "対応形式:PNG、JPEG、TGA"), 

922 ) 

923 return 

924 

925 self.import_dropped_images( 

926 [str(path) for path in paths], 

927 layer_name=folder_path.name, 

928 ) 

929 

930 

931 def import_images_raw_dialog(self): 

932 """変換せず(色数削減なしで)画像を下書きレイヤーへ読み込む。""" 

933 paths, _ = QFileDialog.getOpenFileNames( 

934 self, 

935 tr("変換せず読み込む(下書きレイヤー)"), 

936 "", 

937 tr("画像 (*.png *.jpg *.jpeg *.tga);;" 

938 "PNG (*.png);;JPEG (*.jpg *.jpeg);;TGA (*.tga)"), 

939 ) 

940 if not paths: 

941 return 

942 self.import_dropped_images_raw(paths) 

943 

944 def import_dropped_images_raw(self, paths, layer_name=None): 

945 prepared, error = self.prepare_image_import(paths) 

946 if not prepared: 

947 if error != "__cancelled__": 

948 QMessageBox.warning( 

949 self, 

950 tr("変換せず読み込む"), 

951 tr("画像を読み込めませんでした。\n\n{error}").format(error=error), 

952 ) 

953 return 

954 estimated_frames = max(1, len(self.canvas.frames) + len(paths)) 

955 combined_total = max(1, len(paths) + estimated_frames) 

956 progress = create_counter( 

957 self, 

958 tr("変換せず読み込む"), 

959 combined_total, 

960 tr("画像を読み込んでいます"), 

961 ) 

962 ok = False 

963 error = "" 

964 try: 

965 ok, error = self.canvas.import_image_sequence( 

966 paths, 

967 lambda value, total, label: update_counter( 

968 progress, 

969 value, 

970 combined_total, 

971 tr("{label}(画像 {value}/{total})").format(label=label, value=value, total=total), 

972 ), 

973 layer_name=layer_name, 

974 draft=True, 

975 ) 

976 finally: 

977 close_counter(progress) 

978 if not ok: 

979 QMessageBox.warning( 

980 self, 

981 tr("変換せず読み込む"), 

982 tr("画像を読み込めませんでした。\n\n{error}").format(error=error), 

983 ) 

984 return 

985 self.timeline_ops.set_mode("sequence") 

986 # 下書きレイヤーは色数を取得しないため、使用色パネルは空にする。 

987 self.used_color._refresh_without_delay() 

988 self.statusBar().showMessage( 

989 tr("{len}枚を下書きレイヤーへ変換せず読み込みました。").format(len=len(paths)), 

990 3200, 

991 ) 

992 

993 def resize_doc(self): 

994 d=CanvasSizeDialog(constants.CANVAS_WIDTH,constants.CANVAS_HEIGHT,tr("キャンバスサイズの変更"),self) 

995 if d.exec():self.canvas.push_doc_undo();self.replace_doc(*d.values(),preserve=True) 

996 def replace_doc(self,w,h,preserve=False): 

997 old_frames=self.canvas.frames if preserve else None;oldw,oldh=constants.CANVAS_WIDTH,constants.CANVAS_HEIGHT;constants.CANVAS_WIDTH,constants.CANVAS_HEIGHT=w,h 

998 if preserve: 998 ↛ 999line 998 didn't jump to line 999 because the condition on line 998 was never true

999 new=[] 

1000 for f in old_frames: # pyright: ignore[reportOptionalIterable] 

1001 ls=[] 

1002 for l in f.layers: 

1003 ni=blank_image();p=QPainter(ni);p.drawImage(QRectF(OUTSIDE_MARGIN, OUTSIDE_MARGIN, min(oldw, w), min(oldh, h)), l.image, QRectF(OUTSIDE_MARGIN, OUTSIDE_MARGIN, min(oldw, w), min(oldh, h)));p.end();ls.append(Layer(l.name,ni,l.visible,l.opacity,l.is_paper,l.has_content,l.alpha_locked,l.exposure,l.color_filter_enabled,tuple(l.color_filter_rgb) if l.color_filter_rgb is not None else None,bool(l.is_blank_key),l.sequence_number,bool(l.sequence_only),bool(getattr(l,"is_draft",False)),getattr(l,"cell_name",None))) 

1004 new.append(Frame(ls,f.duration)) 

1005 self.canvas.frames=new 

1006 else: 

1007 # A brand-new document must not inherit transient interaction state 

1008 # from the previous one: an active selection/transform or a running 

1009 # playback still references the old (now-discarded) frames and canvas 

1010 # size, which produced stale overlays and intermittent errors on the 

1011 # fresh document. 

1012 if self.canvas._playback_active: 

1013 self.timeline.play.setChecked(False) 

1014 self.play(False) 

1015 self.tween.cancel_transform_or_tween() 

1016 self.canvas.clear_selection() 

1017 # Reset the frame/layer cursor *before* set_timeline_mode runs: it 

1018 # accesses self.canvas.layers (i.e. frames[current_frame]), which 

1019 # would be out of range if the previous document left current_frame 

1020 # past index 0 of the new single-frame list. 

1021 self.canvas.current_frame=0 

1022 self.canvas.active_layer_index=0 

1023 self.palette.clear_categories() 

1024 self.color_chart_ops.clear() 

1025 self.canvas.frames=[make_frame()];self.canvas.undo_stack.clear();self.canvas.redo_stack.clear();self.canvas.clear_history_branches();self.timeline_ops.set_mode("sheet") 

1026 self.canvas.playback_range_start = 0 

1027 self.canvas.playback_range_end = None 

1028 self.canvas.clip_studio_source_metadata = None 

1029 self.canvas.current_frame=0 

1030 self.canvas.active_layer_index=0 

1031 if not preserve: 1031 ↛ 1034line 1031 didn't jump to line 1034 because the condition on line 1031 was always true

1032 self.current_project_path = None 

1033 self.project.update_title() 

1034 self.refresh_ui() 

1035 QTimer.singleShot(0, self.canvas.warm_up_brush_runtime) 

1036 QTimer.singleShot(0,self.fit_canvas) 

1037 

1038 def closeEvent(self, event): 

1039 """Stop active timers and UI signals before Qt destroys child widgets.""" 

1040 if not getattr(self, "_closing", False) and not self.project.confirm_save_before_close(): 

1041 event.ignore() 

1042 return 

1043 self._closing = True 

1044 try: 

1045 session_state.save(self) 

1046 except OSError: 

1047 log.warning("Could not save painting session", exc_info=True) 

1048 try: 

1049 # A closed window must stop filtering application-wide events; 

1050 # otherwise it keeps intercepting input for the rest of the process 

1051 # (and leaks across tests that share one QApplication). 

1052 remove_app_event_filter(self) 

1053 remove_app_event_filter(self.subview) 

1054 except RuntimeError: 

1055 log.debug("application was deleted during event-filter cleanup", exc_info=True) 

1056 try: 

1057 self.timer.stop() 

1058 self._title_timer.stop() 

1059 self._used_color_timer.stop() 

1060 self._visible_color_timer.stop() 

1061 if getattr(self, "_autosave_timer", None) is not None: 1061 ↛ 1063line 1061 didn't jump to line 1063 because the condition on line 1061 was always true

1062 self._autosave_timer.stop() 

1063 self.timeline.blockSignals(True) 

1064 self.timeline.table.blockSignals(True) 

1065 self.timeline.layer_list.blockSignals(True) 

1066 self.canvas.blockSignals(True) 

1067 self._split_drop_timer.stop() 

1068 self._split_drop_overlay.close() 

1069 for skeleton in self._split_drop_skeletons: 

1070 skeleton.close() 

1071 except RuntimeError: 

1072 log.debug("window child was deleted during shutdown", exc_info=True) 

1073 # A clean shutdown clears the autosave so we don't prompt to restore 

1074 # on the next launch. 

1075 self.autosave.clear() 

1076 event.accept() 

1077 

1078 # ------------------------------------------------------------------ 

1079 # メモ(Issue #25) 

1080 # ------------------------------------------------------------------ 

1081 def set_project_memo(self, text): 

1082 """メモの中身を差し替える。開いている窓にも反映する。""" 

1083 self.project_memo = str(text or "") 

1084 if self._memo_window is not None: 

1085 self._memo_window.set_text(self.project_memo) 

1086 

1087 def show_memo_window(self): 

1088 """メモの窓を開く。すでに開いていれば前へ出す。""" 

1089 if self._memo_window is None: 

1090 self._memo_window = MemoWindow(self) 

1091 self._memo_window.set_text(self.project_memo) 

1092 self._memo_window.textChanged.connect(self._memo_text_changed) 

1093 self._memo_window.show() 

1094 self._memo_window.raise_() 

1095 self._memo_window.activateWindow() 

1096 return self._memo_window 

1097 

1098 def _memo_text_changed(self, text): 

1099 self.project_memo = str(text) 

1100 

1101 def pressure(self): 

1102 d = PressureDialog( 

1103 pressure_settings.brush_uses_global(), 

1104 pressure_settings.active_preset_name(), 

1105 pressure_settings.preset_names(), 

1106 pressure_settings.preset, 

1107 pressure_settings.brush_settings(), 

1108 self, 

1109 ) 

1110 d.editPresetsRequested.connect( 

1111 lambda: self.show_preferences("pressure") 

1112 ) 

1113 if d.exec(): 

1114 pressure_settings.set_brush_uses_global(d.uses_global()) 

1115 pressure_settings.set_active_preset(d.active_preset()) 

1116 pressure_settings.set_brush_settings(d.brush_settings()) 

1117 self.apply_pressure_settings() 

1118 if hasattr(self, "preferences_dialog"): 

1119 self.preferences_dialog.reload_pressure_presets() 

1120 

1121 def apply_pressure_settings(self): 

1122 """Point the canvas at the pressure setting the brush uses now.""" 

1123 settings = pressure_settings.effective_settings() 

1124 self.canvas.pressure_enabled = settings["enabled"] 

1125 self.canvas.pressure_min = settings["minimum"] 

1126 self.canvas.pressure_max = settings["maximum"] 

1127 self.canvas.pressure_curve_points = settings["points"] 

1128 self.canvas.pressure_curve = 1.0 

1129 def shortcuts(self): 

1130 # アクションパネルのボタン。メニューの操作と同じもの(黒シルエットなど)は 

1131 # ここにまとめ、ほかのタブからは外して同じ操作が2か所に出ないようにする。 

1132 panel_actions = self.action_panel.shortcut_entries() 

1133 in_panel = {id(action) for _name, action in panel_actions} 

1134 

1135 def without_panel(entries): 

1136 return [(name, action) for name, action in entries if id(action) not in in_panel] 

1137 

1138 categories = [ 

1139 (tr("ファイル・編集"), without_panel(self.file_edit_actions)), 

1140 (tr("ツール"), self.tool_action_list), 

1141 (tr("キャンバス操作"), self.canvas_operation_actions), 

1142 (tr("ツールコマンド"), without_panel(self.tool_command_actions)), 

1143 (tr("アクション"), panel_actions), 

1144 (tr("タイムライン"), self.timeline_actions), 

1145 ] 

1146 if ShortcutDialog(categories, self).exec(): 1146 ↛ 1147line 1146 didn't jump to line 1147 because the condition on line 1146 was never true

1147 self.shortcut_store.save() 

1148 def crop_image(self,fi):return self.canvas.composite(fi,True).copy(OUTSIDE_MARGIN,OUTSIDE_MARGIN,constants.CANVAS_WIDTH,constants.CANVAS_HEIGHT) 

1149 def save_png(self): 

1150 path,_=QFileDialog.getSaveFileName(self,tr("PNG保存"),"frame.png","PNG (*.png)"); 

1151 if path:self.crop_image(self.canvas.current_frame).save(path if path.lower().endswith('.png') else path+'.png','PNG') # pyright: ignore[reportCallIssue,reportArgumentType] # QImage.save accepts a str format at runtime 

1152 def exposure_images(self): 

1153 out=[] 

1154 for i,f in enumerate(self.canvas.frames): 

1155 im=self.crop_image(i) 

1156 for _ in range(f.duration):out.append(im.copy()) 

1157 return out 

1158 def save_tga_image(self, image, path): 

1159 if PILImage is not None: 

1160 rgba=image.convertToFormat(QImage.Format.Format_RGBA8888) 

1161 width,height=rgba.width(),rgba.height() 

1162 ptr = imaging.qimage_buffer(rgba) 

1163 rows=np.frombuffer(ptr,dtype=np.uint8).reshape((height,rgba.bytesPerLine())) 

1164 pixels=rows[:,:width*4].reshape((height,width,4)).copy() 

1165 pil=PILImage.fromarray(pixels,"RGBA") 

1166 pil.save(str(path),format="TGA",compression="tga_rle") 

1167 return True 

1168 return image.save(str(path),"TGA") 

1169 

1170 def save_tga(self): 

1171 path,_=QFileDialog.getSaveFileName(self,tr("TGA保存"),"frame.tga","TGA (*.tga)") 

1172 if not path:return 

1173 if not path.lower().endswith(".tga"):path+=".tga" 

1174 if not self.save_tga_image(self.crop_image(self.canvas.current_frame),Path(path)): 

1175 QMessageBox.warning(self,tr("TGA保存"),tr("TGAを保存できませんでした。Pillowの導入を確認してください。")) 

1176 

1177 def _sheet_duration(self): 

1178 if self.canvas.playback_range_end is not None: 

1179 return max(1, int(self.canvas.playback_range_end)) 

1180 duration = max(1, len(self.canvas.frames)) 

1181 for column, frame in enumerate(self.canvas.frames): 

1182 for layer in frame.layers: 

1183 if layer.sequence_only: 1183 ↛ 1184line 1183 didn't jump to line 1184 because the condition on line 1183 was never true

1184 continue 

1185 if layer.has_content or layer.is_blank_key: 

1186 duration = max( 

1187 duration, 

1188 column + max(1, int(layer.exposure)), 

1189 ) 

1190 return duration 

1191 

1192 

1193 def play(self, on): 

1194 if on: 

1195 fps = max(1, int(self.timeline.fps.value())) 

1196 self._playback_started_at = time.perf_counter() 

1197 self._playback_emitted_steps = 0 

1198 self.canvas.set_playback_active(True) 

1199 

1200 # 本来のフレーム間隔より細かく確認し、遅延時は 

1201 # 経過時間に合わせてフレームを追いつかせる。 

1202 poll_interval = max( 

1203 4, 

1204 min(16, int(round(500.0 / fps))), 

1205 ) 

1206 self.timer.start(poll_interval) 

1207 else: 

1208 self.timer.stop() 

1209 self._playback_started_at = None 

1210 self._playback_emitted_steps = 0 

1211 self.canvas.set_playback_active(False) 

1212 

1213 # 再生中は省略していたタイムライン選択同期を停止時に1回だけ行う。 

1214 self.canvas.selectionChanged.emit() 

1215 self.canvas.update() 

1216 

1217 def advance(self): 

1218 if self._playback_started_at is None: 1218 ↛ 1219line 1218 didn't jump to line 1219 because the condition on line 1218 was never true

1219 return 

1220 fps = max(1, int(self.timeline.fps.value())) 

1221 elapsed = max( 

1222 0.0, 

1223 time.perf_counter() - self._playback_started_at, 

1224 ) 

1225 expected_steps = int(math.floor(elapsed * fps)) 

1226 delta = expected_steps - self._playback_emitted_steps 

1227 if delta <= 0: 1227 ↛ 1230line 1227 didn't jump to line 1230 because the condition on line 1227 was always true

1228 return 

1229 

1230 self._playback_emitted_steps = expected_steps 

1231 self.canvas.playback_advance(delta)