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
« 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.
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.
11A command showing a collapsed panel (a test run showing Tests…), View →
12Panels and Reset Layout open it too.
13"""
15from __future__ import annotations
17from dataclasses import dataclass
19from qtpy.QtCore import QEvent, QObject, QPoint, QRect, QSize, Qt, QTimer
20from qtpy.QtWidgets import QDockWidget, QMainWindow, QStyle, QTabBar, QWidget
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)
28class _NoTitle(QWidget):
29 """A collapsed panel's title bar: nothing."""
31 def sizeHint(self):
32 return QSize(0, 0)
34 def minimumSizeHint(self):
35 return QSize(0, 0)
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."""
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)")
49 def sizeHint(self):
50 return QSize(GRIP, GRIP)
52 def minimumSizeHint(self):
53 return QSize(GRIP, GRIP)
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)
63 def mouseDoubleClickEvent(self, event):
64 self.snap.expand(self.side.docks[0])
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
79 def size_at(self, delta: int) -> int:
80 return self.press_size + self.sign * delta
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()))
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
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)
111 # ------------------------------------------------------------ finding
112 def _gap(self) -> int:
113 return self.window.style().pixelMetric(QStyle.PM_DockWidgetSeparatorExtent, None, self.window) + SLACK
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())]
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()]
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
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
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 []
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))
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)
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)
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)
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()
267 def is_collapsed(self, dock: QDockWidget) -> bool:
268 return any(dock in side.group for side in self.collapsed)
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))
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)
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
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
318 def end_drag(self):
319 self._press, self._sides = None, []
320 if QWidget.mouseGrabber() is self.window:
321 self.window.releaseMouse()
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)