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

1"""Dock hamburger menus, split-drop overlays, and floating-window chrome. 

2 

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 

32 

33log = get_logger(__name__) 

34 

35# 枠なしフロートウィンドウのリサイズ用ハンドルに、どの辺を動かすかを持たせる属性名。 

36_RESIZE_EDGES_ATTR = "_paintmask_resize_edges" 

37 

38 

39class _FloatingGripTracker(QObject): 

40 """Anchor and drive every resize edge of a frameless floating container.""" 

41 

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 

53 

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

86 

87 @staticmethod 

88 def _clamp_dimension(value, minimum, maximum): 

89 maximum = max(minimum, maximum) 

90 return max(minimum, min(maximum, value)) 

91 

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

100 

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 ) 

111 

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 ) 

122 

123 self.floating.setGeometry(x, y, width, height) 

124 

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 

179 

180 

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 

190 

191 def _fit_window_to_screen(self, width, height): 

192 """既定サイズを画面の作業領域に収めて中央に置く。 

193 

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 ) 

211 

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

226 

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

237 

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 

247 

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 

255 

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) 

264 

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) 

306 

307 @staticmethod 

308 def _stretch_only(splitter, index): 

309 """ウィンドウの伸縮をその列だけが受け持ち、パネルの幅は保つ。 

310 

311 macOSは表示直後に画面に合わせてウィンドウを縮めるので、比例配分の 

312 ままだと配ったばかりのパネル幅まで縮んでしまう。""" 

313 for i in range(splitter.count()): 

314 splitter.setStretchFactor(i, 1 if i == index else 0) 

315 

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) 

341 

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) 

358 

359 def _hamburger_icon(self): 

360 """パネルメニューの3本線アイコン(テーマの控えめな文字色)。""" 

361 return icons.icon("menu", theme.palette()["text_muted"]) 

362 

363 def _add_dock_hamburger_old(self, dock, specific_builder=None): 

364 """ドックのタブ左端にハンバーガーメニューボタンを設置する。 

365 

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 ) 

398 

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

415 

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 ) 

424 

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) 

486 

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) 

503 

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) 

530 

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 

537 

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 ) 

554 

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) 

561 

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 ) 

570 

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) 

574 

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 ) 

586 

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 ) 

596 

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 ) 

606 

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

637 

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) 

650 

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

668 

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) 

697 

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) 

784 

785 def _start_split_drop_monitor(self): 

786 if not self._split_drop_timer.isActive(): 

787 self._split_drop_timer.start() 

788 

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 

796 

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) 

906 

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

924 

925 def _hide_split_drop_feedback(self): 

926 self._split_drop_overlay.hide() 

927 for skeleton in self._split_drop_skeletons: 

928 skeleton.hide() 

929 

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 

1076 

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

1112 

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 

1124 

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) 

1183 

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

1187 

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 

1209 

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) 

1218 

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 

1232 

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 

1238 

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 

1270 

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

1292 

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

1300 

1301 def _commit_split_drop(self): 

1302 """Finish custom boundary feedback without performing a second drop. 

1303 

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) 

1314 

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

1327 

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) 

1379 

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 

1388 

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) 

1457 

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 

1478 

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)