Coverage for paintmaskanimator/main_window_project_io.py: 84%

263 statements  

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

1"""Project open/save orchestration; owned by ``MainWindow`` as ``window.project``. 

2 

3Drives the save/open file dialogs, the unsaved-changes guard, and loading a 

4project archive back into the widgets. The low-level archive read/write lives in 

5``project_io``; this module is the UI orchestration on top of it. 

6 

7A collaborator rather than a mixin -- see ``main_window_export.py`` for why. 

8""" 

9import copy 

10from dataclasses import fields 

11from typing import TYPE_CHECKING 

12from pathlib import Path 

13from PySide6.QtCore import QTimer 

14from PySide6.QtGui import QColor 

15from PySide6.QtWidgets import QFileDialog, QMessageBox 

16from .i18n import tr 

17from .constants import APP_DISPLAY_NAME 

18from . import config, constants, project_io 

19from .errors import OPERATION_ERRORS as _OPERATION_ERRORS 

20from .logging_setup import get_logger 

21 

22if TYPE_CHECKING: 

23 from .main_window import MainWindow 

24 

25log = get_logger(__name__) 

26 

27#: 「最近使用したファイル」を残す設定のキーと、残す件数。 

28RECENT_PROJECTS_KEY = "recent_projects" 

29RECENT_PROJECTS_LIMIT = 10 

30 

31 

32class ProjectIOController: 

33 """Owned by ``MainWindow`` as ``window.project``. 

34 

35 A collaborator rather than a mixin -- see main_window_export.py for why. 

36 """ 

37 

38 def __init__(self, window: "MainWindow"): 

39 self.window = window 

40 self._saved_signature = None 

41 

42 # ------------------------------------------------------------------ 

43 # 最近使用したファイル 

44 # ------------------------------------------------------------------ 

45 def recent_projects(self): 

46 """新しい順のパス一覧(文字列)。""" 

47 value = config.get_value(RECENT_PROJECTS_KEY, []) 

48 if not isinstance(value, list): 48 ↛ 49line 48 didn't jump to line 49 because the condition on line 48 was never true

49 return [] 

50 return [str(item) for item in value if isinstance(item, str) and item] 

51 

52 def remember_recent(self, path): 

53 """開いた・保存したプロジェクトを一覧の先頭に入れる。""" 

54 try: 

55 text = str(Path(path).resolve()) 

56 except OSError: 

57 text = str(path) 

58 paths = [text] + [item for item in self.recent_projects() if item != text] 

59 config.set_value(RECENT_PROJECTS_KEY, paths[:RECENT_PROJECTS_LIMIT]) 

60 

61 def forget_recent(self, path): 

62 paths = [item for item in self.recent_projects() if item != str(path)] 

63 config.set_value(RECENT_PROJECTS_KEY, paths) 

64 

65 def clear_recent(self): 

66 config.set_value(RECENT_PROJECTS_KEY, []) 

67 

68 def fill_recent_menu(self, menu): 

69 """ファイル › 最近使用したファイル の中身を、開くたびに作り直す。""" 

70 menu.clear() 

71 paths = self.recent_projects() 

72 if not paths: 

73 empty = menu.addAction(tr("(なし)")) 

74 empty.setEnabled(False) 

75 return 

76 for index, text in enumerate(paths, 1): 

77 path = Path(text) 

78 label = f"&{index % 10} {path.name}" if index <= 10 else path.name 

79 action = menu.addAction(label) 

80 action.setToolTip(text) 

81 action.setStatusTip(text) 

82 action.triggered.connect( 

83 lambda _checked=False, target=text: self.open_recent(target) 

84 ) 

85 menu.addSeparator() 

86 menu.addAction(tr("一覧を消去"), self.clear_recent) 

87 

88 def open_recent(self, path): 

89 if not Path(path).exists(): 89 ↛ 99line 89 didn't jump to line 99 because the condition on line 89 was always true

90 self.forget_recent(path) 

91 QMessageBox.warning( 

92 self.window, 

93 tr("最近使用したファイル"), 

94 tr("ファイルが見つかりませんでした。一覧から外します。\n\n{path}").format( 

95 path=path 

96 ), 

97 ) 

98 return False 

99 return self.open(path) 

100 

101 def update_title(self): 

102 suffix = " *" if self.has_unsaved_changes() else "" 

103 if self.window.current_project_path: 

104 name = Path(self.window.current_project_path).name 

105 self.window.setWindowTitle(f"{APP_DISPLAY_NAME} — {name}{suffix}") 

106 else: 

107 self.window.setWindowTitle( 

108 tr("{NAME} — 新規プロジェクト").format(NAME=APP_DISPLAY_NAME) + suffix 

109 ) 

110 

111 def save(self): 

112 if ( 

113 not self.window.current_project_path 

114 or Path(self.window.current_project_path).suffix.lower() != ".pman" 

115 ): 

116 return self.save_as() 

117 return self.write(self.window.current_project_path) 

118 

119 def save_as(self): 

120 initial = ( 

121 str(Path(self.window.current_project_path).with_suffix(".pman")) 

122 if self.window.current_project_path 

123 else "untitled.pman" 

124 ) 

125 path, _ = QFileDialog.getSaveFileName( 

126 self.window, 

127 tr("名前を付けて保存"), 

128 initial, 

129 "PaintMaskAnimator Project (*.pman)", 

130 ) 

131 if not path: 131 ↛ 132line 131 didn't jump to line 132 because the condition on line 131 was never true

132 return False 

133 if not path.lower().endswith(".pman"): 133 ↛ 134line 133 didn't jump to line 134 because the condition on line 133 was never true

134 path = str(Path(path).with_suffix(".pman")) 

135 if self.write(path): 135 ↛ 139line 135 didn't jump to line 139 because the condition on line 135 was always true

136 self.window.current_project_path = Path(path) 

137 self.update_title() 

138 return True 

139 return False 

140 

141 def write(self, path): 

142 project_path = Path(path) 

143 

144 metadata = self.window.build_project_metadata() 

145 

146 try: 

147 project_io.write_project_archive( 

148 project_path, 

149 metadata, 

150 self.window.canvas.frames, 

151 self.window.canvas._sequence_archive, 

152 self.window.png_cache, 

153 thumbnail=self.window.crop_image(self.window.canvas.current_frame), 

154 ) 

155 self.window.current_project_path = project_path 

156 self.update_title() 

157 self.remember_recent(project_path) 

158 self.mark_saved() 

159 self.window.statusBar().showMessage( 

160 tr("プロジェクトを保存しました:{name}").format(name=project_path.name), 

161 3000, 

162 ) 

163 return True 

164 except _OPERATION_ERRORS as error: 

165 log.error("project save failed: %s", error, exc_info=True) 

166 QMessageBox.critical( 

167 self.window, 

168 tr("プロジェクト保存エラー"), 

169 tr("保存できませんでした。\n\n{error}").format(error=error), 

170 ) 

171 return False 

172 

173 def open_dialog(self): 

174 path, _ = QFileDialog.getOpenFileName( 

175 self.window, 

176 tr("プロジェクトを開く"), 

177 "", 

178 tr("PaintMaskAnimator Project (*.pman);;" 

179 "旧Oekaki Animation Project (*.oap)"), 

180 ) 

181 if path: 181 ↛ exitline 181 didn't return from function 'open_dialog' because the condition on line 181 was always true

182 self.open(path) 

183 

184 def _content_signature(self): 

185 """Track document content without encoding or scanning image pixels.""" 

186 def layer_signature(layer): 

187 return tuple( 

188 value.cacheKey() if hasattr(value, "cacheKey") else copy.deepcopy(value) 

189 for field in fields(layer) 

190 for value in (getattr(layer, field.name),) 

191 ) 

192 canvas = self.window.canvas 

193 frames = tuple((frame.duration, tuple(layer_signature(layer) for layer in frame.layers)) 

194 for frame in canvas.frames) 

195 archive = tuple((key, layer_signature(layer)) 

196 for key, layer in sorted(canvas._sequence_archive.items())) 

197 metadata = self.window.build_project_metadata() 

198 for key in ( 

199 "current_frame", "active_layer_index", "colors", "display", "pressure", 

200 "timeline_mode", 

201 ): 

202 metadata.pop(key, None) 

203 return frames, archive, copy.deepcopy(metadata) 

204 

205 def mark_saved(self): 

206 self._saved_signature = self._content_signature() 

207 self.update_title() 

208 

209 def has_unsaved_changes(self): 

210 return self._saved_signature != self._content_signature() 

211 

212 def confirm_save_before_close(self): 

213 if not self.has_unsaved_changes(): 

214 return True 

215 dialog = QMessageBox(self.window) 

216 dialog.setObjectName("unsavedCloseConfirmation") 

217 dialog.setIcon(QMessageBox.Icon.Warning) 

218 dialog.setWindowTitle(tr("未保存の変更")) 

219 dialog.setText(tr("変更を保存してから終了しますか?")) 

220 save_button = dialog.addButton(tr("保存する"), QMessageBox.ButtonRole.AcceptRole) 

221 discard_button = dialog.addButton( 

222 tr("保存しない"), QMessageBox.ButtonRole.DestructiveRole 

223 ) 

224 cancel_button = dialog.addButton(tr("キャンセル"), QMessageBox.ButtonRole.RejectRole) 

225 dialog.setDefaultButton(save_button) 

226 dialog.setEscapeButton(cancel_button) 

227 dialog.exec() 

228 clicked = dialog.clickedButton() 

229 if clicked is save_button: 

230 return bool(self.save()) 

231 return clicked is discard_button 

232 

233 def confirm_save_before_dropped(self): 

234 if not self.has_unsaved_changes(): 

235 return True 

236 dialog = QMessageBox(self.window) 

237 dialog.setIcon(QMessageBox.Icon.Question) 

238 dialog.setWindowTitle(tr("プロジェクトを開く")) 

239 dialog.setText( 

240 tr("現在のキャンバスを保存してから、" 

241 "ドロップしたプロジェクトを開きますか?") 

242 ) 

243 save_button = dialog.addButton( 

244 tr("保存する"), QMessageBox.ButtonRole.AcceptRole 

245 ) 

246 discard_button = dialog.addButton( 

247 tr("保存しない"), QMessageBox.ButtonRole.DestructiveRole 

248 ) 

249 cancel_button = dialog.addButton( 

250 tr("キャンセル"), QMessageBox.ButtonRole.RejectRole 

251 ) 

252 dialog.setDefaultButton(save_button) 

253 dialog.exec() 

254 clicked = dialog.clickedButton() 

255 if clicked is cancel_button or clicked is None: 255 ↛ 257line 255 didn't jump to line 257 because the condition on line 255 was always true

256 return False 

257 if clicked is save_button: 

258 return bool(self.save()) 

259 return clicked is discard_button 

260 

261 def open_dropped(self, path): 

262 project_path = Path(path) 

263 if project_path.suffix.lower() != ".pman": 263 ↛ 264line 263 didn't jump to line 264 because the condition on line 263 was never true

264 return False 

265 if not self.confirm_save_before_dropped(): 

266 return False 

267 return self.open(project_path) 

268 

269 def open(self, path): 

270 project_path = Path(path) 

271 try: 

272 metadata, loaded_frames, width, height = project_io.read_project_archive( 

273 project_path, self.window.png_cache 

274 ) 

275 

276 self.window.palette.clear_categories() 

277 self.window._pending_palette_categories = metadata.get( 

278 "used_color_categories", {} 

279 ) 

280 self.window.color_chart_ops.set_data(metadata.get("color_chart", {})) 

281 

282 constants.CANVAS_WIDTH = width 

283 constants.CANVAS_HEIGHT = height 

284 self.window.canvas.frames = loaded_frames 

285 self.window.canvas._sequence_archive = metadata.pop( 

286 "_loaded_sequence_archive", {} 

287 ) 

288 self.window.timeline.sequence_archive = self.window.canvas._sequence_archive 

289 clip_source = metadata.get("clip_studio_source") 

290 self.window.canvas.clip_studio_source_metadata = ( 

291 dict(clip_source) if isinstance(clip_source, dict) else None 

292 ) 

293 self.window.canvas.current_frame = max( 

294 0, 

295 min( 

296 int(metadata.get("current_frame", 0)), 

297 len(loaded_frames) - 1, 

298 ), 

299 ) 

300 layer_count = len( 

301 loaded_frames[self.window.canvas.current_frame].layers 

302 ) 

303 self.window.canvas.active_layer_index = max( 

304 0, 

305 min( 

306 int(metadata.get("active_layer_index", 0)), 

307 layer_count - 1, 

308 ), 

309 ) 

310 

311 colors = metadata.get("colors", {}) 

312 self.window.canvas.main_color = QColor( 

313 colors.get("main", "#000000") 

314 ) 

315 self.window.canvas.sub_color = QColor( 

316 colors.get("sub", "#FF0000") 

317 ) 

318 mode = colors.get("mode", "main") 

319 self.window.canvas.color_mode = ( 

320 mode if mode in ("main", "sub", "transparent") else "main" 

321 ) 

322 self.window.canvas.transparent_display_color = QColor( 

323 colors.get("background", "#FFFFFF") 

324 ) 

325 self.window.palette.set_background_display_color( 

326 self.window.canvas.transparent_display_color 

327 ) 

328 

329 display = metadata.get("display", {}) 

330 self.window.canvas.silhouette_non_background = bool( 

331 display.get("silhouette_non_background", False) 

332 ) 

333 self.window.canvas.onion_skin = bool(display.get("onion_skin", False)) 

334 self.window.canvas.onion_previous_count = max( 

335 0, min(12, int(display.get("onion_previous_count", 1))) 

336 ) 

337 self.window.canvas.onion_next_count = max( 

338 0, min(12, int(display.get("onion_next_count", 1))) 

339 ) 

340 self.window.canvas.onion_previous_opacity = max( 

341 0.01, 

342 min(1.0, float(display.get("onion_previous_opacity", 0.22))), 

343 ) 

344 self.window.canvas.onion_next_opacity = max( 

345 0.01, 

346 min(1.0, float(display.get("onion_next_opacity", 0.22))), 

347 ) 

348 

349 def normalized_onion_levels(raw_values, count): 

350 values = [] 

351 if isinstance(raw_values, list): 351 ↛ 359line 351 didn't jump to line 359 because the condition on line 351 was always true

352 for value in raw_values[:12]: 

353 try: 

354 values.append( 

355 max(0, min(100, int(value))) 

356 ) 

357 except (TypeError, ValueError): 

358 values.append(100) 

359 count = max(0, min(12, int(count))) 

360 while len(values) < count: 360 ↛ 361line 360 didn't jump to line 361 because the condition on line 360 was never true

361 index = len(values) 

362 if count <= 1: 

363 default_value = 100 

364 else: 

365 default_value = int(round( 

366 100.0 

367 - 55.0 

368 * index 

369 / float(max(1, count - 1)) 

370 )) 

371 values.append( 

372 max(10, min(100, default_value)) 

373 ) 

374 return values[:count] 

375 

376 self.window.canvas.onion_previous_levels = ( 

377 normalized_onion_levels( 

378 display.get("onion_previous_levels"), 

379 self.window.canvas.onion_previous_count, 

380 ) 

381 ) 

382 self.window.canvas.onion_next_levels = ( 

383 normalized_onion_levels( 

384 display.get("onion_next_levels"), 

385 self.window.canvas.onion_next_count, 

386 ) 

387 ) 

388 self.window.canvas.onion_center_percent = max( 

389 0.0, 

390 min( 

391 100.0, 

392 float( 

393 display.get( 

394 "onion_center_percent", 

395 50.0, 

396 ) 

397 ), 

398 ), 

399 ) 

400 self.window.canvas.onion_previous_color = QColor( 

401 display.get("onion_previous_color", "#FF5C5C") 

402 ) 

403 self.window.canvas.onion_next_color = QColor( 

404 display.get("onion_next_color", "#5CA0FF") 

405 ) 

406 self.window.canvas.onion_previous_color_enabled = bool( 

407 display.get("onion_previous_color_enabled", False) 

408 ) 

409 self.window.canvas.onion_next_color_enabled = bool( 

410 display.get("onion_next_color_enabled", False) 

411 ) 

412 self.window.canvas.onion_selected_colors_only = bool( 

413 display.get("onion_selected_colors_only", False) 

414 ) 

415 self.window.canvas.onion_previous_shift_x = max( 

416 -10000.0, 

417 min( 

418 10000.0, 

419 float( 

420 display.get( 

421 "onion_previous_shift_x", 

422 0.0, 

423 ) 

424 ), 

425 ), 

426 ) 

427 self.window.canvas.onion_previous_shift_y = max( 

428 -10000.0, 

429 min( 

430 10000.0, 

431 float( 

432 display.get( 

433 "onion_previous_shift_y", 

434 0.0, 

435 ) 

436 ), 

437 ), 

438 ) 

439 self.window.canvas.onion_previous_rotation = ( 

440 ( 

441 float( 

442 display.get( 

443 "onion_previous_rotation", 

444 0.0, 

445 ) 

446 ) 

447 + 180.0 

448 ) 

449 % 360.0 

450 ) - 180.0 

451 self.window.canvas.onion_previous_scale = max( 

452 1.0, 

453 min( 

454 199.0, 

455 float( 

456 display.get( 

457 "onion_previous_scale", 

458 100.0, 

459 ) 

460 ), 

461 ), 

462 ) 

463 self.window.canvas.onion_next_shift_x = max( 

464 -10000.0, 

465 min( 

466 10000.0, 

467 float( 

468 display.get( 

469 "onion_next_shift_x", 

470 0.0, 

471 ) 

472 ), 

473 ), 

474 ) 

475 self.window.canvas.onion_next_shift_y = max( 

476 -10000.0, 

477 min( 

478 10000.0, 

479 float( 

480 display.get( 

481 "onion_next_shift_y", 

482 0.0, 

483 ) 

484 ), 

485 ), 

486 ) 

487 self.window.canvas.onion_next_rotation = ( 

488 ( 

489 float( 

490 display.get( 

491 "onion_next_rotation", 

492 0.0, 

493 ) 

494 ) 

495 + 180.0 

496 ) 

497 % 360.0 

498 ) - 180.0 

499 self.window.canvas.onion_next_scale = max( 

500 1.0, 

501 min( 

502 199.0, 

503 float( 

504 display.get( 

505 "onion_next_scale", 

506 100.0, 

507 ) 

508 ), 

509 ), 

510 ) 

511 self.window.canvas.onion_tu_tb_scale = max( 

512 0.05, 

513 min( 

514 20.0, 

515 float( 

516 display.get( 

517 "onion_tu_tb_scale", 

518 1.0, 

519 ) 

520 ), 

521 ), 

522 ) 

523 

524 # 筆圧は描く人とペンの設定なので、ファイルに入っていても使わない 

525 # (環境設定のプリセットとブラシの設定を使う)。 

526 self.window.timeline.fps.setValue( 

527 max(1, min(60, int(metadata.get("fps", 24)))) 

528 ) 

529 self.window.set_project_memo(metadata.get("memo", "")) 

530 playback_range = metadata.get("playback_range", [0, None]) 

531 try: 

532 range_start, range_end = playback_range 

533 range_start = max(0, int(range_start)) 

534 range_end = None if range_end is None else max(range_start + 1, int(range_end)) 

535 except (TypeError, ValueError): 

536 range_start, range_end = 0, None 

537 self.window.canvas.playback_range_start = range_start 

538 self.window.canvas.playback_range_end = range_end 

539 self.window.canvas.undo_stack.clear() 

540 self.window.canvas.redo_stack.clear() 

541 self.window.canvas.clear_history_branches() 

542 self.window.canvas._color_filter_cache.clear() 

543 self.window.canvas._silhouette_cache.clear() 

544 self.window._used_color_cache.clear() 

545 self.window.current_project_path = project_path 

546 self.update_title() 

547 self.window.tools.set_colors( 

548 self.window.canvas.main_color, 

549 self.window.canvas.sub_color, 

550 self.window.canvas.color_mode, 

551 self.window.canvas.transparent_display_color, 

552 ) 

553 self.window.a_silhouette.setChecked( 

554 self.window.canvas.silhouette_non_background 

555 ) 

556 silhouette_button = self.window.action_panel.button("silhouette") 

557 if silhouette_button is not None: 557 ↛ 558line 557 didn't jump to line 558 because the condition on line 557 was never true

558 silhouette_button.setChecked( 

559 self.window.canvas.silhouette_non_background 

560 ) 

561 self.window.timeline.onion.blockSignals(True) 

562 self.window.timeline.onion.setChecked(self.window.canvas.onion_skin) 

563 self.window.timeline.onion.blockSignals(False) 

564 if not any( 

565 layer.sequence_number is not None 

566 for frame in self.window.canvas.frames 

567 for layer in frame.layers 

568 if layer.has_content 

569 ): 

570 self.window.canvas.normalize_sequence_numbers() 

571 self.window.canvas.apply_sequence_only_entries() 

572 self.window.timeline_ops.set_mode("sheet") 

573 self.window.refresh_ui() 

574 self.window.used_color.schedule_refresh() 

575 QTimer.singleShot(0, self.window.fit_canvas) 

576 self.remember_recent(project_path) 

577 self.mark_saved() 

578 self.window.statusBar().showMessage( 

579 tr("プロジェクトを開きました:{name}").format(name=project_path.name), 

580 3000, 

581 ) 

582 return True 

583 except _OPERATION_ERRORS as error: 

584 log.error("project open failed: %s", error, exc_info=True) 

585 QMessageBox.critical( 

586 self.window, 

587 tr("プロジェクト読込エラー"), 

588 tr("プロジェクトを開けませんでした。\n\n{error}").format(error=error), 

589 ) 

590 return False