Coverage for vibefpmgui/window/panels.py: 97%
191 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"""The panels: docks, their layout and the editor in the middle.
3Every panel but the editor is a dock (moved, stacked, floated, closed
4from View → Panels); `RedockOnDoubleClick` docks a floating one back.
5"""
7from __future__ import annotations
9from qtpy.QtCore import QEvent, QObject, QPointF, Qt, QTimer
10from qtpy.QtGui import QMouseEvent
11from qtpy.QtWidgets import (
12 QApplication,
13 QDockWidget,
14 QLabel,
15 QMainWindow,
16 QStackedWidget,
17 QTabWidget,
18 QWidget,
19)
21from ..dock_snap import SnapClosed
22from ..editor import EditorTab
23from ..terminal import TerminalPanel
26class RedockOnDoubleClick(QObject):
27 """Double-clicking a floating panel's title docks it back, once the
28 mouse button is up.
30 On macOS a floating panel has the system's title bar, and pressing on it
31 starts a window move that macOS carries out; the second press of a
32 double-click starts one too.
33 Qt docked the panel at the double-click itself, in the middle of that
34 move: the panel was left over the main window instead of in its place
35 (and macOS logged "Window move completed without beginning"). Here the
36 double-click is taken over, and the panel is docked (as setFloating(False)
37 does, which works) when the move is done."""
39 POLL_MS = 30
40 MAX_POLLS = 100 # (3 s: if the button is never seen up, dock anyway)
42 def __init__(self, parent: QObject):
43 super().__init__(parent)
44 self._dock: QDockWidget | None = None
45 self._polls = 0
46 self._timer = QTimer(self)
47 self._timer.setInterval(self.POLL_MS)
48 self._timer.timeout.connect(self._check)
50 def eventFilter(self, dock, event) -> bool:
51 if not (isinstance(dock, QDockWidget) and dock.isFloating()):
52 return False
53 # The title: the window's own title bar (macOS: a floating panel has
54 # the system's), or Qt's drawn above the panel's content.
55 on_title = event.type() == QEvent.NonClientAreaMouseButtonDblClick or (
56 event.type() == QEvent.MouseButtonDblClick and dock.widget() is not None
57 and event.position().y() < dock.widget().geometry().top())
58 if on_title and event.button() == Qt.LeftButton:
59 self._dock, self._polls = dock, 0
60 self._timer.start()
61 return True # (not Qt's own docking, mid-move)
62 return False
64 def _check(self):
65 self._polls += 1
66 if QApplication.mouseButtons() != Qt.NoButton and self._polls < self.MAX_POLLS:
67 return # (still held: macOS may still be moving the window)
68 self._timer.stop()
69 dock, self._dock = self._dock, None
70 if dock is None: 70 ↛ 71line 70 didn't jump to line 71 because the condition on line 70 was never true
71 return
72 # The button-up macOS kept to itself (moving the window): Qt's drag of
73 # the panel, if it started one, ends. (Left unended, the main window
74 # kept its layout frozen: panels stayed put when the window was
75 # resized, and a collapsed one left empty space.)
76 pos = QPointF(dock.width() / 2, -10)
77 QApplication.sendEvent(dock, QMouseEvent(QEvent.NonClientAreaMouseButtonRelease, pos, dock.mapToGlobal(pos),
78 Qt.LeftButton, Qt.NoButton, Qt.NoModifier))
79 if dock.isFloating(): 79 ↛ 81line 79 didn't jump to line 81 because the condition on line 79 was always true
80 dock.setFloating(False)
81 window = dock.parentWidget()
82 front = [dock]
83 if isinstance(window, QMainWindow): 83 ↛ 90line 83 didn't jump to line 90 because the condition on line 83 was always true
84 # Anything else of the drag's goes too: the layout restored as it is. (Each
85 # other stack's tab in front is put back after: a restore doesn't keep them.)
86 stack = window.tabifiedDockWidgets(dock)
87 front += [d for d in window.findChildren(QDockWidget) if d is not dock and d not in stack
88 and d.isVisible() and not d.isFloating() and window.rect().intersects(d.geometry())]
89 window.restoreState(window.saveState())
90 for panel in front:
91 panel.raise_()
92 QTimer.singleShot(0, panel, panel.raise_)
95class PanelsMixin:
96 """The panels and their layout (a part of `vibefpmgui.main_window.MainWindow`)."""
98 def _build_central(self):
99 """The editor in the middle (a hint while no file is open)."""
100 self.no_file_hint = QLabel("Open a file from <b>Project files</b>, or with "
101 "<b>File → Open File in Editor…</b>")
102 self.no_file_hint.setAlignment(Qt.AlignCenter)
103 self.no_file_hint.setWordWrap(True) # (narrow: the panels beside it can be wider)
104 self.no_file_hint.setContentsMargins(12, 0, 12, 0)
105 self.no_file_hint.setStyleSheet("color: gray;")
106 self.central = QStackedWidget()
107 self.central.addWidget(self.no_file_hint)
108 self.central.addWidget(self.editor_area) # (the tabs, and their rows when they wrap)
109 self.setCentralWidget(self.central)
111 def _build_docks(self, options: QWidget, files_box: QWidget, graph_tab: QWidget, console_box: QWidget):
112 """Every panel but the editor is a dock: moved to the left, right or
113 bottom, stacked as tabs on another, floated as a window of its own, or
114 closed (View → Panels, filled here)."""
115 # (Not GroupedDragging: a stack of panels dragged out together goes in a
116 # window of Qt's own, and once that's closed its panels come back stuck
117 # over the editor, outside the layout. One panel at a time is solid.
118 # Not AnimatedDocks: on macOS the panel layout could start animating
119 # against itself, each finished animation laying out the panels again
120 # and starting the next, at 100% CPU, the window frozen (seen with a
121 # click in the editor). Panels snap into place instead of sliding; the
122 # style has no animation duration either: see themes.StillStyle.)
123 self.setDockOptions(QMainWindow.AllowNestedDocks | QMainWindow.AllowTabbedDocks)
124 self.setTabPosition(Qt.AllDockWidgetAreas, QTabWidget.North) # (stacked panels: tabs on top)
125 self.setDocumentMode(True)
126 self.setCorner(Qt.BottomLeftCorner, Qt.LeftDockWidgetArea) # (the left panels: full height)
127 self.setCorner(Qt.BottomRightCorner, Qt.BottomDockWidgetArea)
128 self.docks: dict[str, QDockWidget] = {}
129 self._dock_of: dict[QWidget, QDockWidget] = {}
130 for name, title, widget in [
131 ("options", "Build options", options), ("files", "Project files", files_box),
132 ("graph", "Dependency graph", graph_tab), ("outputs", "Build outputs", self.outputs_view),
133 ("build", "Build", self.build_monitor), ("tests", "Tests", self.test_monitor),
134 ("run", "Run", self.run_monitor), ("debug", "Debug", self.debug_panel),
135 ("coverage", "Coverage", self.coverage_panel),
136 ("complexity", "Complexity", self.complexity_panel),
137 ("problems", "Problems", self.problems_panel),
138 ("review", "Review", self.review_monitor),
139 ("references", "References", self.references_panel), ("search", "Search", self.search_panel),
140 ("git", "Git", self.git_panel),
141 ("terminal", "Terminal", self.terminal_panel), ("output", "Output", console_box),
142 ]:
143 self._make_dock(name, title, widget)
144 self._docks_ready = False # (until the window is shown: see _dock_shown)
145 self.docks["outputs"].visibilityChanged.connect(lambda shown: shown and self.refresh_build_outputs())
146 self.docks["terminal"].visibilityChanged.connect(self._terminal_dock_shown)
147 self.docks["problems"].visibilityChanged.connect(self._problems_shown)
148 self.docks["git"].visibilityChanged.connect(self._git_panel_shown)
149 self.docks["complexity"].visibilityChanged.connect(self._complexity_panel_shown)
150 self.docks["graph"].visibilityChanged.connect(self._update_editor_actions) # (Find: the graph's search)
151 self.default_layout()
152 # Dragging a panel's edge past its smallest size collapses it (see dock_snap).
153 self.snap_closed = SnapClosed(self, list(self.docks.values()))
154 self._update_central()
155 self.view_menu.addSeparator()
156 panels = self.panels_menu = self.view_menu.addMenu("Panels") # (kept: PySide would let it go)
157 for dock in self.docks.values():
158 panels.addAction(dock.toggleViewAction()) # (ticked while shown)
159 panels.addSeparator()
160 reset = panels.addAction("Reset Layout")
161 reset.setStatusTip("Put every panel back where it starts: build options and project files on the "
162 "left, the graph and build outputs on the right, the rest at the bottom")
163 reset.triggered.connect(self.reset_layout)
165 PANEL_AREAS = Qt.LeftDockWidgetArea | Qt.RightDockWidgetArea | Qt.BottomDockWidgetArea
167 LAYOUT_VERSION = 2 # (saved layouts of another version aren't restored; 2: no grouped windows)
169 def _make_dock(self, name: str, title: str, widget: QWidget) -> QDockWidget:
170 dock = QDockWidget(title, self)
171 dock.setObjectName(f"panel_{name}") # (how a saved layout knows it)
172 dock.setWidget(widget)
173 dock.setAllowedAreas(self.PANEL_AREAS)
174 dock.setFeatures(QDockWidget.DockWidgetMovable | QDockWidget.DockWidgetFloatable
175 | QDockWidget.DockWidgetClosable)
176 self.docks[name] = dock
177 self._dock_of[widget] = dock
178 if not hasattr(self, "_redock"):
179 self._redock = RedockOnDoubleClick(self)
180 dock.installEventFilter(self._redock) # (see RedockOnDoubleClick)
181 dock.toggleViewAction().toggled.connect(lambda shown, d=dock: shown and self._panel_shown(d))
182 dock.topLevelChanged.connect(lambda floating, d=dock: floating or self._panel_docked(d))
183 return dock
185 def _panel_shown(self, dock: QDockWidget):
186 """View → Panels → a panel: in front of its stack, and on a screen.
187 (Not a panel a layout being restored shows: the one in front of each
188 stack would be the last shown; see raise_panels.)"""
189 self._keep_on_screen(dock)
190 if self._docks_ready and not getattr(self, "_restoring_layout", False):
191 QTimer.singleShot(0, dock, dock.raise_)
193 def restore_layout(self, layout, front: list[str]) -> bool:
194 """Restore a saved layout (QMainWindow.restoreState), with ``front``
195 the panels in front of their stacks (it doesn't keep those)."""
196 self._restoring_layout = True
197 try:
198 restored = self.restoreState(layout, self.LAYOUT_VERSION)
199 finally:
200 self._restoring_layout = False
201 if restored: 201 ↛ 203line 201 didn't jump to line 203 because the condition on line 201 was always true
202 self.raise_panels(front)
203 return restored
205 def _panel_docked(self, dock: QDockWidget):
206 """A floating panel docked again (double-clicking its title): in front
207 of its stack. (Otherwise, on macOS, Qt leaves it shown but not given a
208 place, outside the window, until the next start.)"""
209 if self._docks_ready: 209 ↛ exitline 209 didn't return from function '_panel_docked' because the condition on line 209 was always true
210 QTimer.singleShot(0, dock, dock.raise_)
212 def _keep_on_screen(self, dock: QDockWidget):
213 """A floating panel shown again where no screen is (e.g. on a monitor
214 that's gone): bring it over the window."""
215 if not dock.isFloating():
216 return
217 frame = dock.frameGeometry()
218 if any(screen.availableGeometry().intersects(frame) for screen in QApplication.screens()):
219 return
220 size = dock.size().boundedTo(self.size())
221 dock.resize(size)
222 dock.move(self.geometry().center() - dock.rect().center())
224 def default_layout(self):
225 """Build options above Project files on the left; the dependency graph
226 and Build outputs (stacked) on the right; Build, Tests, Run, Problems,
227 Review, References, Search and Terminal (stacked) at the bottom, Output
228 beside them."""
229 d = self.docks
230 for dock in d.values():
231 self.removeDockWidget(dock)
232 self.addDockWidget(Qt.LeftDockWidgetArea, d["options"])
233 self.splitDockWidget(d["options"], d["files"], Qt.Vertical)
234 self.addDockWidget(Qt.RightDockWidgetArea, d["graph"])
235 self.tabifyDockWidget(d["graph"], d["outputs"])
236 self.addDockWidget(Qt.BottomDockWidgetArea, d["build"])
237 # (Output beside Build first: split next to a stacked panel, it would be stacked too.)
238 self.splitDockWidget(d["build"], d["output"], Qt.Horizontal)
239 for name in ("tests", "run", "debug", "coverage", "complexity", "problems", "review", "references", "search",
240 "git", "terminal"):
241 self.tabifyDockWidget(d["build"], d[name])
242 for dock in d.values():
243 dock.setFloating(False) # (after it's placed: a floating one stays floating otherwise)
244 dock.show()
245 d["graph"].raise_()
246 d["build"].raise_()
247 self._default_sizes()
249 def _default_sizes(self):
250 """The default panels' sizes, for the window's size (applied again once
251 it's shown: before that it isn't its real size)."""
252 d = self.docks
253 self.resizeDocks([d["options"], d["graph"]], [310, max(420, self.width() // 3)], Qt.Horizontal)
254 # Build options as tall as its fields need; Project files gets the rest.
255 options = d["options"].widget().sizeHint().height() + 30
256 self.resizeDocks([d["options"], d["files"]], [options, max(200, self.height() - options)], Qt.Vertical)
257 self.resizeDocks([d["build"]], [max(220, self.height() // 3)], Qt.Vertical)
258 self.resizeDocks([d["build"], d["output"]], [3, 2], Qt.Horizontal)
260 def reset_layout(self):
261 self.snap_closed.expand_all()
262 self.default_layout()
263 self.statusBar().showMessage("Panels back in their places", 3000)
265 def front_panels(self) -> list[str]:
266 """The panels in front of their stacks (by name), for next time: a
267 restored layout doesn't keep them."""
268 return sorted(name for name, dock in self.docks.items()
269 if self.tabifiedDockWidgets(dock) and dock.isVisible() and not dock.visibleRegion().isEmpty())
271 def raise_panels(self, names: list[str]):
272 """Put these panels in front of their stacks: after the panels a
273 restored layout shows have put themselves in front (see _panel_shown)."""
274 docks = [self.docks[name] for name in names if name in self.docks]
275 QTimer.singleShot(0, self, lambda: [dock.raise_() for dock in docks if not dock.isHidden()])
277 def show_panel(self, widget: QWidget):
278 """Show a panel and bring it to the front of its stack."""
279 dock = self._dock_of.get(widget)
280 if dock is not None: 280 ↛ exitline 280 didn't return from function 'show_panel' because the condition on line 280 was always true
281 dock.show()
282 self.snap_closed.expand(dock) # (collapsed by dragging its edge: open again)
283 self._keep_on_screen(dock)
284 dock.raise_()
286 def _content(self, dock: QDockWidget) -> QWidget | None:
287 """A panel's content (collapsed by dragging its edge, the panel shows a grip instead)."""
288 snap = getattr(self, "snap_closed", None)
289 return snap.content(dock) if snap is not None else dock.widget()
291 def panel_in_front(self, widget: QWidget) -> bool:
292 """Whether a panel is shown (and not behind another in its stack)."""
293 dock = self._dock_of.get(widget)
294 return dock is not None and not dock.isHidden() and widget.isVisible()
296 def _update_central(self, *_):
297 """The editor's tabs, or a hint while no file is open."""
298 self.central.setCurrentWidget(self.editor_area if self.tabs.count() else self.no_file_hint)
300 def _terminal_dock_shown(self, shown: bool):
301 # Choosing the Terminal tab: type there straight away (after the click
302 # is done). Not while the window is being built and laid out.
303 if shown and self._docks_ready:
304 QTimer.singleShot(0, self.terminal_panel, self.terminal_panel.focus)
306 def showEvent(self, event):
307 super().showEvent(event)
308 if not self._docks_ready and not getattr(self, "_layout_restored", False):
309 QTimer.singleShot(0, self, self._default_sizes) # (now the window has its size)
310 QTimer.singleShot(0, self, lambda: setattr(self, "_docks_ready", True))
312 def _build_terminal(self):
313 """The Terminal panel."""
314 self.terminal_panel = TerminalPanel()
315 self.terminal_panel.set_font(self._mono_font(editor=True))
316 self.terminal_panel.set_word_wrap(EditorTab.word_wrap)
318 def show_terminal(self):
319 """Show the Terminal panel (starting the shell) and type there."""
320 self.show_panel(self.terminal_panel)
321 self.terminal_panel.start()
322 self.terminal_panel.focus()