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
« 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``.
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.
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
22if TYPE_CHECKING:
23 from .main_window import MainWindow
25log = get_logger(__name__)
27#: 「最近使用したファイル」を残す設定のキーと、残す件数。
28RECENT_PROJECTS_KEY = "recent_projects"
29RECENT_PROJECTS_LIMIT = 10
32class ProjectIOController:
33 """Owned by ``MainWindow`` as ``window.project``.
35 A collaborator rather than a mixin -- see main_window_export.py for why.
36 """
38 def __init__(self, window: "MainWindow"):
39 self.window = window
40 self._saved_signature = None
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]
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])
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)
65 def clear_recent(self):
66 config.set_value(RECENT_PROJECTS_KEY, [])
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)
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)
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 )
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)
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
141 def write(self, path):
142 project_path = Path(path)
144 metadata = self.window.build_project_metadata()
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
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)
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)
205 def mark_saved(self):
206 self._saved_signature = self._content_signature()
207 self.update_title()
209 def has_unsaved_changes(self):
210 return self._saved_signature != self._content_signature()
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
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
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)
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 )
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", {}))
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 )
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 )
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 )
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]
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 )
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