Coverage for paintmaskanimator/main_window_onion.py: 30%
143 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"""Onion skin; owned by ``MainWindow`` as ``window.onion``.
3Toggles onion-skin display, owns the floating settings window, and implements
4the "center canvas between onion shifts" transform.
6A collaborator rather than a mixin -- see ``main_window_export.py`` for why.
7"""
8from typing import TYPE_CHECKING
10import math
11from PySide6.QtCore import QPointF, Qt
12from PySide6.QtGui import QColor
14from .i18n import tr
15from .onion import OnionSkinSettingsBrowser
16from .tool_window import ToolWindow
18if TYPE_CHECKING:
19 from .main_window import MainWindow
22class OnionSkinController:
23 """Owned by ``MainWindow`` as ``window.onion``.
25 A collaborator rather than a mixin -- see main_window_export.py for why.
26 """
28 def __init__(self, window: "MainWindow"):
29 self.window = window
31 """Onion-skin toggles and the onion settings window."""
33 def set_all_layers(self, enabled):
34 self.window.canvas.onion_all_layers = bool(enabled)
35 self.window.canvas._onion_cache.clear()
36 self.window.canvas.update()
38 def set_enabled(self, enabled):
39 self.window.canvas.onion_skin = bool(enabled)
40 self.window.canvas._onion_cache.clear()
41 self.window.canvas.update()
43 def toggle_settings_popup(self, checked):
44 checked = bool(checked)
45 if checked: 45 ↛ 48line 45 didn't jump to line 48 because the condition on line 45 was always true
46 self.show_settings()
47 else:
48 settings_window = self.window._onion_settings_window
49 if settings_window is not None:
50 settings_window.close()
51 self.window.canvas.update()
53 def show_settings(self):
54 browser = self.window._onion_settings_browser
55 if browser is not None: 55 ↛ 56line 55 didn't jump to line 56 because the condition on line 55 was never true
56 if self.window._onion_settings_window is not None:
57 self.window._onion_settings_window.show_and_raise()
58 return
60 browser = OnionSkinSettingsBrowser(
61 self.window.canvas.onion_previous_count,
62 self.window.canvas.onion_next_count,
63 self.window.canvas.onion_previous_opacity,
64 self.window.canvas.onion_next_opacity,
65 self.window.canvas.onion_previous_color,
66 self.window.canvas.onion_next_color,
67 self.window.canvas.onion_previous_color_enabled,
68 self.window.canvas.onion_next_color_enabled,
69 self.window.canvas.onion_selected_colors_only,
70 self.window.canvas.onion_previous_shift_x,
71 self.window.canvas.onion_previous_shift_y,
72 self.window.canvas.onion_previous_rotation,
73 self.window.canvas.onion_next_shift_x,
74 self.window.canvas.onion_next_shift_y,
75 self.window.canvas.onion_next_rotation,
76 self.window.canvas.onion_previous_scale,
77 self.window.canvas.onion_next_scale,
78 self.window.canvas.onion_previous_levels,
79 self.window.canvas.onion_next_levels,
80 self.window.canvas.onion_center_percent,
81 self.window.canvas.rotation,
82 self.window,
83 )
84 self.window._onion_settings_browser = browser
85 browser.settingsChanged.connect(
86 self.apply_browser_settings
87 )
88 browser.shiftEditRequested.connect(
89 self.window.canvas.begin_onion_shift_interaction
90 )
91 browser.canvasPositionEditRequested.connect(
92 self.window.canvas.begin_onion_canvas_position_interaction
93 )
94 browser.canvasRotationRequested.connect(
95 self.window.canvas.set_onion_canvas_view_rotation
96 )
97 browser.centerCanvasRequested.connect(
98 self.center_canvas_between_shifts
99 )
100 browser.destroyed.connect(
101 self._settings_browser_destroyed
102 )
104 # パネルの列に差し込むのではなく、キャンバスの上に浮かぶ別ウィンドウで
105 # 開く。閉じると中身ごと破棄し、次に開くときは現在の設定から作り直す。
106 settings_window = ToolWindow(
107 self.window, tr("オニオンスキン設定"), "onion_settings", browser,
108 )
109 settings_window.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
110 self.window._onion_settings_window = settings_window
111 settings_window.show_and_raise()
113 def _settings_browser_destroyed(self, *_args):
114 self.window._onion_settings_browser = None
115 self.window._onion_settings_window = None
116 self.window.canvas.cancel_onion_interaction(restore=False)
117 if self.window.timeline.onion_settings.isChecked(): 117 ↛ exitline 117 didn't return from function '_settings_browser_destroyed' because the condition on line 117 was always true
118 self.window.timeline.onion_settings.blockSignals(True)
119 self.window.timeline.onion_settings.setChecked(False)
120 self.window.timeline.onion_settings.blockSignals(False)
122 def apply_browser_settings(self):
123 browser = self.window._onion_settings_browser
124 if browser is None:
125 return
127 self.window.canvas.onion_previous_count = (
128 browser.previous_count.value()
129 )
130 self.window.canvas.onion_next_count = (
131 browser.next_count.value()
132 )
133 self.window.canvas.onion_previous_opacity = (
134 browser.previous_opacity.value() / 100.0
135 )
136 self.window.canvas.onion_next_opacity = (
137 browser.next_opacity.value() / 100.0
138 )
139 self.window.canvas.onion_previous_levels = (
140 browser.previous_levels()
141 )
142 self.window.canvas.onion_next_levels = (
143 browser.next_levels()
144 )
145 self.window.canvas.onion_previous_color = QColor(
146 browser.previous_color
147 )
148 self.window.canvas.onion_next_color = QColor(
149 browser.next_color
150 )
151 self.window.canvas.onion_previous_color_enabled = (
152 browser.previous_color_enabled.isChecked()
153 )
154 self.window.canvas.onion_next_color_enabled = (
155 browser.next_color_enabled.isChecked()
156 )
157 self.window.canvas.onion_selected_colors_only = (
158 browser.selected_colors_only.isChecked()
159 )
160 self.window.canvas.onion_previous_shift_x = float(
161 browser.previous_shift_x.value()
162 )
163 self.window.canvas.onion_previous_shift_y = float(
164 browser.previous_shift_y.value()
165 )
166 self.window.canvas.onion_previous_rotation = float(
167 browser.previous_rotation.value()
168 )
169 self.window.canvas.onion_next_shift_x = float(
170 browser.next_shift_x.value()
171 )
172 self.window.canvas.onion_next_shift_y = float(
173 browser.next_shift_y.value()
174 )
175 self.window.canvas.onion_next_rotation = float(
176 browser.next_rotation.value()
177 )
178 self.window.canvas.onion_previous_scale = float(
179 browser.previous_scale.value()
180 )
181 self.window.canvas.onion_next_scale = float(
182 browser.next_scale.value()
183 )
184 self.window.canvas.onion_center_percent = float(
185 browser.center_percent_value.value()
186 )
188 self.window.canvas._onion_cache.clear()
189 self.window.canvas.update()
191 def sync_browser_from_canvas(self):
192 browser = self.window._onion_settings_browser
193 if browser is None:
194 return
195 browser.set_transform_values(
196 self.window.canvas.onion_previous_shift_x,
197 self.window.canvas.onion_previous_shift_y,
198 self.window.canvas.onion_previous_rotation,
199 self.window.canvas.onion_previous_scale,
200 self.window.canvas.onion_next_shift_x,
201 self.window.canvas.onion_next_shift_y,
202 self.window.canvas.onion_next_rotation,
203 self.window.canvas.onion_next_scale,
204 )
205 browser.set_canvas_rotation_value(
206 self.window.canvas.rotation
207 )
209 def finish_browser_interaction(self):
210 browser = self.window._onion_settings_browser
211 if browser is not None:
212 browser.clear_interaction_buttons()
214 def center_canvas_between_shifts(self, percent):
215 """前後間のTU/TB変形を現在キャンバスの基準へ取り込む。"""
216 self.apply_browser_settings()
217 percent = max(0.0, min(100.0, float(percent)))
218 self.window.canvas.onion_center_percent = percent
219 ratio = percent / 100.0
221 previous_x = float(self.window.canvas.onion_previous_shift_x)
222 previous_y = float(self.window.canvas.onion_previous_shift_y)
223 next_x = float(self.window.canvas.onion_next_shift_x)
224 next_y = float(self.window.canvas.onion_next_shift_y)
225 previous_rotation = float(
226 self.window.canvas.onion_previous_rotation
227 )
228 next_rotation = float(
229 self.window.canvas.onion_next_rotation
230 )
231 previous_scale = max(
232 0.01,
233 float(self.window.canvas.onion_previous_scale) / 100.0,
234 )
235 next_scale = max(
236 0.01,
237 float(self.window.canvas.onion_next_scale) / 100.0,
238 )
240 target_x = previous_x + (next_x - previous_x) * ratio
241 target_y = previous_y + (next_y - previous_y) * ratio
243 # 回転は最短方向、TU/TB拡大率は撮影倍率として線形補間する。
244 rotation_delta = (
245 (next_rotation - previous_rotation + 180.0)
246 % 360.0
247 ) - 180.0
248 target_rotation = self.window.canvas._normalized_angle(
249 previous_rotation + rotation_delta * ratio
250 )
251 target_scale = max(
252 0.01,
253 previous_scale + (next_scale - previous_scale) * ratio,
254 )
256 # 表示用デジタルズームself.canvas.zoomは変更しない。
257 digital_zoom = max(0.01, float(self.window.canvas.zoom))
258 current_tu_tb = max(
259 0.0001,
260 float(self.window.canvas.onion_tu_tb_scale),
261 )
262 old_view_rotation = float(self.window.canvas.rotation)
263 view_angle = math.radians(old_view_rotation)
264 local_display_x = (
265 target_x * digital_zoom * current_tu_tb
266 )
267 local_display_y = (
268 target_y * digital_zoom * current_tu_tb
269 )
270 display_x = (
271 local_display_x * math.cos(view_angle)
272 - local_display_y * math.sin(view_angle)
273 )
274 display_y = (
275 local_display_x * math.sin(view_angle)
276 + local_display_y * math.cos(view_angle)
277 )
279 self.window.canvas.pan += QPointF(display_x, display_y)
280 self.window.canvas.rotation = self.window.canvas._normalized_angle(
281 old_view_rotation + target_rotation
282 )
283 self.window.canvas.onion_tu_tb_scale = max(
284 0.05,
285 min(20.0, current_tu_tb * target_scale),
286 )
288 # H_target^-1 × H_eachで相対位置・回転・倍率を再取得。
289 angle = math.radians(-target_rotation)
290 cos_angle = math.cos(angle)
291 sin_angle = math.sin(angle)
293 def relative_shift(source_x, source_y):
294 dx = float(source_x) - target_x
295 dy = float(source_y) - target_y
296 return QPointF(
297 (dx * cos_angle - dy * sin_angle)
298 / target_scale,
299 (dx * sin_angle + dy * cos_angle)
300 / target_scale,
301 )
303 previous_relative = relative_shift(
304 previous_x,
305 previous_y,
306 )
307 next_relative = relative_shift(next_x, next_y)
309 # 選択した中央%の回転を新しい0°基準として取得する。
310 # キャンバス表示角度は0°へ戻し、前後の角度は
311 # 取得した中央回転との差分として再設定する。
312 absorbed_view_rotation = float(self.window.canvas.rotation)
313 previous_screen_relative = self.window.canvas._rotated_vector(
314 previous_relative.x(),
315 previous_relative.y(),
316 absorbed_view_rotation,
317 )
318 next_screen_relative = self.window.canvas._rotated_vector(
319 next_relative.x(),
320 next_relative.y(),
321 absorbed_view_rotation,
322 )
324 self.window.canvas.onion_previous_shift_x = (
325 previous_screen_relative.x()
326 )
327 self.window.canvas.onion_previous_shift_y = (
328 previous_screen_relative.y()
329 )
330 self.window.canvas.onion_next_shift_x = (
331 next_screen_relative.x()
332 )
333 self.window.canvas.onion_next_shift_y = (
334 next_screen_relative.y()
335 )
336 self.window.canvas.onion_previous_rotation = (
337 self.window.canvas._normalized_angle(
338 previous_rotation - target_rotation
339 )
340 )
341 self.window.canvas.onion_next_rotation = (
342 self.window.canvas._normalized_angle(
343 next_rotation - target_rotation
344 )
345 )
346 self.window.canvas.rotation = 0.0
347 self.window.canvas.onion_previous_scale = max(
348 1.0,
349 min(199.0, previous_scale / target_scale * 100.0),
350 )
351 self.window.canvas.onion_next_scale = max(
352 1.0,
353 min(199.0, next_scale / target_scale * 100.0),
354 )
356 self.sync_browser_from_canvas()
357 self.window.canvas.viewChanged.emit(
358 float(self.window.canvas.zoom),
359 float(self.window.canvas.rotation),
360 )
361 self.window.canvas.update()
362 self.window.statusBar().showMessage(
363 tr("前後の位置・回転・TU/TB拡大率間の{percent:g}%をキャンバス基準へ移しました。(中央回転 {rotation:.1f}°を基準として取得/撮影倍率 {value:.1f}%/キャンバス回転 0°/デジタルズーム変更なし)").format(percent=percent, rotation=target_rotation, value=target_scale * 100.0),
364 4200,
365 )