Coverage for vibefpmgui/dock_snap.py: 96%

225 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-11-01 20:51 +0000

1"""Dragging a panel's edge past its smallest size collapses it, as in VS Code. 

2 

3Qt stops a dock separator at the panels' minimum sizes, so `SnapClosed` 

4does separator drags itself (they reach the main window, not a child 

5widget). Once the mouse is more than half a panel's minimum size past where 

6it would stop, the panel collapses to a thin `Grip`; dragging the grip (or 

7the separator beside it) back out opens it, and so does double-clicking it. 

8At the edge of a side of the window the whole side collapses; between two 

9panels, just the squeezed one, with the tabs stacked with it. 

10 

11A command showing a collapsed panel (a test run showing Tests…), View → 

12Panels and Reset Layout open it too. 

13""" 

14 

15from __future__ import annotations 

16 

17from dataclasses import dataclass 

18 

19from qtpy.QtCore import QEvent, QObject, QPoint, QRect, QSize, Qt, QTimer 

20from qtpy.QtWidgets import QDockWidget, QMainWindow, QStyle, QTabBar, QWidget 

21 

22SLACK = 3 # (pixels around a separator still counted as on it) 

23SEPARATOR_NAME = "qt_qmainwindow_extended_splitter" # (Qt's widget over a thin separator) 

24GRIP = 7 # a collapsed panel's thickness 

25REOPEN = 12 # (pixels past where it collapses that a collapsed panel opens again: not at every wobble) 

26 

27 

28class _NoTitle(QWidget): 

29 """A collapsed panel's title bar: nothing.""" 

30 

31 def sizeHint(self): 

32 return QSize(0, 0) 

33 

34 def minimumSizeHint(self): 

35 return QSize(0, 0) 

36 

37 

38class Grip(QWidget): 

39 """A collapsed panel's content: a thin strip with the resize cursor (the 

40 style draws the dots on the separator beside it). Not its title bar: 

41 with no content, Qt keeps a panel only as big as its title, in a corner.""" 

42 

43 def __init__(self, snap: SnapClosed, side: Side): 

44 super().__init__() 

45 self.snap, self.side = snap, side 

46 self.setCursor(Qt.SplitHCursor if side.horizontal else Qt.SplitVCursor) 

47 self.setToolTip("Drag to open (or double-click)") 

48 

49 def sizeHint(self): 

50 return QSize(GRIP, GRIP) 

51 

52 def minimumSizeHint(self): 

53 return QSize(GRIP, GRIP) 

54 

55 def mousePressEvent(self, event): 

56 if event.button() == Qt.LeftButton: 56 ↛ 61line 56 didn't jump to line 61 because the condition on line 56 was always true

57 # The window takes the rest of the drag: the grip goes once the panel opens. 

58 window = self.snap.window 

59 self.snap.start_drag([self.side], self.mapTo(window, event.position().toPoint())) 

60 window.grabMouse(self.cursor()) 

61 event.accept() # (not the panel's: it would float it) 

62 

63 def mouseDoubleClickEvent(self, event): 

64 self.snap.expand(self.side.docks[0]) 

65 

66 

67@dataclass(eq=False) 

68class Side: 

69 """The panels on one side of a separator: what collapses together.""" 

70 docks: list[QDockWidget] # the ones in front along the separator 

71 group: list[QDockWidget] # and every one stacked with them 

72 size: int # their extent across the separator (at the press, or before collapsing) 

73 minimum: int 

74 sign: int # +1: the mouse moving along the axis makes them bigger 

75 horizontal: bool # (the drag's axis: a vertical separator moves sideways) 

76 press_size: int = 0 # (during a drag: the size at the press) 

77 cross: list[int] | None = None # the docks' sizes along the separator, before collapsing 

78 

79 def size_at(self, delta: int) -> int: 

80 return self.press_size + self.sign * delta 

81 

82 

83def along(rect, horizontal: bool) -> tuple[int, int, int, int]: 

84 """A rectangle's start and end along a drag, then across it.""" 

85 return ((rect.left(), rect.right(), rect.top(), rect.bottom()) if horizontal 

86 else (rect.top(), rect.bottom(), rect.left(), rect.right())) 

87 

88 

89def touching(rect, pos: QPoint, before: bool, horizontal: bool, gap: int) -> bool: 

90 """Whether ``rect`` ends just before ``pos`` (``before``) or starts 

91 just after it, along the drag, and reaches it across.""" 

92 start, end, low, high = along(rect, horizontal) 

93 p, q = (pos.x(), pos.y()) if horizontal else (pos.y(), pos.x()) 

94 distance = p - end if before else start - p 

95 return 0 <= distance <= gap and low - gap <= q <= high + gap 

96 

97 

98class SnapClosed(QObject): 

99 def __init__(self, window: QMainWindow, docks: list[QDockWidget]): 

100 super().__init__(window) 

101 self.window = window 

102 self.docks = docks 

103 self.collapsed: list[Side] = [] 

104 self.contents: dict[QDockWidget, QWidget] = {} # a collapsed panel's content (its widget: the grip) 

105 self._press: QPoint | None = None 

106 self._sides: list[Side] = [] 

107 for dock in docks: 

108 dock.toggleViewAction().toggled.connect(lambda on, d=dock: on and self.expand(d)) 

109 window.installEventFilter(self) 

110 

111 # ------------------------------------------------------------ finding 

112 def _gap(self) -> int: 

113 return self.window.style().pixelMetric(QStyle.PM_DockWidgetSeparatorExtent, None, self.window) + SLACK 

114 

115 def _front(self) -> list[QDockWidget]: 

116 """The docked panels on view (behind a tab, or collapsed, one is 

117 "visible" but moved out of the window).""" 

118 rect = self.window.rect() 

119 return [d for d in self.docks if d.isVisible() and not d.isFloating() and rect.intersects(d.geometry())] 

120 

121 def _tab_bars(self) -> list[QTabBar]: 

122 return [bar for bar in self.window.findChildren(QTabBar) if bar.parent() is self.window and bar.isVisible()] 

123 

124 def _rect(self, widget: QWidget, tab_bars: list) -> QRect: 

125 """Where a panel is, with its stack's tab bar (which sits between it 

126 and the separator when the tabs are on that side).""" 

127 rect = widget.geometry() 

128 if isinstance(widget, QDockWidget): 128 ↛ 143line 128 didn't jump to line 143 because the condition on line 128 was always true

129 for bar in tab_bars: 

130 other = bar.geometry() 

131 # Its own stack's: starting where it starts and within it, along 

132 # its edge (a bar across: tabs above or below it), or down its 

133 # side (a bar down: tabs beside it). (Not another stack's that 

134 # only touches it: with the macOS style's thin separators, the 

135 # bottom panels' tab bar touched the right side's, which then 

136 # reached across the window; and a stack's bar, beside a panel 

137 # and level with its top, was taken to be its tabs down its side.) 

138 across = other.width() >= other.height() 

139 lined_up = (across and abs(other.left() - rect.left()) <= 4 and other.right() <= rect.right() + 4) \ 

140 or (not across and abs(other.top() - rect.top()) <= 4 and other.bottom() <= rect.bottom() + 4) 

141 if lined_up and other.adjusted(-2, -2, 2, 2).intersects(rect): 

142 rect = rect.united(other) 

143 return rect 

144 

145 def _on_separator(self, pos: QPoint) -> bool: 

146 """No widget there but the window, or one of the invisible widgets Qt 

147 puts over thin separators to catch the mouse (the macOS style's are 

148 1 pixel).""" 

149 widget = self.window.childAt(pos) 

150 return widget is None or widget.objectName() == SEPARATOR_NAME 

151 

152 def _sides_at(self, pos: QPoint) -> list[Side]: 

153 """The panels on each side of the separator at ``pos`` that could collapse.""" 

154 gap, tab_bars = self._gap(), self._tab_bars() 

155 # A collapsed side: its separator is next to its grips. (Not "next to 

156 # what's beyond it": the right side's panels are beyond the left side, 

157 # and their edges are at the right side's separator too.) 

158 for side in self.collapsed: 

159 if any(d.isVisible() and touching(d.geometry(), pos, side.sign > 0, side.horizontal, gap) 

160 for d in side.docks): 

161 return [side] 

162 front = self._front() 

163 rects = {dock: self._rect(dock, tab_bars) for dock in front} 

164 central = self.window.centralWidget() 

165 area = self.window.dockWidgetArea 

166 for horizontal in (True, False): 

167 before = [d for d in front if touching(rects[d], pos, True, horizontal, gap)] 

168 after = [d for d in front if touching(rects[d], pos, False, horizontal, gap)] 

169 if not before and not after: 

170 continue 

171 for side, check in ((before, True), (after, False)): 

172 if central is not None and touching(central.geometry(), pos, check, horizontal, gap): 

173 side.append(central) 

174 sides = [] 

175 for docks, others, sign in ((before, after, 1), (after, before, -1)): 

176 if not docks or central in docks: 

177 continue # (the editor's side can't collapse) 

178 areas = {area(d) for d in docks} 

179 if len(areas) != 1: 179 ↛ 180line 179 didn't jump to line 180 because the condition on line 179 was never true

180 continue 

181 (here,) = areas 

182 if central in others or any(area(d) != here for d in others) \ 

183 or self._outside(pos, [rects[d] for d in front if area(d) == here], horizontal): 

184 # The edge of a side of the window: all of that side 

185 # collapses, moved the way it opens (the left one sideways). 

186 if (here in (Qt.LeftDockWidgetArea, Qt.RightDockWidgetArea)) != horizontal: 

187 continue 

188 docks = [d for d in front if area(d) == here] 

189 group = [] 

190 for dock in docks: 

191 for member in [dock, *self.window.tabifiedDockWidgets(dock)]: 

192 if member not in group: 192 ↛ 191line 192 didn't jump to line 191 because the condition on line 192 was always true

193 group.append(member) 

194 spans = [along(rects[d], horizontal) for d in docks] 

195 size = max(span[1] for span in spans) - min(span[0] for span in spans) + 1 

196 # (All its tabs': Qt keeps a stack as big as its biggest tab needs.) 

197 minimum = max((d.minimumSizeHint().width() if horizontal else d.minimumSizeHint().height()) 

198 for d in group) 

199 sides.append(Side(docks, group, size, max(minimum, 1), sign, horizontal)) 

200 if sides: 200 ↛ 166line 200 didn't jump to line 166 because the condition on line 200 was always true

201 return sides 

202 return [] 

203 

204 @staticmethod 

205 def _outside(pos: QPoint, rects: list[QRect], horizontal: bool) -> bool: 

206 """Whether ``pos`` is beyond all of a side's panels, along the drag: the 

207 side's own edge, whatever's beyond it. (Not found beyond: a row past 

208 where the editor ends, which the macOS style's widget over a thin 

209 separator reaches; only the panel there was taken, and one of two 

210 side by side collapsed, leaving the side as tall, blank.)""" 

211 spans = [along(rect, horizontal) for rect in rects] 

212 p = pos.x() if horizontal else pos.y() 

213 return bool(spans) and (p < min(span[0] for span in spans) or p > max(span[1] for span in spans)) 

214 

215 # ------------------------------------------------------------ collapsing 

216 def _collapse(self, side: Side): 

217 """The panels in front: no content or title, so no minimum size; the 

218 ones behind them in their stacks: hidden (with one panel left, Qt 

219 takes the stack's tab bar away, which would keep a strip open). 

220 Panels among them already collapsed (on their own: one of two side by 

221 side, then both) are opened first: their content put back, to be 

222 saved again (saved as it was, a panel's would be its grip, and it would 

223 come back blank); they open with the others.""" 

224 for other in [s for s in self.collapsed if set(s.group) & set(side.group)]: 

225 self._open(other) 

226 side.cross = [d.height() if side.horizontal else d.width() for d in side.docks] 

227 for dock in side.group: 

228 if dock in side.docks: 

229 content = dock.widget() 

230 self.contents[dock] = content 

231 dock.setTitleBarWidget(_NoTitle()) 

232 dock.setWidget(Grip(self, side)) 

233 if content is not None: 233 ↛ 227line 233 didn't jump to line 227 because the condition on line 233 was always true

234 content.hide() # (still the panel's child, put back by _open) 

235 else: 

236 dock.hide() 

237 self.collapsed.append(side) 

238 

239 def _open(self, side: Side): 

240 self.collapsed.remove(side) # (first: showing them below mustn't open it again) 

241 for dock in side.docks: 

242 grip = dock.widget() 

243 content = self.contents.pop(dock, None) 

244 dock.setTitleBarWidget(None) 

245 if content is not None: 245 ↛ 248line 245 didn't jump to line 248 because the condition on line 245 was always true

246 dock.setWidget(content) 

247 content.show() 

248 if isinstance(grip, Grip): 248 ↛ 241line 248 didn't jump to line 241 because the condition on line 248 was always true

249 grip.hide() 

250 grip.deleteLater() 

251 for dock in side.group: 

252 dock.show() # (back in their places in the stacks) 

253 for dock in side.docks: # (the tabs that were in front; again once Qt has put the others back) 

254 dock.raise_() 

255 QTimer.singleShot(0, dock, dock.raise_) 

256 if side.cross and len(side.docks) > 1: # (the panels beside each other: as they were) 

257 self.window.resizeDocks(side.docks, side.cross, Qt.Vertical if side.horizontal else Qt.Horizontal) 

258 

259 def _resize(self, side: Side, size: int): 

260 orientation = Qt.Horizontal if side.horizontal else Qt.Vertical 

261 self.window.resizeDocks(side.docks, [max(size, 1)] * len(side.docks), orientation) 

262 

263 def content(self, dock: QDockWidget) -> QWidget | None: 

264 """A panel's content, collapsed or not.""" 

265 return self.contents.get(dock) or dock.widget() 

266 

267 def is_collapsed(self, dock: QDockWidget) -> bool: 

268 return any(dock in side.group for side in self.collapsed) 

269 

270 def expand(self, dock: QDockWidget): 

271 """Open a collapsed panel (and the others collapsed with it), at the 

272 size it had.""" 

273 side = next((s for s in self.collapsed if dock in s.group), None) 

274 if side is None: 

275 return 

276 self._open(side) 

277 QTimer.singleShot(0, dock, dock.raise_) # (in front: after _open's raising of the tabs that were) 

278 QTimer.singleShot(0, self.window, lambda: self._resize(side, side.size)) 

279 

280 def expand_all(self): 

281 """Open every collapsed panel, at its size (Reset Layout; quitting, 

282 so the saved layout has them open).""" 

283 for side in list(self.collapsed): 

284 self._open(side) 

285 self._resize(side, side.size) 

286 

287 # ------------------------------------------------------------ the drag 

288 # On a separator next to panels that can collapse, the drag is ours (Qt's 

289 # own stops at the minimum sizes, and doesn't follow a panel just opened). 

290 def eventFilter(self, obj, event): 

291 kind = event.type() 

292 if kind == QEvent.MouseButtonPress and event.button() == Qt.LeftButton: 

293 pos = event.position().toPoint() 

294 self._press = None 

295 if self._on_separator(pos): 295 ↛ 309line 295 didn't jump to line 309 because the condition on line 295 was always true

296 sides = self._sides_at(pos) 

297 if sides: 297 ↛ 309line 297 didn't jump to line 309 because the condition on line 297 was always true

298 self.start_drag(sides, pos) 

299 return True 

300 elif kind == QEvent.MouseMove and self._press is not None: 

301 if not event.buttons() & Qt.LeftButton: 301 ↛ 302line 301 didn't jump to line 302 because the condition on line 301 was never true

302 self.end_drag() 

303 return False 

304 self._drag_to(event.position().toPoint()) 

305 return True 

306 elif kind == QEvent.MouseButtonRelease and self._press is not None: 

307 self.end_drag() 

308 return True 

309 return False 

310 

311 def start_drag(self, sides: list[Side], pos: QPoint): 

312 """A drag of a separator (or a collapsed panel's grip) from ``pos`` 

313 (in the window).""" 

314 self._sides, self._press = sides, pos 

315 for side in sides: 

316 side.press_size = GRIP if side in self.collapsed else side.size 

317 

318 def end_drag(self): 

319 self._press, self._sides = None, [] 

320 if QWidget.mouseGrabber() is self.window: 

321 self.window.releaseMouse() 

322 

323 def _drag_to(self, pos: QPoint): 

324 moved = pos - self._press 

325 sizes = [] 

326 for side in self._sides: 

327 size = side.size_at(moved.x() if side.horizontal else moved.y()) 

328 collapsed = side in self.collapsed 

329 # (Opening again a little further than it collapsed: the mouse wobbling 

330 # where it collapses would swap it back and forth at every move.) 

331 if not collapsed and size < side.minimum / 2: 

332 side.size = side.press_size # (to open at, later) 

333 self._collapse(side) 

334 elif collapsed and size >= side.minimum / 2 + REOPEN: 

335 self._open(side) 

336 sizes.append((side, GRIP if side in self.collapsed else max(size, side.minimum))) 

337 # Both sides of the separator sized (one alone, and Qt shares the change 

338 # between both: the separator moved half as far as the mouse), the 

339 # collapsed one first: the other takes what's left. 

340 sizes.sort(key=lambda pair: pair[0] not in self.collapsed) 

341 docks, lengths = [], [] 

342 for side, size in sizes: 

343 docks += side.docks 

344 lengths += [max(size, 1)] * len(side.docks) 

345 self.window.resizeDocks(docks, lengths, Qt.Horizontal if sizes[0][0].horizontal else Qt.Vertical)