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

1"""The panels: docks, their layout and the editor in the middle. 

2 

3Every panel but the editor is a dock (moved, stacked, floated, closed 

4from View → Panels); `RedockOnDoubleClick` docks a floating one back. 

5""" 

6 

7from __future__ import annotations 

8 

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) 

20 

21from ..dock_snap import SnapClosed 

22from ..editor import EditorTab 

23from ..terminal import TerminalPanel 

24 

25 

26class RedockOnDoubleClick(QObject): 

27 """Double-clicking a floating panel's title docks it back, once the 

28 mouse button is up. 

29 

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.""" 

38 

39 POLL_MS = 30 

40 MAX_POLLS = 100 # (3 s: if the button is never seen up, dock anyway) 

41 

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) 

49 

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 

63 

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

93 

94 

95class PanelsMixin: 

96 """The panels and their layout (a part of `vibefpmgui.main_window.MainWindow`).""" 

97 

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) 

110 

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) 

164 

165 PANEL_AREAS = Qt.LeftDockWidgetArea | Qt.RightDockWidgetArea | Qt.BottomDockWidgetArea 

166 

167 LAYOUT_VERSION = 2 # (saved layouts of another version aren't restored; 2: no grouped windows) 

168 

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 

184 

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

192 

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 

204 

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

211 

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

223 

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

248 

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) 

259 

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) 

264 

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

270 

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

276 

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

285 

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

290 

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

295 

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) 

299 

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) 

305 

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

311 

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) 

317 

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