vibefpmgui.dock_snap
Dragging a panel's edge past its smallest size collapses it, as in VS Code.
Qt stops a dock separator at the panels' minimum sizes, so SnapClosed
does separator drags itself (they reach the main window, not a child
widget). Once the mouse is more than half a panel's minimum size past where
it would stop, the panel collapses to a thin Grip; dragging the grip (or
the separator beside it) back out opens it, and so does double-clicking it.
At the edge of a side of the window the whole side collapses; between two
panels, just the squeezed one, with the tabs stacked with it.
A command showing a collapsed panel (a test run showing Tests…), View → Panels and Reset Layout open it too.
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: 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): 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: 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: 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: 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: 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: 246 dock.setWidget(content) 247 content.show() 248 if isinstance(grip, Grip): 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): 296 sides = self._sides_at(pos) 297 if sides: 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: 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)
39class Grip(QWidget): 40 """A collapsed panel's content: a thin strip with the resize cursor (the 41 style draws the dots on the separator beside it). Not its title bar: 42 with no content, Qt keeps a panel only as big as its title, in a corner.""" 43 44 def __init__(self, snap: SnapClosed, side: Side): 45 super().__init__() 46 self.snap, self.side = snap, side 47 self.setCursor(Qt.SplitHCursor if side.horizontal else Qt.SplitVCursor) 48 self.setToolTip("Drag to open (or double-click)") 49 50 def sizeHint(self): 51 return QSize(GRIP, GRIP) 52 53 def minimumSizeHint(self): 54 return QSize(GRIP, GRIP) 55 56 def mousePressEvent(self, event): 57 if event.button() == Qt.LeftButton: 58 # The window takes the rest of the drag: the grip goes once the panel opens. 59 window = self.snap.window 60 self.snap.start_drag([self.side], self.mapTo(window, event.position().toPoint())) 61 window.grabMouse(self.cursor()) 62 event.accept() # (not the panel's: it would float it) 63 64 def mouseDoubleClickEvent(self, event): 65 self.snap.expand(self.side.docks[0])
A collapsed panel's content: a thin strip with the resize cursor (the style draws the dots on the separator beside it). Not its title bar: with no content, Qt keeps a panel only as big as its title, in a corner.
44 def __init__(self, snap: SnapClosed, side: Side): 45 super().__init__() 46 self.snap, self.side = snap, side 47 self.setCursor(Qt.SplitHCursor if side.horizontal else Qt.SplitVCursor) 48 self.setToolTip("Drag to open (or double-click)")
__init__(self, /, parent: PySide6.QtWidgets.QWidget | None = None, f: PySide6.QtCore.Qt.WindowType = Default(Qt.WindowFlags), *, modal: bool | None = None, windowModality: PySide6.QtCore.Qt.WindowModality | None = None, enabled: bool | None = None, geometry: PySide6.QtCore.QRect | None = None, frameGeometry: PySide6.QtCore.QRect | None = None, normalGeometry: PySide6.QtCore.QRect | None = None, x: int | None = None, y: int | None = None, pos: PySide6.QtCore.QPoint | None = None, frameSize: PySide6.QtCore.QSize | None = None, size: PySide6.QtCore.QSize | None = None, width: int | None = None, height: int | None = None, rect: PySide6.QtCore.QRect | None = None, childrenRect: PySide6.QtCore.QRect | None = None, childrenRegion: PySide6.QtGui.QRegion | None = None, sizePolicy: PySide6.QtWidgets.QSizePolicy | None = None, minimumSize: PySide6.QtCore.QSize | None = None, maximumSize: PySide6.QtCore.QSize | None = None, minimumWidth: int | None = None, minimumHeight: int | None = None, maximumWidth: int | None = None, maximumHeight: int | None = None, sizeIncrement: PySide6.QtCore.QSize | None = None, baseSize: PySide6.QtCore.QSize | None = None, palette: PySide6.QtGui.QPalette | None = None, font: PySide6.QtGui.QFont | None = None, cursor: PySide6.QtGui.QCursor | None = None, mouseTracking: bool | None = None, tabletTracking: bool | None = None, isActiveWindow: bool | None = None, focusPolicy: PySide6.QtCore.Qt.FocusPolicy | None = None, focus: bool | None = None, contextMenuPolicy: PySide6.QtCore.Qt.ContextMenuPolicy | None = None, updatesEnabled: bool | None = None, visible: bool | None = None, minimized: bool | None = None, maximized: bool | None = None, fullScreen: bool | None = None, sizeHint: PySide6.QtCore.QSize | None = None, minimumSizeHint: PySide6.QtCore.QSize | None = None, acceptDrops: bool | None = None, windowTitle: str | None = None, windowIcon: PySide6.QtGui.QIcon | None = None, windowIconText: str | None = None, windowOpacity: float | None = None, windowModified: bool | None = None, toolTip: str | None = None, toolTipDuration: int | None = None, statusTip: str | None = None, whatsThis: str | None = None, accessibleName: str | None = None, accessibleDescription: str | None = None, accessibleIdentifier: str | None = None, layoutDirection: PySide6.QtCore.Qt.LayoutDirection | None = None, autoFillBackground: bool | None = None, styleSheet: str | None = None, locale: PySide6.QtCore.QLocale | None = None, windowFilePath: str | None = None, inputMethodHints: PySide6.QtCore.Qt.InputMethodHint | None = None) -> None
Initialize self. See help(type(self)) for accurate signature.
56 def mousePressEvent(self, event): 57 if event.button() == Qt.LeftButton: 58 # The window takes the rest of the drag: the grip goes once the panel opens. 59 window = self.snap.window 60 self.snap.start_drag([self.side], self.mapTo(window, event.position().toPoint())) 61 window.grabMouse(self.cursor()) 62 event.accept() # (not the panel's: it would float it)
mousePressEvent(self, event: PySide6.QtGui.QMouseEvent, /) -> None
68@dataclass(eq=False) 69class Side: 70 """The panels on one side of a separator: what collapses together.""" 71 docks: list[QDockWidget] # the ones in front along the separator 72 group: list[QDockWidget] # and every one stacked with them 73 size: int # their extent across the separator (at the press, or before collapsing) 74 minimum: int 75 sign: int # +1: the mouse moving along the axis makes them bigger 76 horizontal: bool # (the drag's axis: a vertical separator moves sideways) 77 press_size: int = 0 # (during a drag: the size at the press) 78 cross: list[int] | None = None # the docks' sizes along the separator, before collapsing 79 80 def size_at(self, delta: int) -> int: 81 return self.press_size + self.sign * delta
The panels on one side of a separator: what collapses together.
84def along(rect, horizontal: bool) -> tuple[int, int, int, int]: 85 """A rectangle's start and end along a drag, then across it.""" 86 return ((rect.left(), rect.right(), rect.top(), rect.bottom()) if horizontal 87 else (rect.top(), rect.bottom(), rect.left(), rect.right()))
A rectangle's start and end along a drag, then across it.
90def touching(rect, pos: QPoint, before: bool, horizontal: bool, gap: int) -> bool: 91 """Whether ``rect`` ends just before ``pos`` (``before``) or starts 92 just after it, along the drag, and reaches it across.""" 93 start, end, low, high = along(rect, horizontal) 94 p, q = (pos.x(), pos.y()) if horizontal else (pos.y(), pos.x()) 95 distance = p - end if before else start - p 96 return 0 <= distance <= gap and low - gap <= q <= high + gap
Whether rect ends just before pos (before) or starts
just after it, along the drag, and reaches it across.
99class SnapClosed(QObject): 100 def __init__(self, window: QMainWindow, docks: list[QDockWidget]): 101 super().__init__(window) 102 self.window = window 103 self.docks = docks 104 self.collapsed: list[Side] = [] 105 self.contents: dict[QDockWidget, QWidget] = {} # a collapsed panel's content (its widget: the grip) 106 self._press: QPoint | None = None 107 self._sides: list[Side] = [] 108 for dock in docks: 109 dock.toggleViewAction().toggled.connect(lambda on, d=dock: on and self.expand(d)) 110 window.installEventFilter(self) 111 112 # ------------------------------------------------------------ finding 113 def _gap(self) -> int: 114 return self.window.style().pixelMetric(QStyle.PM_DockWidgetSeparatorExtent, None, self.window) + SLACK 115 116 def _front(self) -> list[QDockWidget]: 117 """The docked panels on view (behind a tab, or collapsed, one is 118 "visible" but moved out of the window).""" 119 rect = self.window.rect() 120 return [d for d in self.docks if d.isVisible() and not d.isFloating() and rect.intersects(d.geometry())] 121 122 def _tab_bars(self) -> list[QTabBar]: 123 return [bar for bar in self.window.findChildren(QTabBar) if bar.parent() is self.window and bar.isVisible()] 124 125 def _rect(self, widget: QWidget, tab_bars: list) -> QRect: 126 """Where a panel is, with its stack's tab bar (which sits between it 127 and the separator when the tabs are on that side).""" 128 rect = widget.geometry() 129 if isinstance(widget, QDockWidget): 130 for bar in tab_bars: 131 other = bar.geometry() 132 # Its own stack's: starting where it starts and within it, along 133 # its edge (a bar across: tabs above or below it), or down its 134 # side (a bar down: tabs beside it). (Not another stack's that 135 # only touches it: with the macOS style's thin separators, the 136 # bottom panels' tab bar touched the right side's, which then 137 # reached across the window; and a stack's bar, beside a panel 138 # and level with its top, was taken to be its tabs down its side.) 139 across = other.width() >= other.height() 140 lined_up = (across and abs(other.left() - rect.left()) <= 4 and other.right() <= rect.right() + 4) \ 141 or (not across and abs(other.top() - rect.top()) <= 4 and other.bottom() <= rect.bottom() + 4) 142 if lined_up and other.adjusted(-2, -2, 2, 2).intersects(rect): 143 rect = rect.united(other) 144 return rect 145 146 def _on_separator(self, pos: QPoint) -> bool: 147 """No widget there but the window, or one of the invisible widgets Qt 148 puts over thin separators to catch the mouse (the macOS style's are 149 1 pixel).""" 150 widget = self.window.childAt(pos) 151 return widget is None or widget.objectName() == SEPARATOR_NAME 152 153 def _sides_at(self, pos: QPoint) -> list[Side]: 154 """The panels on each side of the separator at ``pos`` that could collapse.""" 155 gap, tab_bars = self._gap(), self._tab_bars() 156 # A collapsed side: its separator is next to its grips. (Not "next to 157 # what's beyond it": the right side's panels are beyond the left side, 158 # and their edges are at the right side's separator too.) 159 for side in self.collapsed: 160 if any(d.isVisible() and touching(d.geometry(), pos, side.sign > 0, side.horizontal, gap) 161 for d in side.docks): 162 return [side] 163 front = self._front() 164 rects = {dock: self._rect(dock, tab_bars) for dock in front} 165 central = self.window.centralWidget() 166 area = self.window.dockWidgetArea 167 for horizontal in (True, False): 168 before = [d for d in front if touching(rects[d], pos, True, horizontal, gap)] 169 after = [d for d in front if touching(rects[d], pos, False, horizontal, gap)] 170 if not before and not after: 171 continue 172 for side, check in ((before, True), (after, False)): 173 if central is not None and touching(central.geometry(), pos, check, horizontal, gap): 174 side.append(central) 175 sides = [] 176 for docks, others, sign in ((before, after, 1), (after, before, -1)): 177 if not docks or central in docks: 178 continue # (the editor's side can't collapse) 179 areas = {area(d) for d in docks} 180 if len(areas) != 1: 181 continue 182 (here,) = areas 183 if central in others or any(area(d) != here for d in others) \ 184 or self._outside(pos, [rects[d] for d in front if area(d) == here], horizontal): 185 # The edge of a side of the window: all of that side 186 # collapses, moved the way it opens (the left one sideways). 187 if (here in (Qt.LeftDockWidgetArea, Qt.RightDockWidgetArea)) != horizontal: 188 continue 189 docks = [d for d in front if area(d) == here] 190 group = [] 191 for dock in docks: 192 for member in [dock, *self.window.tabifiedDockWidgets(dock)]: 193 if member not in group: 194 group.append(member) 195 spans = [along(rects[d], horizontal) for d in docks] 196 size = max(span[1] for span in spans) - min(span[0] for span in spans) + 1 197 # (All its tabs': Qt keeps a stack as big as its biggest tab needs.) 198 minimum = max((d.minimumSizeHint().width() if horizontal else d.minimumSizeHint().height()) 199 for d in group) 200 sides.append(Side(docks, group, size, max(minimum, 1), sign, horizontal)) 201 if sides: 202 return sides 203 return [] 204 205 @staticmethod 206 def _outside(pos: QPoint, rects: list[QRect], horizontal: bool) -> bool: 207 """Whether ``pos`` is beyond all of a side's panels, along the drag: the 208 side's own edge, whatever's beyond it. (Not found beyond: a row past 209 where the editor ends, which the macOS style's widget over a thin 210 separator reaches; only the panel there was taken, and one of two 211 side by side collapsed, leaving the side as tall, blank.)""" 212 spans = [along(rect, horizontal) for rect in rects] 213 p = pos.x() if horizontal else pos.y() 214 return bool(spans) and (p < min(span[0] for span in spans) or p > max(span[1] for span in spans)) 215 216 # ------------------------------------------------------------ collapsing 217 def _collapse(self, side: Side): 218 """The panels in front: no content or title, so no minimum size; the 219 ones behind them in their stacks: hidden (with one panel left, Qt 220 takes the stack's tab bar away, which would keep a strip open). 221 Panels among them already collapsed (on their own: one of two side by 222 side, then both) are opened first: their content put back, to be 223 saved again (saved as it was, a panel's would be its grip, and it would 224 come back blank); they open with the others.""" 225 for other in [s for s in self.collapsed if set(s.group) & set(side.group)]: 226 self._open(other) 227 side.cross = [d.height() if side.horizontal else d.width() for d in side.docks] 228 for dock in side.group: 229 if dock in side.docks: 230 content = dock.widget() 231 self.contents[dock] = content 232 dock.setTitleBarWidget(_NoTitle()) 233 dock.setWidget(Grip(self, side)) 234 if content is not None: 235 content.hide() # (still the panel's child, put back by _open) 236 else: 237 dock.hide() 238 self.collapsed.append(side) 239 240 def _open(self, side: Side): 241 self.collapsed.remove(side) # (first: showing them below mustn't open it again) 242 for dock in side.docks: 243 grip = dock.widget() 244 content = self.contents.pop(dock, None) 245 dock.setTitleBarWidget(None) 246 if content is not None: 247 dock.setWidget(content) 248 content.show() 249 if isinstance(grip, Grip): 250 grip.hide() 251 grip.deleteLater() 252 for dock in side.group: 253 dock.show() # (back in their places in the stacks) 254 for dock in side.docks: # (the tabs that were in front; again once Qt has put the others back) 255 dock.raise_() 256 QTimer.singleShot(0, dock, dock.raise_) 257 if side.cross and len(side.docks) > 1: # (the panels beside each other: as they were) 258 self.window.resizeDocks(side.docks, side.cross, Qt.Vertical if side.horizontal else Qt.Horizontal) 259 260 def _resize(self, side: Side, size: int): 261 orientation = Qt.Horizontal if side.horizontal else Qt.Vertical 262 self.window.resizeDocks(side.docks, [max(size, 1)] * len(side.docks), orientation) 263 264 def content(self, dock: QDockWidget) -> QWidget | None: 265 """A panel's content, collapsed or not.""" 266 return self.contents.get(dock) or dock.widget() 267 268 def is_collapsed(self, dock: QDockWidget) -> bool: 269 return any(dock in side.group for side in self.collapsed) 270 271 def expand(self, dock: QDockWidget): 272 """Open a collapsed panel (and the others collapsed with it), at the 273 size it had.""" 274 side = next((s for s in self.collapsed if dock in s.group), None) 275 if side is None: 276 return 277 self._open(side) 278 QTimer.singleShot(0, dock, dock.raise_) # (in front: after _open's raising of the tabs that were) 279 QTimer.singleShot(0, self.window, lambda: self._resize(side, side.size)) 280 281 def expand_all(self): 282 """Open every collapsed panel, at its size (Reset Layout; quitting, 283 so the saved layout has them open).""" 284 for side in list(self.collapsed): 285 self._open(side) 286 self._resize(side, side.size) 287 288 # ------------------------------------------------------------ the drag 289 # On a separator next to panels that can collapse, the drag is ours (Qt's 290 # own stops at the minimum sizes, and doesn't follow a panel just opened). 291 def eventFilter(self, obj, event): 292 kind = event.type() 293 if kind == QEvent.MouseButtonPress and event.button() == Qt.LeftButton: 294 pos = event.position().toPoint() 295 self._press = None 296 if self._on_separator(pos): 297 sides = self._sides_at(pos) 298 if sides: 299 self.start_drag(sides, pos) 300 return True 301 elif kind == QEvent.MouseMove and self._press is not None: 302 if not event.buttons() & Qt.LeftButton: 303 self.end_drag() 304 return False 305 self._drag_to(event.position().toPoint()) 306 return True 307 elif kind == QEvent.MouseButtonRelease and self._press is not None: 308 self.end_drag() 309 return True 310 return False 311 312 def start_drag(self, sides: list[Side], pos: QPoint): 313 """A drag of a separator (or a collapsed panel's grip) from ``pos`` 314 (in the window).""" 315 self._sides, self._press = sides, pos 316 for side in sides: 317 side.press_size = GRIP if side in self.collapsed else side.size 318 319 def end_drag(self): 320 self._press, self._sides = None, [] 321 if QWidget.mouseGrabber() is self.window: 322 self.window.releaseMouse() 323 324 def _drag_to(self, pos: QPoint): 325 moved = pos - self._press 326 sizes = [] 327 for side in self._sides: 328 size = side.size_at(moved.x() if side.horizontal else moved.y()) 329 collapsed = side in self.collapsed 330 # (Opening again a little further than it collapsed: the mouse wobbling 331 # where it collapses would swap it back and forth at every move.) 332 if not collapsed and size < side.minimum / 2: 333 side.size = side.press_size # (to open at, later) 334 self._collapse(side) 335 elif collapsed and size >= side.minimum / 2 + REOPEN: 336 self._open(side) 337 sizes.append((side, GRIP if side in self.collapsed else max(size, side.minimum))) 338 # Both sides of the separator sized (one alone, and Qt shares the change 339 # between both: the separator moved half as far as the mouse), the 340 # collapsed one first: the other takes what's left. 341 sizes.sort(key=lambda pair: pair[0] not in self.collapsed) 342 docks, lengths = [], [] 343 for side, size in sizes: 344 docks += side.docks 345 lengths += [max(size, 1)] * len(side.docks) 346 self.window.resizeDocks(docks, lengths, Qt.Horizontal if sizes[0][0].horizontal else Qt.Vertical)
QObject(self, /, parent: PySide6.QtCore.QObject | None = None, *, objectName: str | None = None) -> None
100 def __init__(self, window: QMainWindow, docks: list[QDockWidget]): 101 super().__init__(window) 102 self.window = window 103 self.docks = docks 104 self.collapsed: list[Side] = [] 105 self.contents: dict[QDockWidget, QWidget] = {} # a collapsed panel's content (its widget: the grip) 106 self._press: QPoint | None = None 107 self._sides: list[Side] = [] 108 for dock in docks: 109 dock.toggleViewAction().toggled.connect(lambda on, d=dock: on and self.expand(d)) 110 window.installEventFilter(self)
__init__(self, /, parent: PySide6.QtCore.QObject | None = None, *, objectName: str | None = None) -> None
Initialize self. See help(type(self)) for accurate signature.
264 def content(self, dock: QDockWidget) -> QWidget | None: 265 """A panel's content, collapsed or not.""" 266 return self.contents.get(dock) or dock.widget()
A panel's content, collapsed or not.
271 def expand(self, dock: QDockWidget): 272 """Open a collapsed panel (and the others collapsed with it), at the 273 size it had.""" 274 side = next((s for s in self.collapsed if dock in s.group), None) 275 if side is None: 276 return 277 self._open(side) 278 QTimer.singleShot(0, dock, dock.raise_) # (in front: after _open's raising of the tabs that were) 279 QTimer.singleShot(0, self.window, lambda: self._resize(side, side.size))
Open a collapsed panel (and the others collapsed with it), at the size it had.
281 def expand_all(self): 282 """Open every collapsed panel, at its size (Reset Layout; quitting, 283 so the saved layout has them open).""" 284 for side in list(self.collapsed): 285 self._open(side) 286 self._resize(side, side.size)
Open every collapsed panel, at its size (Reset Layout; quitting, so the saved layout has them open).
291 def eventFilter(self, obj, event): 292 kind = event.type() 293 if kind == QEvent.MouseButtonPress and event.button() == Qt.LeftButton: 294 pos = event.position().toPoint() 295 self._press = None 296 if self._on_separator(pos): 297 sides = self._sides_at(pos) 298 if sides: 299 self.start_drag(sides, pos) 300 return True 301 elif kind == QEvent.MouseMove and self._press is not None: 302 if not event.buttons() & Qt.LeftButton: 303 self.end_drag() 304 return False 305 self._drag_to(event.position().toPoint()) 306 return True 307 elif kind == QEvent.MouseButtonRelease and self._press is not None: 308 self.end_drag() 309 return True 310 return False
eventFilter(self, watched: PySide6.QtCore.QObject, event: PySide6.QtCore.QEvent, /) -> bool
312 def start_drag(self, sides: list[Side], pos: QPoint): 313 """A drag of a separator (or a collapsed panel's grip) from ``pos`` 314 (in the window).""" 315 self._sides, self._press = sides, pos 316 for side in sides: 317 side.press_size = GRIP if side in self.collapsed else side.size
A drag of a separator (or a collapsed panel's grip) from pos
(in the window).