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

1"""一括処理ランナー(Issue #9)と MainWindow の UI を繋ぐ層。 

2 

3``frame_scope`` 側は UI を知らない純粋な実行器なので、進捗ダイアログ・待機 

4カーソル・キャンセル・キャッシュ破棄といった「画面側の後始末」はここが引き 

5受ける。各操作(ゴミ取り・色置換・変形…)は 

6:meth:`ScopeOpsController.run_over_scope` を呼ぶだけでよく、全コマ対応を自前で 

7書き直す必要がなくなる。 

8""" 

9from typing import TYPE_CHECKING 

10 

11from PySide6.QtCore import Qt 

12from PySide6.QtWidgets import QApplication 

13 

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 

19 

20if TYPE_CHECKING: 

21 from .main_window import MainWindow 

22 

23log = get_logger(__name__) 

24 

25 

26class ScopeOpsController: 

27 """Owned by ``MainWindow`` as ``window.scope``. 

28 

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

30 """ 

31 

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

33 self.window = window 

34 

35 def current_frame(self, all_frames, *, layers=None, selection_only=False): 

36 """「すべてのコマ」チェックボックスの状態からスコープを組み立てる。 

37 

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 ) 

47 

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`` を適用し、進捗表示と後始末まで行う。 

60 

61 戻り値は :class:`~.frame_scope.ScopeResult`。キャンセルされた場合は 

62 ドキュメントが変更されていないことがランナー側で保証されている。 

63 """ 

64 title = progress_title or label 

65 dialog_state = {"dialog": None, "total": 0} 

66 

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 ) 

85 

86 def cancel_requested(): 

87 dialog = dialog_state["dialog"] 

88 return bool(dialog is not None and dialog.wasCanceled()) 

89 

90 should_cancel = cancel_requested if cancellable else None 

91 

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

106 

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 

114 

115 def invalidate_caches(self): 

116 """画像実体を差し替えたときに捨てる表示用キャッシュをまとめる。 

117 

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