Coverage for paintmaskanimator/main_window_scope_ops.py: 98%
45 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"""一括処理ランナー(Issue #9)と MainWindow の UI を繋ぐ層。
3``frame_scope`` 側は UI を知らない純粋な実行器なので、進捗ダイアログ・待機
4カーソル・キャンセル・キャッシュ破棄といった「画面側の後始末」はここが引き
5受ける。各操作(ゴミ取り・色置換・変形…)は
6:meth:`ScopeOpsController.run_over_scope` を呼ぶだけでよく、全コマ対応を自前で
7書き直す必要がなくなる。
8"""
9from typing import TYPE_CHECKING
11from PySide6.QtCore import Qt
12from PySide6.QtWidgets import QApplication
14from .i18n import tr
15from .time_display import frame_position
16from .frame_scope import FrameScope, apply_over_scope
17from .progress import close_counter, create_counter, update_counter
18from .logging_setup import get_logger
20if TYPE_CHECKING:
21 from .main_window import MainWindow
23log = get_logger(__name__)
26class ScopeOpsController:
27 """Owned by ``MainWindow`` as ``window.scope``.
29 A collaborator rather than a mixin -- see main_window_export.py for why.
30 """
32 def __init__(self, window: "MainWindow"):
33 self.window = window
35 def current_frame(self, all_frames, *, layers=None, selection_only=False):
36 """「すべてのコマ」チェックボックスの状態からスコープを組み立てる。
38 適用範囲の指定はいずれ1箇所へ統一する(#9 の受け入れ条件)。その移行を
39 1関数に閉じておくため、各呼び出し元はこの入口だけを使う。
40 """
41 return FrameScope.choose(
42 self.window.canvas,
43 bool(all_frames),
44 layers=layers,
45 selection_only=selection_only,
46 )
48 def run_over(
49 self,
50 scope,
51 op,
52 *,
53 label,
54 progress_title=None,
55 progress_label=None,
56 count_pixels=None,
57 cancellable=False,
58 ):
59 """スコープ内の各セルへ ``op`` を適用し、進捗表示と後始末まで行う。
61 戻り値は :class:`~.frame_scope.ScopeResult`。キャンセルされた場合は
62 ドキュメントが変更されていないことがランナー側で保証されている。
63 """
64 title = progress_title or label
65 dialog_state = {"dialog": None, "total": 0}
67 def report(done, total, cell):
68 if dialog_state["dialog"] is None:
69 dialog_state["total"] = max(1, int(total))
70 dialog_state["dialog"] = create_counter(
71 self.window,
72 title,
73 dialog_state["total"],
74 progress_label or tr("{label}の対象コマを確認しています").format(label=label),
75 cancellable=bool(cancellable),
76 )
77 frame_index = int(cell[0])
78 update_counter(
79 dialog_state["dialog"],
80 done,
81 dialog_state["total"],
82 tr("{position}に{label}を適用しています").format(
83 position=frame_position(frame_index), label=label),
84 )
86 def cancel_requested():
87 dialog = dialog_state["dialog"]
88 return bool(dialog is not None and dialog.wasCanceled())
90 should_cancel = cancel_requested if cancellable else None
92 QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
93 try:
94 result = apply_over_scope(
95 self.window.canvas,
96 scope,
97 op,
98 label=label,
99 progress=report,
100 should_cancel=should_cancel,
101 count_pixels=count_pixels,
102 )
103 finally:
104 QApplication.restoreOverrideCursor()
105 close_counter(dialog_state["dialog"])
107 if result.changed:
108 self.invalidate_caches()
109 for frame_index, layer_index in result.changed_cell_list:
110 self.window.canvas.cellChanged.emit(int(frame_index), int(layer_index))
111 self.window.canvas.changed.emit()
112 self.window.canvas.update()
113 return result
115 def invalidate_caches(self):
116 """画像実体を差し替えたときに捨てる表示用キャッシュをまとめる。
118 一括処理は ``layer.image`` を新しいオブジェクトへ差し替えるため、画像を
119 キーにした派生キャッシュを残すと表示/非表示の切替で旧画像が復活する。
120 破棄漏れが各所で再発しないよう、対象の一覧はここだけに置く。
121 """
122 canvas = self.window.canvas
123 for cache_name in (
124 "_color_filter_cache",
125 "_color_index_cache",
126 "_silhouette_cache",
127 "_onion_cache",
128 "_playback_frame_cache",
129 ):
130 cache = getattr(canvas, cache_name, None)
131 if cache is not None: 131 ↛ 123line 131 didn't jump to line 123 because the condition on line 131 was always true
132 cache.clear()