Coverage for paintmaskanimator/main_window_docking.py: 72%
942 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"""Dock hamburger menus, split-drop overlays, and floating-window chrome.
3Split out of ``main_window.py`` as a mixin. These methods build the per-dock
4"hamburger" menus, drive the custom split-drop hit zones / overlay feedback
5while dragging docks, and manage floating-window titles and tool-selector
6snapping. They run against a live ``MainWindow`` instance and its
7``dock_manager``.
8"""
9from PySide6.QtCore import QEvent, QPoint, QRect, QRectF, QSize, QTimer, Qt
10from PySide6.QtGui import QCursor, QIcon
11from PySide6.QtWidgets import (
12 QAbstractButton,
13 QApplication,
14 QLabel,
15 QMenu,
16 QSizeGrip,
17 QSizePolicy,
18 QSplitter,
19 QToolButton,
20 QWidget,
21)
22from .i18n import tr
23from ._main_window_members import MainWindowMembers
24import ctypes
25import sys
26import PySide6QtAds as QtAds
27from PySide6.QtCore import QEasingCurve, QEventLoop, QObject, QPropertyAnimation
28from PySide6.QtWidgets import QGraphicsOpacityEffect
29from . import config, icons, theme
30from .widgets import HSVColorWheel
31from .logging_setup import get_logger
33log = get_logger(__name__)
35# 枠なしフロートウィンドウのリサイズ用ハンドルに、どの辺を動かすかを持たせる属性名。
36_RESIZE_EDGES_ATTR = "_paintmask_resize_edges"
39class _FloatingGripTracker(QObject):
40 """Anchor and drive every resize edge of a frameless floating container."""
42 def __init__(self, floating, grip, handles=None):
43 super().__init__(floating)
44 self.floating = floating
45 self.grip = grip
46 self.handles = dict(handles or {})
47 self.handles["bottom_right"] = grip
48 self._manual_resize = False
49 self._resize_origin = QPoint()
50 self._resize_geometry = QRect()
51 self._resize_edges = Qt.Edge.RightEdge | Qt.Edge.BottomEdge
52 self._resize_widget = None
54 def position_handles(self):
55 width = max(0, self.floating.width())
56 height = max(0, self.floating.height())
57 edge = 6
58 corner = 14
59 horizontal_width = max(0, width - corner * 2)
60 vertical_height = max(0, height - corner * 2)
61 geometries = {
62 "top": QRect(corner, 0, horizontal_width, edge),
63 "bottom": QRect(
64 corner, max(0, height - edge), horizontal_width, edge
65 ),
66 "left": QRect(0, corner, edge, vertical_height),
67 "right": QRect(
68 max(0, width - edge), corner, edge, vertical_height
69 ),
70 "top_left": QRect(0, 0, corner, corner),
71 "top_right": QRect(max(0, width - corner), 0, corner, corner),
72 "bottom_left": QRect(
73 0, max(0, height - corner), corner, corner
74 ),
75 }
76 for name, geometry in geometries.items():
77 handle = self.handles.get(name)
78 if handle is not None: 78 ↛ 76line 78 didn't jump to line 76 because the condition on line 78 was always true
79 handle.setGeometry(geometry)
80 handle.raise_()
81 self.grip.move(
82 max(0, width - self.grip.width()),
83 max(0, height - self.grip.height()),
84 )
85 self.grip.raise_()
87 @staticmethod
88 def _clamp_dimension(value, minimum, maximum):
89 maximum = max(minimum, maximum)
90 return max(minimum, min(maximum, value))
92 def _apply_manual_resize(self, delta):
93 geometry = self._resize_geometry
94 minimum = self.floating.minimumSize().expandedTo(
95 self.floating.minimumSizeHint()
96 )
97 maximum = self.floating.maximumSize()
98 x, y = geometry.x(), geometry.y()
99 width, height = geometry.width(), geometry.height()
101 if self._resize_edges & Qt.Edge.LeftEdge: 101 ↛ 102line 101 didn't jump to line 102 because the condition on line 101 was never true
102 resized = self._clamp_dimension(
103 width - delta.x(), minimum.width(), maximum.width()
104 )
105 x += width - resized
106 width = resized
107 elif self._resize_edges & Qt.Edge.RightEdge:
108 width = self._clamp_dimension(
109 width + delta.x(), minimum.width(), maximum.width()
110 )
112 if self._resize_edges & Qt.Edge.TopEdge:
113 resized = self._clamp_dimension(
114 height - delta.y(), minimum.height(), maximum.height()
115 )
116 y += height - resized
117 height = resized
118 elif self._resize_edges & Qt.Edge.BottomEdge: 118 ↛ 119line 118 didn't jump to line 119 because the condition on line 118 was never true
119 height = self._clamp_dimension(
120 height + delta.y(), minimum.height(), maximum.height()
121 )
123 self.floating.setGeometry(x, y, width, height)
125 def eventFilter(self, watched, event):
126 if watched is self.floating and event.type() in (
127 QEvent.Type.Resize, QEvent.Type.Show
128 ):
129 self.position_handles()
130 if watched not in self.handles.values():
131 return False
132 if (
133 event.type() == QEvent.Type.MouseButtonPress
134 and event.button() == Qt.MouseButton.LeftButton
135 ):
136 self._resize_edges = getattr(
137 watched,
138 _RESIZE_EDGES_ATTR,
139 Qt.Edge.RightEdge | Qt.Edge.BottomEdge,
140 )
141 handle = (
142 self.floating.windowHandle()
143 if self.floating.isWindow() else None
144 )
145 if ( 145 ↛ 150line 145 didn't jump to line 150 because the condition on line 145 was never true
146 handle is not None
147 and handle.isTopLevel()
148 and handle.startSystemResize(self._resize_edges)
149 ):
150 event.accept()
151 return True
152 self._manual_resize = True
153 self._resize_origin = event.globalPosition().toPoint()
154 self._resize_geometry = QRect(self.floating.geometry())
155 self._resize_widget = watched
156 try:
157 watched.grabMouse()
158 except RuntimeError:
159 pass
160 event.accept()
161 return True
162 if event.type() == QEvent.Type.MouseMove and self._manual_resize:
163 delta = event.globalPosition().toPoint() - self._resize_origin
164 self._apply_manual_resize(delta)
165 event.accept()
166 return True
167 if event.type() == QEvent.Type.MouseButtonRelease:
168 resize_widget = self._resize_widget
169 self._manual_resize = False
170 self._resize_widget = None
171 if resize_widget is not None: 171 ↛ 176line 171 didn't jump to line 176 because the condition on line 171 was always true
172 try:
173 resize_widget.releaseMouse()
174 except RuntimeError:
175 pass
176 event.accept()
177 return True
178 return False
181class DockingMixin(MainWindowMembers):
182 # 初期配置で各パネルに与える幅(px)。キャンバスは残り全部を使う。
183 DEFAULT_PANEL_WIDTHS = {
184 "toolsDock": 230,
185 "colorWheelDock": 250,
186 "subviewDock": 240,
187 }
188 # 画面が狭いときも、キャンバスにはウィンドウ幅のこの割合を最低限残す。
189 MIN_CANVAS_WIDTH_RATIO = 0.45
191 def _fit_window_to_screen(self, width, height):
192 """既定サイズを画面の作業領域に収めて中央に置く。
194 macOSは画面より大きいウィンドウをOSが縮めるが、Windowsは縮めないため
195 小さいノートPCや拡大率150%の環境ではタイトルバーやタイムラインが
196 画面外にはみ出していた。
197 """
198 screen = self.screen() or QApplication.primaryScreen()
199 if screen is None: 199 ↛ 200line 199 didn't jump to line 200 because the condition on line 199 was never true
200 self.resize(width, height)
201 return
202 available = screen.availableGeometry()
203 # タイトルバーや枠の分を見込んで作業領域より少し小さくする。
204 width = min(width, int(available.width() * 0.95))
205 height = min(height, int(available.height() * 0.92))
206 self.resize(width, height)
207 self.move(
208 available.x() + (available.width() - width) // 2,
209 available.y() + (available.height() - height) // 2,
210 )
212 def _finalize_startup_dock_ui(self):
213 # 保存済みワークスペースを当てる前の、組み立てたままの配置を
214 # ワークスペースメニューの「初期設定」として覚えておく。
215 self.workspace.remember_default(self.dock_manager.saveState())
216 active = config.get_value("active_workspace")
217 if not active or not self.workspace.apply(active): 217 ↛ 225line 217 didn't jump to line 225 because the condition on line 217 was always true
218 # A save/restore cycle makes the initial areas follow the same ADS
219 # reconstruction path used after tabs are stacked.
220 state = self.dock_manager.saveState()
221 self.dock_manager.restoreState(state)
222 # 表示前に組んだ配置は各パネルが最小幅(60px)に潰れて復元されるので、
223 # 表示後に実際のウィンドウサイズから幅と高さを配り直す。
224 self._pending_default_dock_layout = True
225 self._sync_all_area_hamburgers()
227 def showEvent(self, event):
228 super().showEvent(event)
229 if getattr(self, "_pending_default_dock_layout", False): 229 ↛ exitline 229 didn't return from function 'showEvent' because the condition on line 229 was always true
230 self._pending_default_dock_layout = False
231 # 遅延させると、その間にドックが動かされた場合にADSの再構成と
232 # 競合するので、表示と同時にレイアウトを確定させてから配る。
233 layout = self.layout()
234 if layout is not None: 234 ↛ 236line 234 didn't jump to line 236 because the condition on line 234 was always true
235 layout.activate()
236 self._apply_default_dock_layout()
238 @staticmethod
239 def _splitter_slot(widget, orientation):
240 """widget を含む指定方向の最も近い QSplitter と、その直下の子を返す。"""
241 child, parent = widget, widget.parentWidget()
242 while parent is not None: 242 ↛ 246line 242 didn't jump to line 246 because the condition on line 242 was always true
243 if isinstance(parent, QSplitter) and parent.orientation() == orientation:
244 return parent, child
245 child, parent = parent, parent.parentWidget()
246 return None, None
248 @staticmethod
249 def _splitter_index_of(splitter, dock):
250 for index in range(splitter.count()): 250 ↛ 254line 250 didn't jump to line 254 because the loop on line 250 didn't complete
251 child = splitter.widget(index)
252 if child is dock or child.isAncestorOf(dock):
253 return index
254 return -1
256 def _apply_default_dock_layout(self):
257 """ワークスペース未保存時の初期配置に、実用的なパネルサイズを与える。"""
258 try:
259 self._apply_default_panel_widths()
260 self._apply_default_panel_heights()
261 except RuntimeError:
262 # 表示直後にウィンドウが閉じられた場合など。
263 log.debug("default dock layout skipped", exc_info=True)
265 def _apply_default_panel_widths(self):
266 splitter, _ = self._splitter_slot(
267 self.central_dock, Qt.Orientation.Horizontal
268 )
269 if splitter is None: 269 ↛ 270line 269 didn't jump to line 270 because the condition on line 269 was never true
270 return
271 sizes = splitter.sizes()
272 total = sum(sizes)
273 canvas_index = self._splitter_index_of(splitter, self.central_dock)
274 if total <= 0 or canvas_index < 0: 274 ↛ 275line 274 didn't jump to line 275 because the condition on line 274 was never true
275 return
276 wanted = {}
277 docks = {
278 dock.objectName(): dock
279 for dock in (self.tools_dock, self.color_wheel_dock, self.subview_dock)
280 }
281 for name, width in self.DEFAULT_PANEL_WIDTHS.items():
282 index = self._splitter_index_of(splitter, docks[name])
283 # 閉じているパネル(幅0)やキャンバスと同じ列のものは触らない。
284 if index < 0 or index == canvas_index or sizes[index] <= 0: 284 ↛ 285line 284 didn't jump to line 285 because the condition on line 284 was never true
285 continue
286 wanted[index] = max(wanted.get(index, 0), width)
287 if not wanted: 287 ↛ 288line 287 didn't jump to line 288 because the condition on line 287 was never true
288 return
289 # ツール選択など、ここで扱わない列は現在の幅のまま残す。
290 others = sum(
291 size for index, size in enumerate(sizes)
292 if index != canvas_index and index not in wanted
293 )
294 room = total - others - int(total * self.MIN_CANVAS_WIDTH_RATIO)
295 requested = sum(wanted.values())
296 if requested > room > 0:
297 scale = room / requested
298 wanted = {index: int(width * scale) for index, width in wanted.items()}
299 for index, width in wanted.items():
300 sizes[index] = width
301 sizes[canvas_index] = max(1, total - sum(
302 size for index, size in enumerate(sizes) if index != canvas_index
303 ))
304 splitter.setSizes(sizes)
305 self._stretch_only(splitter, canvas_index)
307 @staticmethod
308 def _stretch_only(splitter, index):
309 """ウィンドウの伸縮をその列だけが受け持ち、パネルの幅は保つ。
311 macOSは表示直後に画面に合わせてウィンドウを縮めるので、比例配分の
312 ままだと配ったばかりのパネル幅まで縮んでしまう。"""
313 for i in range(splitter.count()):
314 splitter.setStretchFactor(i, 1 if i == index else 0)
316 def _apply_default_panel_heights(self):
317 vertical = Qt.Orientation.Vertical
318 # タイムライン: ウィンドウ高さの約22%(150〜240px)。
319 splitter, _ = self._splitter_slot(self.timeline_dock, vertical)
320 if splitter is not None: 320 ↛ 328line 320 didn't jump to line 328 because the condition on line 320 was always true
321 self._share_splitter(splitter, {
322 self.timeline_dock: lambda total: max(150, min(240, int(total * 0.22))),
323 }, filler=self.central_dock)
324 self._stretch_only(
325 splitter, self._splitter_index_of(splitter, self.central_dock)
326 )
327 # 左列: ツールプロパティを上に広く、アクションは下に。
328 splitter, _ = self._splitter_slot(self.tools_dock, vertical)
329 if splitter is not None: 329 ↛ 334line 329 didn't jump to line 334 because the condition on line 329 was always true
330 self._share_splitter(splitter, {
331 self.action_panel_dock: lambda total: int(total * 0.4),
332 }, filler=self.tools_dock)
333 # 右列: カラーサークルはほぼ正方形、スライダーは中身の高さ、残りを使用色へ。
334 splitter, _ = self._splitter_slot(self.color_wheel_dock, vertical)
335 if splitter is not None: 335 ↛ exitline 335 didn't return from function '_apply_default_panel_heights' because the condition on line 335 was always true
336 wheel_width = max(1, self.color_wheel_dock.width())
337 self._share_splitter(splitter, {
338 self.color_wheel_dock: lambda total: min(int(total * 0.4), wheel_width + 40),
339 self.color_slider_dock: lambda total: min(int(total * 0.25), 150),
340 }, filler=self.palette_dock)
342 def _share_splitter(self, splitter, targets, filler):
343 """targets の各ドックを指定サイズにし、差分を filler のドックに渡す。"""
344 sizes = splitter.sizes()
345 total = sum(sizes)
346 filler_index = self._splitter_index_of(splitter, filler)
347 if total <= 0 or filler_index < 0: 347 ↛ 348line 347 didn't jump to line 348 because the condition on line 347 was never true
348 return
349 for dock, size_for in targets.items():
350 index = self._splitter_index_of(splitter, dock)
351 if index < 0 or index == filler_index or sizes[index] <= 0: 351 ↛ 352line 351 didn't jump to line 352 because the condition on line 351 was never true
352 continue
353 sizes[index] = max(1, size_for(total))
354 sizes[filler_index] = max(1, total - sum(
355 size for index, size in enumerate(sizes) if index != filler_index
356 ))
357 splitter.setSizes(sizes)
359 def _hamburger_icon(self):
360 """パネルメニューの3本線アイコン(テーマの控えめな文字色)。"""
361 return icons.icon("menu", theme.palette()["text_muted"])
363 def _add_dock_hamburger_old(self, dock, specific_builder=None):
364 """ドックのタブ左端にハンバーガーメニューボタンを設置する。
366 ボタンはドック所有とし、フロート/再ドッキングでタブが作り直され
367 ても失われないよう、その都度タブへ再挿入する。
368 """
369 button = QToolButton(dock)
370 button.setObjectName("dockHamburger")
371 button.setIcon(self._hamburger_icon())
372 button.setIconSize(QSize(12, 12))
373 button.setAutoRaise(True)
374 button.setCursor(Qt.CursorShape.PointingHandCursor)
375 button.setToolTip(tr("パネルメニュー"))
376 button.setFocusPolicy(Qt.FocusPolicy.NoFocus)
377 button.setFixedSize(18, 18)
378 button.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
379 button.setStyleSheet(
380 "QToolButton{border:none;background:transparent;padding:0;margin:0 2px;}"
381 "QToolButton::menu-indicator{image:none;width:0;}"
382 )
383 menu = QMenu(button)
384 menu.aboutToShow.connect(
385 lambda m=menu, d=dock, b=specific_builder:
386 self._rebuild_dock_menu(m, d, b)
387 )
388 button.setMenu(menu)
389 dock._hamburger_button = button
390 dock._hamburger_builder = specific_builder
391 self._attach_dock_hamburger(dock)
392 # タブが生成済みでない構築初期でも確実に挿入されるよう遅延実行する。
393 QTimer.singleShot(0, lambda d=dock: self._attach_dock_hamburger(d))
394 dock.topLevelChanged.connect(
395 lambda _floating, d=dock:
396 QTimer.singleShot(0, lambda: self._attach_dock_hamburger(d))
397 )
399 def _attach_dock_hamburger(self, dock):
400 button = getattr(dock, "_hamburger_button", None)
401 if button is None:
402 return
403 tab = dock.tabWidget()
404 if tab is None:
405 return
406 layout = tab.layout()
407 if layout is None:
408 return
409 for index in range(layout.count()):
410 if layout.itemAt(index).widget() is button:
411 return
412 button.setParent(tab)
413 layout.insertWidget(0, button)
414 button.show()
416 def _add_dock_hamburger(self, dock, specific_builder=None):
417 self._dock_menu_builders[dock] = specific_builder
418 self._attach_area_hamburger(dock)
419 QTimer.singleShot(0, lambda d=dock: self._attach_area_hamburger(d))
420 dock.topLevelChanged.connect(
421 lambda _floating, d=dock:
422 QTimer.singleShot(0, lambda: self._attach_area_hamburger(d))
423 )
425 def _attach_area_hamburger(self, dock):
426 # Reached from queued singleShot / topLevelChanged callbacks; the dock's
427 # C++ object may already be gone by the time they run.
428 try:
429 area = dock.dockAreaWidget()
430 except RuntimeError as exc:
431 log.debug("_attach_area_hamburger on deleted dock: %s", exc)
432 return
433 if area is None: 433 ↛ 434line 433 didn't jump to line 434 because the condition on line 433 was never true
434 return
435 title_bar = area.titleBar()
436 buttons = title_bar.findChildren(
437 QToolButton,
438 "dockHamburger",
439 Qt.FindChildOption.FindDirectChildrenOnly,
440 )
441 cached = getattr(area, "_hamburger_button", None)
442 button = cached if cached in buttons else (buttons[0] if buttons else None)
443 for duplicate in buttons:
444 if duplicate is button:
445 continue
446 title_bar.layout().removeWidget(duplicate)
447 duplicate.hide()
448 duplicate.setParent(None)
449 duplicate.deleteLater()
450 if button is None:
451 button = QToolButton(title_bar)
452 button.setObjectName("dockHamburger")
453 button.setIcon(self._hamburger_icon())
454 button.setIconSize(QSize(12, 12))
455 button.setAutoRaise(True)
456 button.setCursor(Qt.CursorShape.PointingHandCursor)
457 button.setToolTip(tr("パネルメニュー"))
458 button.setFocusPolicy(Qt.FocusPolicy.NoFocus)
459 button.setFixedSize(18, 18)
460 button.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
461 button.setStyleSheet(
462 "QToolButton{border:none;background:transparent;"
463 "padding:0;margin:0;}"
464 "QToolButton::menu-indicator{image:none;width:0;}"
465 )
466 menu = QMenu(button)
467 # エリアはここで捕まえず、開いた時点でボタンの親から辿る。ADSが配置を
468 # 組み直すと、捕まえておいたエリアのPythonラッパーが無効になり、
469 # メニューが空になっていた。
470 menu.aboutToShow.connect(
471 lambda m=menu, b=button:
472 self._rebuild_area_dock_menu(m, self._area_of_widget(b))
473 )
474 button.setMenu(menu)
475 title_bar.layout().insertWidget(0, button)
476 area._hamburger_button = button
477 area.currentChanged.connect(
478 lambda _index, b=button:
479 self._sync_area_hamburger(self._area_of_widget(b))
480 )
481 if title_bar.layout().indexOf(button) != 0: 481 ↛ 482line 481 didn't jump to line 482 because the condition on line 481 was never true
482 title_bar.layout().removeWidget(button)
483 title_bar.layout().insertWidget(0, button)
484 area._hamburger_button = button
485 self._sync_area_hamburger(area)
487 def _sync_all_area_hamburgers(self):
488 """Restore shared buttons after ADS has regrouped or rebuilt areas."""
489 for area in self.dock_manager.openedDockAreas():
490 if any(
491 dock in self._dock_menu_builders
492 for dock in area.openedDockWidgets()
493 ):
494 current = area.currentDockWidget()
495 source = (
496 current if current in self._dock_menu_builders
497 else next(
498 dock for dock in area.openedDockWidgets()
499 if dock in self._dock_menu_builders
500 )
501 )
502 self._attach_area_hamburger(source)
504 def _sync_area_hamburger(self, area):
505 # These run from queued signals (currentChanged / topLevelChanged); ADS
506 # may have already destroyed the underlying C++ CDockAreaWidget by the
507 # time they fire, so any attribute access raises RuntimeError. Bail out
508 # instead of crashing.
509 if area is None: 509 ↛ 510line 509 didn't jump to line 510 because the condition on line 509 was never true
510 return
511 try:
512 title_bar = area.titleBar()
513 title_bar.setFixedHeight(22)
514 title_bar.tabBar().setFixedHeight(22)
515 for dock in area.openedDockWidgets():
516 tab = dock.tabWidget()
517 tab.ensurePolished()
518 tab.setFixedHeight(20)
519 tab.setSizePolicy(
520 tab.sizePolicy().horizontalPolicy(),
521 QSizePolicy.Policy.Fixed,
522 )
523 button = getattr(area, "_hamburger_button", None)
524 if button is not None:
525 button.setVisible(
526 area.currentDockWidget() in self._dock_menu_builders
527 )
528 except RuntimeError as exc:
529 log.debug("_sync_area_hamburger on deleted area: %s", exc)
531 @staticmethod
532 def _area_of_widget(widget):
533 parent = widget.parentWidget() if widget is not None else None
534 while parent is not None and not isinstance(parent, QtAds.CDockAreaWidget):
535 parent = parent.parentWidget()
536 return parent
538 def _rebuild_area_dock_menu(self, menu, area):
539 # aboutToShow can fire after ADS deleted the area's C++ object.
540 try:
541 if area is None: 541 ↛ 542line 541 didn't jump to line 542 because the condition on line 541 was never true
542 raise RuntimeError("hamburger button is not inside a dock area")
543 dock = area.currentDockWidget()
544 except RuntimeError as exc:
545 log.debug("_rebuild_area_dock_menu on deleted area: %s", exc)
546 menu.clear()
547 return
548 if dock not in self._dock_menu_builders: 548 ↛ 549line 548 didn't jump to line 549 because the condition on line 548 was never true
549 menu.clear()
550 return
551 self._rebuild_dock_menu(
552 menu, dock, self._dock_menu_builders.get(dock)
553 )
555 def _rebuild_dock_menu(self, menu, dock, specific_builder):
556 menu.clear()
557 if specific_builder is not None:
558 specific_builder(menu)
559 menu.addSeparator()
560 self._build_default_dock_menu(menu, dock)
562 def _build_default_dock_menu(self, menu, dock):
563 float_action = menu.addAction(tr("フロート表示"))
564 float_action.setEnabled(not dock.isFloating())
565 float_action.triggered.connect(lambda _c=False, d=dock: d.setFloating())
566 close_action = menu.addAction(tr("パネルを閉じる"))
567 close_action.triggered.connect(
568 lambda _c=False, d=dock: d.closeDockWidget()
569 )
571 def _build_action_panel_menu(self, menu):
572 edit_action = menu.addAction(tr("スクリプトを編集"))
573 edit_action.triggered.connect(self.action_panel.open_script_editor)
575 def _build_color_wheel_menu(self, menu):
576 wheel = self.tools.hsv_wheel
577 hue_menu = menu.addMenu(tr("色相の形"))
578 hue_labels = {"RING": tr("リング"), "BAR": tr("バー")}
579 for mode in HSVColorWheel.HUE_MODES:
580 action = hue_menu.addAction(hue_labels[mode])
581 action.setCheckable(True)
582 action.setChecked(mode == wheel.hueMode())
583 action.triggered.connect(
584 lambda _c=False, m=mode: self.tools.set_wheel_hue_mode(m)
585 )
587 inner_menu = menu.addMenu(tr("内側の形"))
588 labels = {"HSV": tr("四角(HSV)"), "HLS": tr("三角(HLS)")}
589 for mode in HSVColorWheel.MODES:
590 action = inner_menu.addAction(labels[mode])
591 action.setCheckable(True)
592 action.setChecked(mode == wheel.mode())
593 action.triggered.connect(
594 lambda _c=False, m=mode: self.tools.set_wheel_mode(m)
595 )
597 def _build_color_slider_menu(self, menu):
598 current = self.tools.slider_mode
599 for mode in ("RGB", "HLS", "CMYK"):
600 action = menu.addAction(mode)
601 action.setCheckable(True)
602 action.setChecked(mode == current)
603 action.triggered.connect(
604 lambda _c=False, m=mode: self.tools.set_slider_mode(m)
605 )
607 def _customize_docking_hover(self):
608 """Apply richer hover feedback while keeping ADS drop geometry intact."""
609 c = theme.palette()
610 # タブは枠で囲まず、選択中だけ文字を濃くして下線を引く。
611 self.dock_manager.setStyleSheet(
612 "ads--CDockAreaWidget{border:0;background:%(window)s;}"
613 "ads--CDockAreaWidget:hover{border:0;}"
614 "ads--CDockAreaTitleBar{background:%(window)s;"
615 "border-bottom:1px solid %(border)s;min-height:24px;max-height:24px;}"
616 "ads--CDockAreaTitleBar:hover{background:%(window)s;border-bottom-color:%(border)s;}"
617 "ads--CDockWidgetTab{background:transparent;color:%(text_muted)s;"
618 "border:none;border-bottom:2px solid transparent;"
619 "padding:0 8px;min-height:22px;max-height:22px;}"
620 "ads--CDockWidgetTab:hover{color:%(text)s;"
621 "border-bottom-color:%(border)s;}"
622 "ads--CDockWidgetTab[activeTab=\"true\"]{background:transparent;"
623 "color:%(text)s;border-bottom-color:%(accent)s;}"
624 "ads--CDockWidgetTab[activeTab=\"true\"]:hover{background:transparent;"
625 "color:%(text)s;border-bottom-color:%(accent)s;}"
626 "ads--CDockWidgetTab QLabel{font-weight:500;}"
627 "ads--CDockWidgetTab[activeTab=\"true\"] QLabel{font-weight:600;}"
628 "ads--CTitleBarButton{background:transparent;border:none;"
629 "border-radius:4px;padding:2px;}"
630 "ads--CTitleBarButton:hover{background:%(hover)s;}"
631 "QToolButton#dockHamburger:hover{background:%(hover)s;border-radius:4px;}"
632 "ads--CDockSplitter::handle{background:%(border)s;}"
633 "ads--CDockSplitter::handle:hover{background:%(accent)s;}"
634 % c
635 )
636 self._apply_dock_chrome_icons()
638 # Keep the standard ADS target calculation and drop-area preview, but
639 # make its central cross glyph fully transparent.
640 for overlay in (
641 self.dock_manager.containerOverlay(),
642 self.dock_manager.dockAreaOverlay(),
643 ):
644 overlay.ensurePolished()
645 overlay.enableDropPreview(True)
646 overlay.setWindowOpacity(0.42)
647 for cross in self.dock_manager.findChildren(QtAds.CDockOverlayCross):
648 cross.setWindowOpacity(0.0)
649 self._expand_docking_hit_zones(cross)
651 def _apply_dock_chrome_icons(self):
652 """ドックの見出しボタン(≡・×など)をテーマ色の線アイコンにそろえる。"""
653 muted = theme.palette()["text_muted"]
654 close_icon = icons.icon("close", muted)
655 provider = QtAds.CDockManager.iconProvider()
656 provider.registerCustomIcon(QtAds.DockAreaCloseIcon, close_icon)
657 provider.registerCustomIcon(QtAds.TabCloseIcon, close_icon)
658 provider.registerCustomIcon(QtAds.DockAreaMenuIcon, icons.icon("chevron_down", muted))
659 for name, icon in (
660 ("dockAreaCloseButton", close_icon),
661 ("floatingCloseButton", close_icon),
662 ("tabsMenuButton", icons.icon("chevron_down", muted)),
663 ("dockHamburger", self._hamburger_icon()),
664 ):
665 for button in self.dock_manager.findChildren(QAbstractButton, name):
666 button.setIcon(icon)
667 button.setIconSize(QSize(12, 12))
669 def _expand_docking_hit_zones(self, cross):
670 """Divide the complete hovered panel into five ADS drop targets."""
671 layout = cross.layout()
672 targets = {
673 int(target.property("dockWidgetArea")): target
674 for target in cross.findChildren(QLabel, "DockWidgetAreaLabel")
675 }
676 placements = {
677 int(QtAds.TopDockWidgetArea): (0, 0, 1, 3),
678 int(QtAds.LeftDockWidgetArea): (1, 0, 1, 1),
679 int(QtAds.CenterDockWidgetArea): (1, 1, 1, 1),
680 int(QtAds.RightDockWidgetArea): (1, 2, 1, 1),
681 int(QtAds.BottomDockWidgetArea): (2, 0, 1, 3),
682 }
683 for target in targets.values():
684 layout.removeWidget(target)
685 target.setMinimumSize(0, 0)
686 target.setSizePolicy(
687 QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding
688 )
689 layout.setContentsMargins(0, 0, 0, 0)
690 for row in range(5):
691 layout.setRowStretch(row, 1 if row < 3 else 0)
692 layout.setColumnStretch(row, 1 if row < 3 else 0)
693 for area, placement in placements.items():
694 target = targets.get(area)
695 if target is not None: 695 ↛ 693line 695 didn't jump to line 693 because the condition on line 695 was always true
696 layout.addWidget(target, *placement)
698 def _setup_split_drop_overlay(self):
699 """Create the insertion marker used for drops between dock areas."""
700 self._manual_drop_preview = QWidget(None)
701 self._manual_drop_preview.setWindowFlags(
702 Qt.WindowType.Tool
703 | Qt.WindowType.FramelessWindowHint
704 | Qt.WindowType.WindowStaysOnTopHint
705 )
706 self._manual_drop_preview.setAttribute(
707 Qt.WidgetAttribute.WA_TransparentForMouseEvents
708 )
709 self._manual_drop_preview.setAttribute(
710 Qt.WidgetAttribute.WA_TranslucentBackground
711 )
712 self._manual_drop_preview.setAttribute(
713 Qt.WidgetAttribute.WA_ShowWithoutActivating
714 )
715 self._manual_drop_preview.setStyleSheet(
716 "background:rgba(82,145,190,72);"
717 "border:2px solid rgba(105,180,230,210);"
718 "border-radius:4px;"
719 )
720 self._manual_drop_animation = QPropertyAnimation(
721 self._manual_drop_preview, b"windowOpacity", self
722 )
723 self._manual_drop_animation.setDuration(160)
724 self._manual_drop_animation.setStartValue(0.45)
725 self._manual_drop_animation.setEndValue(1.0)
726 self._manual_drop_animation.setEasingCurve(
727 QEasingCurve.Type.OutCubic
728 )
729 self._manual_drop_last_candidate = None
730 self._manual_center_effect_target = None
731 self._manual_center_effect = None
732 self._manual_center_animation = None
733 # One stable, unclipped overlay for every dock area. Parenting this to
734 # an individual area clips bottom/top previews when the eventual dock
735 # branch is wider than that area.
736 self._manual_center_overlay = QWidget(self.dock_manager)
737 self._manual_center_overlay.setAttribute(
738 Qt.WidgetAttribute.WA_TransparentForMouseEvents
739 )
740 self._manual_center_overlay.setStyleSheet(
741 "background:rgba(112,92,210,125);"
742 "border:2px solid rgba(145,125,245,240);"
743 "border-radius:4px;"
744 )
745 self._manual_center_overlay.hide()
746 self._manual_tab_drag_timer = QTimer(self)
747 self._manual_tab_drag_timer.setInterval(16)
748 self._manual_tab_drag_timer.timeout.connect(
749 self._poll_manual_tab_drag
750 )
751 self._split_drop_overlay = QWidget(None)
752 self._split_drop_overlay.setWindowFlags(
753 Qt.WindowType.Tool
754 | Qt.WindowType.FramelessWindowHint
755 | Qt.WindowType.WindowStaysOnTopHint
756 )
757 self._split_drop_overlay.setAttribute(
758 Qt.WidgetAttribute.WA_TransparentForMouseEvents
759 )
760 self._split_drop_overlay.setStyleSheet(
761 "background:#657680;border-radius:2px;"
762 )
763 self._split_drop_skeletons = []
764 for _index in range(2):
765 skeleton = QWidget(None)
766 skeleton.setWindowFlags(
767 Qt.WindowType.Tool
768 | Qt.WindowType.FramelessWindowHint
769 | Qt.WindowType.WindowStaysOnTopHint
770 )
771 skeleton.setAttribute(
772 Qt.WidgetAttribute.WA_TransparentForMouseEvents
773 )
774 skeleton.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
775 skeleton.setStyleSheet(
776 "background:rgba(90,110,122,38);"
777 "border:2px solid rgba(90,110,122,150);"
778 "border-radius:3px;"
779 )
780 self._split_drop_skeletons.append(skeleton)
781 self._split_drop_timer = QTimer(self)
782 self._split_drop_timer.setInterval(33)
783 self._split_drop_timer.timeout.connect(self._update_split_drop_target)
785 def _start_split_drop_monitor(self):
786 if not self._split_drop_timer.isActive():
787 self._split_drop_timer.start()
789 def _active_dragged_dock(self):
790 for floating in self.dock_manager.floatingWidgets(): 790 ↛ 791line 790 didn't jump to line 791 because the loop on line 790 never started
791 if floating.isDraggingActive():
792 return floating.topLevelDockWidget()
793 if self._split_drop_dragged_dock is not None: 793 ↛ 795line 793 didn't jump to line 795 because the condition on line 793 was always true
794 return self._split_drop_dragged_dock
795 return None
797 def _update_split_drop_target(self):
798 dragged = self._active_dragged_dock()
799 if dragged is None: 799 ↛ 800line 799 didn't jump to line 800 because the condition on line 799 was never true
800 if not self.dock_manager.floatingWidgets():
801 self._split_drop_timer.stop()
802 self._hide_split_drop_feedback()
803 # Native window dragging does not always deliver the release to
804 # QApplication. Commit on the active -> released transition too.
805 if (
806 self._split_drop_candidate is not None
807 and QApplication.mouseButtons() == Qt.MouseButton.NoButton
808 ):
809 QTimer.singleShot(0, self._commit_split_drop)
810 return
811 cursor = QCursor.pos()
812 areas = [
813 area for area in self.dock_manager.findChildren(QtAds.CDockAreaWidget)
814 if area.isVisible() and area.openDockWidgetsCount() > 0
815 ]
816 boundaries = []
817 best = None
818 for first in areas:
819 first_rect = QRectF(
820 first.mapToGlobal(first.rect().topLeft()), first.size()
821 ).toRect()
822 for second in areas:
823 if first is second:
824 continue
825 second_rect = QRectF(
826 second.mapToGlobal(second.rect().topLeft()), second.size()
827 ).toRect()
828 vertical_gap = abs(first_rect.right() - second_rect.left())
829 overlap_top = max(first_rect.top(), second_rect.top())
830 overlap_bottom = min(first_rect.bottom(), second_rect.bottom())
831 if overlap_bottom > overlap_top and vertical_gap <= 8:
832 boundary = (first_rect.right() + second_rect.left()) // 2
833 boundaries.append(("v", boundary, overlap_top,
834 overlap_bottom, first, first_rect,
835 second, second_rect))
836 horizontal_gap = abs(first_rect.bottom() - second_rect.top())
837 overlap_left = max(first_rect.left(), second_rect.left())
838 overlap_right = min(first_rect.right(), second_rect.right())
839 if overlap_right > overlap_left and horizontal_gap <= 8:
840 boundary = (first_rect.bottom() + second_rect.top()) // 2
841 boundaries.append(("h", boundary, overlap_left,
842 overlap_right, first, first_rect,
843 second, second_rect))
844 for (
845 orientation, boundary, _start, _end, first, first_rect,
846 _second, _second_rect,
847 ) in boundaries:
848 group = [
849 entry for entry in boundaries
850 if entry[0] == orientation and abs(entry[1] - boundary) <= 4
851 ]
852 group_start = min(entry[2] for entry in group)
853 group_end = max(entry[3] for entry in group)
854 tolerance = 26 if len(group) > 1 else 14
855 distance = abs(
856 cursor.x() - boundary if orientation == "v"
857 else cursor.y() - boundary
858 )
859 along = cursor.y() if orientation == "v" else cursor.x()
860 if not (distance <= tolerance and group_start <= along <= group_end):
861 continue
862 containing = [entry for entry in group if entry[2] <= along <= entry[3]]
863 chosen = min(
864 containing or group,
865 key=lambda entry: 0 if entry[2] <= along <= entry[3]
866 else min(abs(along - entry[2]), abs(along - entry[3])),
867 )
868 first, first_rect = chosen[4], chosen[5]
869 second, second_rect = chosen[6], chosen[7]
870 dragged_in_first = dragged in first.dockWidgets()
871 if orientation == "v":
872 marker = QRect(boundary - 2, group_start, 4,
873 group_end - group_start + 1)
874 if dragged_in_first:
875 side = QtAds.LeftDockWidgetArea
876 target_area, target_rect = second, second_rect
877 else:
878 side = QtAds.RightDockWidgetArea
879 target_area, target_rect = first, first_rect
880 else:
881 marker = QRect(group_start, boundary - 2,
882 group_end - group_start + 1, 4)
883 if dragged_in_first:
884 side = QtAds.TopDockWidgetArea
885 target_area, target_rect = second, second_rect
886 else:
887 side = QtAds.BottomDockWidgetArea
888 target_area, target_rect = first, first_rect
889 score = (distance, -len(group))
890 if best is None or score < best[0]:
891 best = (score, side, target_area, marker, target_rect)
892 if best is None: 892 ↛ 893line 892 didn't jump to line 893 because the condition on line 892 was never true
893 self._split_drop_candidate = None
894 self._split_drop_dragged_dock = dragged
895 self._hide_split_drop_feedback()
896 return
897 _, side, target_area, marker, target_rect = best
898 self._split_drop_candidate = (side, target_area)
899 self._split_drop_dragged_dock = dragged
900 if self._split_drop_source_area is None: 900 ↛ 902line 900 didn't jump to line 902 because the condition on line 900 was always true
901 self._split_drop_source_area = dragged.dockAreaWidget()
902 self._split_drop_overlay.setGeometry(marker)
903 self._split_drop_overlay.show()
904 self._split_drop_overlay.raise_()
905 self._show_split_skeleton(side, target_rect)
907 def _show_split_skeleton(self, side, target_rect):
908 first = QRect(target_rect)
909 inserted = QRect(target_rect)
910 if side == QtAds.RightDockWidgetArea:
911 inserted_width = max(48, int(target_rect.width() * 0.35))
912 inserted.setLeft(target_rect.right() - inserted_width + 1)
913 first.setRight(inserted.left() - 3)
914 else:
915 inserted_height = max(48, int(target_rect.height() * 0.35))
916 inserted.setTop(target_rect.bottom() - inserted_height + 1)
917 first.setBottom(inserted.top() - 3)
918 for widget, geometry in zip(
919 self._split_drop_skeletons, (first, inserted)
920 ):
921 widget.setGeometry(geometry)
922 widget.show()
923 widget.raise_()
925 def _hide_split_drop_feedback(self):
926 self._split_drop_overlay.hide()
927 for skeleton in self._split_drop_skeletons:
928 skeleton.hide()
930 def _update_manual_tab_drop(self, global_pos):
931 """Show the native ADS area overlay during a frameless tab drag."""
932 overlay = self.dock_manager.dockAreaOverlay()
933 target = None
934 target_rect = None
935 for area in self.dock_manager.openedDockAreas(): 935 ↛ 943line 935 didn't jump to line 943 because the loop on line 935 didn't complete
936 if area.window() is not self.window() or not area.isVisible(): 936 ↛ 937line 936 didn't jump to line 937 because the condition on line 936 was never true
937 continue
938 rect = QRect(area.mapToGlobal(QPoint()), area.size())
939 if rect.contains(global_pos):
940 target = area
941 target_rect = rect
942 break
943 if target is None: 943 ↛ 944line 943 didn't jump to line 944 because the condition on line 943 was never true
944 overlay.hideOverlay()
945 self._clear_manual_center_effect()
946 self._manual_drop_animation.stop()
947 self._manual_drop_preview.hide()
948 self._manual_drop_last_candidate = None
949 self._manual_tab_drop_target = None
950 self._manual_tab_drop_extent = None
951 return
952 assert target_rect is not None
953 title_bar = target.titleBar()
954 title_rect = QRect(
955 title_bar.mapToGlobal(QPoint()), title_bar.size()
956 )
957 if title_rect.contains(global_pos): 957 ↛ 958line 957 didn't jump to line 958 because the condition on line 957 was never true
958 side = QtAds.CenterDockWidgetArea
959 else:
960 x_ratio = (global_pos.x() - target_rect.left()) / max(
961 1, target_rect.width()
962 )
963 y_ratio = (global_pos.y() - target_rect.top()) / max(
964 1, target_rect.height()
965 )
966 edge = min(x_ratio, 1 - x_ratio, y_ratio, 1 - y_ratio)
967 if edge >= 0.25:
968 side = QtAds.CenterDockWidgetArea
969 elif edge == x_ratio: 969 ↛ 970line 969 didn't jump to line 970 because the condition on line 969 was never true
970 side = QtAds.LeftDockWidgetArea
971 elif edge == 1 - x_ratio: 971 ↛ 972line 971 didn't jump to line 972 because the condition on line 971 was never true
972 side = QtAds.RightDockWidgetArea
973 elif edge == y_ratio: 973 ↛ 974line 973 didn't jump to line 974 because the condition on line 973 was never true
974 side = QtAds.TopDockWidgetArea
975 else:
976 side = QtAds.BottomDockWidgetArea
977 # The ADS overlay has its own palette and briefly paints underneath our
978 # preview (most noticeably for the bottom target). The custom preview
979 # is the sole feedback for this manual drag path.
980 overlay.hideOverlay()
981 preview_rect = QRect(target_rect)
982 extent = None
983 dragged = getattr(self, "_floating_tab_drag_dock", None)
984 minimum = QSize(1, 1)
985 if dragged is not None: 985 ↛ 1005line 985 didn't jump to line 1005 because the condition on line 985 was always true
986 try:
987 dragged_area = dragged.dockAreaWidget()
988 hints = (
989 dragged_area.minimumSizeHint(),
990 dragged_area.sizeHint(),
991 dragged_area.titleBar().minimumSizeHint(),
992 dragged_area.titleBar().sizeHint(),
993 dragged.minimumSizeHint(),
994 dragged.sizeHint(),
995 dragged.tabWidget().minimumSizeHint(),
996 dragged.tabWidget().sizeHint(),
997 )
998 minimum = QSize(
999 max(1, dragged.window().width(),
1000 *(hint.width() for hint in hints)),
1001 max(1, *(hint.height() for hint in hints)),
1002 )
1003 except RuntimeError:
1004 pass
1005 target_minimum_width = 1
1006 try:
1007 target_dock = target.currentDockWidget()
1008 target_hints = (
1009 target.minimumSizeHint(),
1010 target.sizeHint(),
1011 target.titleBar().minimumSizeHint(),
1012 target.titleBar().sizeHint(),
1013 target_dock.minimumSizeHint(),
1014 target_dock.sizeHint(),
1015 target_dock.tabWidget().minimumSizeHint(),
1016 target_dock.tabWidget().sizeHint(),
1017 target_dock.widget().minimumSizeHint(),
1018 target_dock.widget().sizeHint(),
1019 )
1020 target_minimum_width = max(
1021 1, target_dock.width(), target_dock.widget().width(),
1022 *(hint.width() for hint in target_hints)
1023 )
1024 except (AttributeError, RuntimeError):
1025 pass
1026 target_branch_width = target_rect.width()
1027 try:
1028 branch = target
1029 parent = branch.parentWidget()
1030 while parent is not None: 1030 ↛ 1041line 1030 didn't jump to line 1041 because the condition on line 1030 was always true
1031 if ( 1031 ↛ 1037line 1031 didn't jump to line 1037 because the condition on line 1031 was always true
1032 isinstance(parent, QSplitter)
1033 and parent.orientation() == Qt.Orientation.Horizontal
1034 ):
1035 target_branch_width = branch.width()
1036 break
1037 branch = parent
1038 parent = branch.parentWidget()
1039 except RuntimeError:
1040 pass
1041 if side == QtAds.LeftDockWidgetArea: 1041 ↛ 1042line 1041 didn't jump to line 1042 because the condition on line 1041 was never true
1042 preview_rect.setWidth(max(minimum.width(), target_rect.width() // 2))
1043 extent = (Qt.Orientation.Horizontal, preview_rect.width())
1044 elif side == QtAds.RightDockWidgetArea: 1044 ↛ 1045line 1044 didn't jump to line 1045 because the condition on line 1044 was never true
1045 width = max(minimum.width(), target_rect.width() // 2)
1046 preview_rect.setLeft(target_rect.right() - width + 1)
1047 extent = (Qt.Orientation.Horizontal, width)
1048 elif side == QtAds.TopDockWidgetArea: 1048 ↛ 1049line 1048 didn't jump to line 1049 because the condition on line 1048 was never true
1049 preview_rect.setHeight(max(minimum.height(), target_rect.height() // 2))
1050 preview_rect.setWidth(max(
1051 minimum.width(), target_minimum_width,
1052 target_rect.width(), target_branch_width,
1053 ))
1054 extent = (Qt.Orientation.Vertical, preview_rect.height())
1055 elif side == QtAds.BottomDockWidgetArea:
1056 height = max(minimum.height(), target_rect.height() // 2)
1057 preview_rect.setTop(target_rect.bottom() - height + 1)
1058 preview_rect.setWidth(max(
1059 minimum.width(), target_minimum_width,
1060 target_rect.width(), target_branch_width,
1061 ))
1062 extent = (Qt.Orientation.Vertical, height)
1063 if extent is not None:
1064 extent = (*extent, QSize(preview_rect.size()))
1065 candidate_key = (int(side), id(target))
1066 local_preview = QRect(
1067 self.dock_manager.mapFromGlobal(preview_rect.topLeft()),
1068 preview_rect.size(),
1069 )
1070 self._show_manual_center_effect(
1071 target, local_preview, candidate_key, side
1072 )
1073 self._manual_drop_preview.hide()
1074 self._manual_tab_drop_target = (side, target)
1075 self._manual_tab_drop_extent = extent
1077 def _show_manual_center_effect(self, target, geometry, candidate_key, side):
1078 if (
1079 self._manual_center_effect_target is target
1080 and self._manual_center_overlay.isVisible()
1081 ):
1082 # Switching between center and an edge on the same area must not
1083 # hide/recreate the overlay. Recreating resets opacity to zero and
1084 # produces a one-frame flash, especially at the bottom boundary.
1085 self._manual_center_overlay.setGeometry(geometry)
1086 self._manual_center_overlay.raise_()
1087 self._manual_drop_last_candidate = candidate_key
1088 return
1089 self._clear_manual_center_effect()
1090 overlay = self._manual_center_overlay
1091 overlay.setGeometry(geometry)
1092 overlay.setStyleSheet(
1093 "background:rgba(112,92,210,125);"
1094 "border:2px solid rgba(145,125,245,240);"
1095 "border-radius:4px;"
1096 )
1097 effect = QGraphicsOpacityEffect(overlay)
1098 effect.setOpacity(0.0)
1099 overlay.setGraphicsEffect(effect)
1100 overlay.show()
1101 overlay.raise_()
1102 animation = QPropertyAnimation(effect, b"opacity", self)
1103 animation.setDuration(180)
1104 animation.setStartValue(0.0)
1105 animation.setEndValue(1.0)
1106 animation.setEasingCurve(QEasingCurve.Type.OutCubic)
1107 self._manual_center_effect_target = target
1108 self._manual_center_effect = effect
1109 self._manual_center_animation = animation
1110 self._manual_drop_last_candidate = candidate_key
1111 animation.start()
1113 def _clear_manual_center_effect(self):
1114 animation = getattr(self, "_manual_center_animation", None)
1115 if animation is not None:
1116 animation.stop()
1117 overlay = getattr(self, "_manual_center_overlay", None)
1118 if overlay is not None: 1118 ↛ 1121line 1118 didn't jump to line 1121 because the condition on line 1118 was always true
1119 overlay.hide()
1120 overlay.setGraphicsEffect(None)
1121 self._manual_center_effect_target = None
1122 self._manual_center_effect = None
1123 self._manual_center_animation = None
1125 def _finish_manual_tab_drop(self, global_pos):
1126 overlay = self.dock_manager.dockAreaOverlay()
1127 candidate = getattr(self, "_manual_tab_drop_target", None)
1128 extent = getattr(self, "_manual_tab_drop_extent", None)
1129 dock = getattr(self, "_floating_tab_drag_dock", None)
1130 side = candidate[0] if candidate is not None else None
1131 freeze_layout = (
1132 candidate is not None
1133 and dock is not None
1134 and side != QtAds.CenterDockWidgetArea
1135 )
1136 if freeze_layout: 1136 ↛ 1139line 1136 didn't jump to line 1139 because the condition on line 1136 was never true
1137 # Preserve the last preview frame until the final splitter geometry
1138 # is ready; otherwise clearing it exposes one intermediate layout.
1139 self.setUpdatesEnabled(False)
1140 overlay.hideOverlay()
1141 self._clear_manual_center_effect()
1142 self._manual_drop_animation.stop()
1143 self._manual_drop_preview.hide()
1144 self._manual_drop_last_candidate = None
1145 self._manual_tab_drop_target = None
1146 self._manual_tab_drop_extent = None
1147 if candidate is None or dock is None: 1147 ↛ 1148line 1147 didn't jump to line 1148 because the condition on line 1147 was never true
1148 return
1149 side, target = candidate
1150 if side == QtAds.CenterDockWidgetArea: 1150 ↛ 1153line 1150 didn't jump to line 1153 because the condition on line 1150 was always true
1151 self.dock_manager.addDockWidgetTabToArea(dock, target)
1152 else:
1153 try:
1154 self.dock_manager.addDockWidget(side, dock, target)
1155 QApplication.processEvents(
1156 QEventLoop.ProcessEventsFlag.ExcludeUserInputEvents
1157 )
1158 self._apply_manual_drop_extent(dock, target, extent)
1159 except Exception:
1160 self.setUpdatesEnabled(True)
1161 self.update()
1162 raise
1163 # ADS queues more than one splitter layout pass after insertion.
1164 # Keep painting suspended until those passes have settled, and
1165 # re-apply the preview extent before exposing the final layout.
1166 for delay in (0, 20, 80, 180):
1167 QTimer.singleShot(
1168 delay,
1169 lambda d=dock, t=target, e=extent:
1170 self._apply_manual_drop_extent(d, t, e),
1171 )
1172 QTimer.singleShot(
1173 200,
1174 lambda d=dock, t=target, e=extent:
1175 self._finish_manual_drop_layout(d, t, e),
1176 )
1177 for delay in (0, 20, 80, 180):
1178 QTimer.singleShot(
1179 delay,
1180 lambda d=dock: self._sync_floating_title(d, False),
1181 )
1182 QTimer.singleShot(0, self._sync_all_area_hamburgers)
1184 def _start_manual_tab_drag_polling(self):
1185 if not self._manual_tab_drag_timer.isActive(): 1185 ↛ exitline 1185 didn't return from function '_start_manual_tab_drag_polling' because the condition on line 1185 was always true
1186 self._manual_tab_drag_timer.start()
1188 def _poll_manual_tab_drag(self):
1189 floating = getattr(self, "_floating_move_window", None)
1190 dock = getattr(self, "_floating_tab_drag_dock", None)
1191 if floating is None or dock is None:
1192 self._manual_tab_drag_timer.stop()
1193 return
1194 global_pos = QCursor.pos()
1195 if self._physical_left_button_pressed():
1196 offset = getattr(self, "_floating_move_offset", QPoint())
1197 try:
1198 floating.move(global_pos - offset)
1199 except RuntimeError:
1200 self._manual_tab_drag_timer.stop()
1201 return
1202 self._update_manual_tab_drop(global_pos)
1203 return
1204 self._manual_tab_drag_timer.stop()
1205 self._finish_manual_tab_drop(global_pos)
1206 self._floating_drag_grabber = None
1207 self._floating_move_window = None
1208 self._floating_tab_drag_dock = None
1210 @staticmethod
1211 def _physical_left_button_pressed():
1212 if sys.platform == "win32":
1213 try:
1214 return bool(ctypes.windll.user32.GetAsyncKeyState(0x01) & 0x8000)
1215 except (AttributeError, OSError):
1216 pass
1217 return bool(QApplication.mouseButtons() & Qt.MouseButton.LeftButton)
1219 @staticmethod
1220 def _apply_manual_drop_extent(dock, target_area, extent):
1221 """Match the new splitter branch to the size shown in the preview."""
1222 if extent is None:
1223 return
1224 try:
1225 new_area = dock.dockAreaWidget()
1226 splitter = new_area.parentWidget()
1227 if not isinstance(splitter, QSplitter):
1228 return
1229 orientation, requested = extent[:2]
1230 if splitter.orientation() != orientation:
1231 return
1233 def direct_branch(widget):
1234 branch = widget
1235 while branch is not None and branch.parentWidget() is not splitter:
1236 branch = branch.parentWidget()
1237 return branch
1239 new_branch = direct_branch(new_area)
1240 target_branch = direct_branch(target_area)
1241 if new_branch is None or target_branch is None:
1242 return
1243 new_index = splitter.indexOf(new_branch)
1244 target_index = splitter.indexOf(target_branch)
1245 if new_index < 0 or target_index < 0 or new_index == target_index:
1246 return
1247 sizes = splitter.sizes()
1248 combined = sizes[new_index] + sizes[target_index]
1249 desired = max(1, min(int(requested), combined - 1))
1250 sizes[new_index] = desired
1251 sizes[target_index] = combined - desired
1252 splitter.setSizes(sizes)
1253 if len(extent) >= 3:
1254 preview_size = extent[2]
1255 perpendicular = (
1256 Qt.Orientation.Vertical
1257 if orientation == Qt.Orientation.Horizontal
1258 else Qt.Orientation.Horizontal
1259 )
1260 perpendicular_extent = (
1261 preview_size.height()
1262 if perpendicular == Qt.Orientation.Vertical
1263 else preview_size.width()
1264 )
1265 DockingMixin._resize_ancestor_splitter_branch(
1266 new_area, perpendicular, perpendicular_extent
1267 )
1268 except RuntimeError:
1269 return
1271 @staticmethod
1272 def _resize_ancestor_splitter_branch(widget, orientation, requested):
1273 """Resize the outer branch that controls a dock's other dimension."""
1274 branch = widget
1275 parent = branch.parentWidget()
1276 while parent is not None:
1277 if isinstance(parent, QSplitter) and parent.orientation() == orientation:
1278 index = parent.indexOf(branch)
1279 sizes = parent.sizes()
1280 if index < 0 or len(sizes) < 2:
1281 return
1282 desired = max(1, int(requested))
1283 delta = sizes[index] - desired
1284 recipients = [i for i in range(len(sizes)) if i != index]
1285 recipient = max(recipients, key=lambda i: sizes[i])
1286 sizes[index] = desired
1287 sizes[recipient] = max(1, sizes[recipient] + delta)
1288 parent.setSizes(sizes)
1289 return
1290 branch = parent
1291 parent = branch.parentWidget()
1293 def _finish_manual_drop_layout(self, dock, target_area, extent):
1294 """Expose a manual drop only after ADS' queued splitter passes."""
1295 try:
1296 self._apply_manual_drop_extent(dock, target_area, extent)
1297 finally:
1298 self.setUpdatesEnabled(True)
1299 self.update()
1301 def _commit_split_drop(self):
1302 """Finish custom boundary feedback without performing a second drop.
1304 QtAds owns the actual mouse-release drop. Calling addDockWidget here as
1305 well races its internal floating-container cleanup and can orphan the
1306 dragged tab, especially with native Windows title-bar dragging.
1307 """
1308 self._split_drop_candidate = None
1309 self._split_drop_dragged_dock = None
1310 self._split_drop_source_area = None
1311 self._split_drop_press_pos = None
1312 self._hide_split_drop_feedback()
1313 QTimer.singleShot(0, self._sync_all_area_hamburgers)
1315 def _snap_tool_selector_width(self, content_width):
1316 """Queue a snap after resizing settles to avoid fighting the drag."""
1317 if (
1318 QApplication.mouseButtons() == Qt.MouseButton.NoButton
1319 and not self._tool_selector_resize_drag_active
1320 ):
1321 # ADS also resizes dock contents while docking, tabbing, restoring
1322 # layouts, and resizing the main window. Those layout-driven
1323 # changes must not make the tool panel resize itself again.
1324 return
1325 self._pending_tool_selector_snap = int(content_width)
1326 self._tool_selector_snap_timer.start()
1328 def _sync_floating_title(self, dock, floating):
1329 """Keep panel menus reachable while avoiding empty floating chrome."""
1330 def update():
1331 try:
1332 area = dock.dockAreaWidget()
1333 floating_now = dock.isFloating()
1334 except RuntimeError:
1335 return
1336 if area is None: 1336 ↛ 1337line 1336 didn't jump to line 1337 because the condition on line 1336 was never true
1337 return
1338 # The floating container is frameless, so this is its only drag bar.
1339 title_bar = area.titleBar()
1340 title_bar.setVisible(True)
1341 if floating_now:
1342 title_bar._paintmask_move_container = dock.window()
1343 elif hasattr(title_bar, "_paintmask_move_container"): 1343 ↛ 1344line 1343 didn't jump to line 1344 because the condition on line 1343 was never true
1344 del title_bar._paintmask_move_container
1345 close_button = title_bar.findChild(
1346 QToolButton,
1347 "floatingCloseButton",
1348 Qt.FindChildOption.FindDirectChildrenOnly,
1349 )
1350 if close_button is None:
1351 close_button = QToolButton(title_bar)
1352 close_button.setObjectName("floatingCloseButton")
1353 close_button.setIcon(
1354 icons.icon("close", theme.palette()["text_muted"])
1355 )
1356 close_button.setIconSize(QSize(12, 12))
1357 close_button.setToolTip(tr("パネルを閉じる"))
1358 close_button.setAutoRaise(True)
1359 close_button.setFocusPolicy(Qt.FocusPolicy.NoFocus)
1360 close_button.setFixedSize(18, 18)
1361 close_button.setStyleSheet(
1362 "QToolButton{border:none;background:transparent;"
1363 "padding:0;margin:0;border-radius:4px;}"
1364 "QToolButton:hover{background:rgba(220,60,60,120);}"
1365 )
1366 close_button.clicked.connect(
1367 lambda _checked=False, a=area:
1368 self._close_current_area_dock(a)
1369 )
1370 title_bar.layout().addWidget(close_button)
1371 close_button.setVisible(True)
1372 if dock in self._dock_menu_builders: 1372 ↛ exitline 1372 didn't return from function 'update' because the condition on line 1372 was always true
1373 self._attach_area_hamburger(dock)
1374 # ADS can rebuild the area after topLevelChanged, especially on the
1375 # second and later float cycles. Re-apply chrome after both phases.
1376 update()
1377 for delay in (10, 50):
1378 QTimer.singleShot(delay, update)
1380 @staticmethod
1381 def _close_current_area_dock(area):
1382 try:
1383 dock = area.currentDockWidget()
1384 if dock is not None:
1385 dock.closeDockWidget()
1386 except RuntimeError:
1387 return
1389 def _configure_floating_window(self, floating):
1390 """Use the ADS panel bar as the sole chrome for floating groups."""
1391 floating.setWindowIcon(QIcon())
1392 floating.setWindowFlags(
1393 Qt.WindowType.Tool
1394 | Qt.WindowType.FramelessWindowHint
1395 )
1396 grip = QSizeGrip(floating)
1397 grip.setObjectName("floatingResizeGrip")
1398 grip.setFixedSize(16, 16)
1399 grip.setCursor(Qt.CursorShape.SizeFDiagCursor)
1400 setattr(
1401 grip, _RESIZE_EDGES_ATTR, Qt.Edge.RightEdge | Qt.Edge.BottomEdge
1402 )
1403 grip.raise_()
1404 grip.show()
1405 handle_specs = {
1406 "top": (Qt.Edge.TopEdge, Qt.CursorShape.SizeVerCursor),
1407 "bottom": (Qt.Edge.BottomEdge, Qt.CursorShape.SizeVerCursor),
1408 "left": (Qt.Edge.LeftEdge, Qt.CursorShape.SizeHorCursor),
1409 "right": (Qt.Edge.RightEdge, Qt.CursorShape.SizeHorCursor),
1410 "top_left": (
1411 Qt.Edge.TopEdge | Qt.Edge.LeftEdge,
1412 Qt.CursorShape.SizeFDiagCursor,
1413 ),
1414 "top_right": (
1415 Qt.Edge.TopEdge | Qt.Edge.RightEdge,
1416 Qt.CursorShape.SizeBDiagCursor,
1417 ),
1418 "bottom_left": (
1419 Qt.Edge.BottomEdge | Qt.Edge.LeftEdge,
1420 Qt.CursorShape.SizeBDiagCursor,
1421 ),
1422 }
1423 resize_handles = {}
1424 for name, (edges, cursor) in handle_specs.items():
1425 handle = QWidget(floating)
1426 handle.setObjectName(
1427 "floatingResize" + "".join(
1428 part.title() for part in name.split("_")
1429 )
1430 )
1431 handle.setCursor(cursor)
1432 handle.setStyleSheet("background:transparent;")
1433 setattr(handle, _RESIZE_EDGES_ATTR, edges)
1434 handle.show()
1435 resize_handles[name] = handle
1436 floating._paintmask_resize_grip = grip
1437 floating._paintmask_resize_handles = resize_handles
1438 tracker = _FloatingGripTracker(floating, grip, resize_handles)
1439 floating._paintmask_grip_tracker = tracker
1440 floating.installEventFilter(tracker)
1441 grip.installEventFilter(tracker)
1442 for handle in resize_handles.values():
1443 handle.installEventFilter(tracker)
1444 self._position_floating_resize_grip(floating)
1445 QTimer.singleShot(
1446 0, lambda f=floating: self._position_floating_resize_grip(f)
1447 )
1448 def sync_floating_docks():
1449 try:
1450 docks = tuple(floating.dockWidgets())
1451 except RuntimeError:
1452 return
1453 for current in docks:
1454 self._sync_floating_title(current, True)
1455 for delay in (0, 10, 50):
1456 QTimer.singleShot(delay, sync_floating_docks)
1458 @staticmethod
1459 def _position_floating_resize_grip(floating):
1460 try:
1461 grip = getattr(floating, "_paintmask_resize_grip", None)
1462 except RuntimeError:
1463 return
1464 if grip is None: 1464 ↛ 1465line 1464 didn't jump to line 1465 because the condition on line 1464 was never true
1465 return
1466 try:
1467 tracker = getattr(floating, "_paintmask_grip_tracker", None)
1468 if tracker is not None: 1468 ↛ 1471line 1468 didn't jump to line 1471 because the condition on line 1468 was always true
1469 tracker.position_handles()
1470 else:
1471 grip.move(
1472 max(0, floating.width() - grip.width()),
1473 max(0, floating.height() - grip.height()),
1474 )
1475 grip.raise_()
1476 except RuntimeError:
1477 return
1479 def _apply_tool_selector_snap(self):
1480 """Apply the last requested column width once per completed drag."""
1481 if self._pending_tool_selector_snap is None: 1481 ↛ 1482line 1481 didn't jump to line 1482 because the condition on line 1481 was never true
1482 return
1483 if QApplication.mouseButtons() != Qt.MouseButton.NoButton:
1484 # A pause while dragging is not the end of the resize. Keep the
1485 # request pending and retry only as a fallback for platforms where
1486 # the native resize release is not delivered to the app.
1487 self._tool_selector_snap_timer.start()
1488 return
1489 # Snap to the number of columns QListView actually rendered, rather
1490 # than guessing from which mathematical width is nearest.
1491 columns = self.tool_selector.displayed_column_count()
1492 self.tool_selector._column_count = columns
1493 self.tool_selector._resize_swatch_to_columns(columns)
1494 content_width = self.tool_selector.width_for_columns(columns)
1495 self._pending_tool_selector_snap = None
1496 dock = self.tool_selector_dock
1497 frame_width = max(0, dock.width() - self.tool_selector.width())
1498 target_width = int(content_width) + frame_width
1499 if dock.isFloating(): 1499 ↛ 1500line 1499 didn't jump to line 1500 because the condition on line 1499 was never true
1500 floating_window = dock.window()
1501 floating_window.resize(target_width, floating_window.height())
1502 else:
1503 self._resize_tool_selector_area(content_width)