Coverage for vibefpmgui/wrapping_tabs.py: 84%

178 statements  

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

1"""The editor's tabs on several rows (View → Editor Tabs → Several Rows), 

2instead of one row that scrolls. 

3 

4Qt's tab bar has one row only. `WrappingTabBar` lays the editor's tabs out 

5in as many rows as they need, drawn as Qt draws tabs (the style's own tab 

6shapes), and stands in for the tab bar, which is hidden but stays the 

7tabs' model: the names, icons and tooltips are read from it, and choosing, 

8moving and closing tabs is done through it (so everything else about the 

9tabs works as before). 

10""" 

11 

12from __future__ import annotations 

13 

14from qtpy.QtCore import QEvent, QPoint, QRect, QSize, Qt, QTimer, Signal 

15from qtpy.QtGui import QColor, QFont, QPainter 

16from qtpy.QtWidgets import ( 

17 QApplication, 

18 QSizePolicy, 

19 QStyle, 

20 QStyleOptionTab, 

21 QStylePainter, 

22 QTabBar, 

23 QTabWidget, 

24 QToolTip, 

25 QWidget, 

26) 

27 

28from .icons import make_icon 

29 

30CLOSE_SIZE = 12 # the close glyph (as the tabs' own close buttons) 

31 

32 

33def close_pixmap(ratio: float): 

34 """The close glyph for a screen with this many pixels to a point (2 on a 

35 Retina screen, 1.25 or 1.5 on a scaled Windows one): sharp there, drawn 

36 CLOSE_SIZE points across.""" 

37 return make_icon("close", QColor(140, 140, 140)).pixmap(QSize(CLOSE_SIZE, CLOSE_SIZE), ratio) 

38 

39 

40class WrappingTabBar(QWidget): 

41 """A ``QTabWidget``'s tabs, wrapped onto rows.""" 

42 

43 close_requested = Signal(int) # a tab's close glyph clicked 

44 empty_menu_requested = Signal(QPoint) # a right-click on no tab: where, on the screen 

45 

46 def __init__(self, tabs: QTabWidget, parent=None): 

47 super().__init__(parent) 

48 self.tabs = tabs 

49 self.bar: QTabBar = tabs.tabBar() 

50 self._rects: list[QRect] = [] 

51 self._pressed: int | None = None # the tab pressed (being dragged to another place) 

52 self._press_pos = QPoint() 

53 self._closing: int | None = None # a close glyph pressed 

54 self._hover: int | None = None # (the close glyph under the mouse: highlighted) 

55 self.setMouseTracking(True) 

56 self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) 

57 tabs.currentChanged.connect(self.refresh) 

58 self.bar.tabMoved.connect(self.refresh) 

59 self.bar.installEventFilter(self) # (its font or style changed: the tabs' sizes too) 

60 

61 # ------------------------------------------------------------ layout 

62 def refresh(self, *_): 

63 """The tabs changed (added, removed, renamed, moved, chosen).""" 

64 self._layout(self.width()) 

65 self.updateGeometry() 

66 self.update() 

67 

68 def eventFilter(self, obj, event): 

69 if obj is self.bar and event.type() in (QEvent.FontChange, QEvent.StyleChange): 

70 QTimer.singleShot(0, self, self.refresh) # (once the bar has dropped its old text sizes) 

71 return super().eventFilter(obj, event) 

72 

73 def _layout(self, width: int) -> int: 

74 """Place the tabs in rows ``width`` wide; the height they take.""" 

75 rects, x, y, row_height = [], 0, 0, 0 

76 for index in range(self.bar.count()): 

77 size = self.bar.tabSizeHint(index) 

78 if x and x + size.width() > width: # (no room left on this row: the next) 

79 x, y = 0, y + row_height 

80 row_height = 0 

81 rects.append(QRect(x, y, size.width(), size.height())) 

82 x += size.width() 

83 row_height = max(row_height, size.height()) 

84 self._rects = rects 

85 return y + row_height 

86 

87 def hasHeightForWidth(self) -> bool: 

88 return True 

89 

90 def heightForWidth(self, width: int) -> int: 

91 rects = self._rects 

92 height = self._layout(width) 

93 self._rects = rects # (only asked: the tabs stay where they are) 

94 return height 

95 

96 def sizeHint(self) -> QSize: 

97 return QSize(self.width() or 400, self.heightForWidth(self.width() or 400)) 

98 

99 def minimumSizeHint(self) -> QSize: 

100 return QSize(0, self.sizeHint().height()) 

101 

102 def resizeEvent(self, event): 

103 super().resizeEvent(event) 

104 height = self._layout(event.size().width()) 

105 if height != self.height(): 

106 self.setFixedHeight(height) # (more rows, or fewer: the editor below gets the rest) 

107 

108 def index_at(self, pos: QPoint) -> int: 

109 return next((i for i, rect in enumerate(self._rects) if rect.contains(pos)), -1) 

110 

111 def tab_rect(self, index: int) -> QRect: 

112 return QRect(self._rects[index]) if 0 <= index < len(self._rects) else QRect() 

113 

114 # ------------------------------------------------------------ drawing 

115 def _option(self, index: int) -> QStyleOptionTab: 

116 option = QStyleOptionTab() 

117 self.bar.initStyleOption(option, index) 

118 option.rect = self._rects[index] 

119 row = [i for i, r in enumerate(self._rects) if r.top() == option.rect.top()] 

120 option.position = (QStyleOptionTab.OnlyOneTab if len(row) == 1 else QStyleOptionTab.Beginning 

121 if index == row[0] else QStyleOptionTab.End if index == row[-1] 

122 else QStyleOptionTab.Middle) 

123 option.state |= QStyle.State_Enabled 

124 if index == self.tabs.currentIndex(): 

125 option.state |= QStyle.State_Selected 

126 else: 

127 option.state &= ~QStyle.State_Selected 

128 option.text = self.bar.tabText(index) 

129 return option 

130 

131 def _button_rect(self, element, option: QStyleOptionTab) -> QRect: 

132 """Where the style puts a tab's button (``element``: its left or right 

133 one), for a tab on any row. The macOS style places them from the top 

134 of the bar whatever the tab's own top (one row's always 0): asked for 

135 the tab on the first row, then moved down to its own.""" 

136 top = QStyleOptionTab(option) 

137 top.rect = QRect(option.rect) 

138 top.rect.moveTop(0) 

139 return self.style().subElementRect(element, top, self).translated(0, option.rect.top()) 

140 

141 def _close_rect(self, option: QStyleOptionTab) -> QRect: 

142 area = self._button_rect(QStyle.SE_TabBarTabRightButton, option) 

143 rect = QRect(0, 0, CLOSE_SIZE + 6, CLOSE_SIZE + 6) 

144 rect.moveCenter(area.center()) 

145 return rect 

146 

147 def paintEvent(self, event): 

148 if len(self._rects) != self.bar.count(): 148 ↛ 149line 148 didn't jump to line 149 because the condition on line 148 was never true

149 self._layout(self.width()) 

150 painter = QStylePainter(self) 

151 italic = QFont(self.font()) 

152 italic.setItalic(True) 

153 close = close_pixmap(self.devicePixelRatioF()) 

154 current = self.tabs.currentIndex() 

155 for index in [i for i in range(len(self._rects)) if i != current] + ([current] if current >= 0 else []): 

156 option = self._option(index) # (the current tab last: drawn over its neighbours) 

157 painter.setClipRect(option.rect) # (a style may draw the current tab down into the page: not the next row) 

158 painter.drawControl(QStyle.CE_TabBarTabShape, option) 

159 name = self.bar.tab_name(index) if hasattr(self.bar, "tab_name") else option.text 

160 is_italic = hasattr(self.bar, "italic_name") and self.bar.italic_name(index) is not None 

161 option.text = name 

162 painter.setFont(italic if is_italic else self.font()) 

163 painter.drawControl(QStyle.CE_TabBarTabLabel, option) 

164 icon = self.bar.tabButton(index, QTabBar.LeftSide) 

165 if icon is not None and hasattr(icon, "pixmap") and icon.pixmap() is not None: 165 ↛ 172line 165 didn't jump to line 172 because the condition on line 165 was always true

166 area = self._button_rect(QStyle.SE_TabBarTabLeftButton, option) 

167 pixmap = icon.pixmap() 

168 # (Its size as drawn, not in device pixels: on a Retina screen a 16-point icon is 32 pixels.) 

169 target = QRect(QPoint(0, 0), pixmap.deviceIndependentSize().toSize()) 

170 target.moveCenter(area.center()) 

171 painter.drawPixmap(target, pixmap) 

172 rect = self._close_rect(option) 

173 if index == self._hover: 173 ↛ 174line 173 didn't jump to line 174 because the condition on line 173 was never true

174 painter.setRenderHint(QPainter.Antialiasing) 

175 painter.setPen(Qt.NoPen) 

176 painter.setBrush(QColor(128, 128, 128, 80)) 

177 painter.drawRoundedRect(rect, 4, 4) 

178 glyph = QRect(0, 0, CLOSE_SIZE, CLOSE_SIZE) # (its size as drawn, whatever the screen's pixels) 

179 glyph.moveCenter(rect.center()) 

180 painter.drawPixmap(glyph, close) 

181 painter.setClipping(False) 

182 

183 # ------------------------------------------------------------ the mouse 

184 def _on_close(self, index: int, pos: QPoint) -> bool: 

185 return index >= 0 and self._close_rect(self._option(index)).contains(pos) 

186 

187 def mousePressEvent(self, event): 

188 index = self.index_at(event.position().toPoint()) 

189 if event.button() != Qt.LeftButton or index < 0: 189 ↛ 190line 189 didn't jump to line 190 because the condition on line 189 was never true

190 super().mousePressEvent(event) 

191 return 

192 if self._on_close(index, event.position().toPoint()): 

193 self._closing = index 

194 return 

195 self.tabs.setCurrentIndex(index) 

196 self._pressed, self._press_pos = index, event.position().toPoint() 

197 

198 def mouseMoveEvent(self, event): 

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

200 index = self.index_at(pos) 

201 hover = index if self._on_close(index, pos) else None 

202 if hover != self._hover: 202 ↛ 203line 202 didn't jump to line 203 because the condition on line 202 was never true

203 self._hover = hover 

204 self.update() 

205 if self._pressed is None or not event.buttons() & Qt.LeftButton: 205 ↛ 206line 205 didn't jump to line 206 because the condition on line 205 was never true

206 return 

207 if (pos - self._press_pos).manhattanLength() < QApplication.startDragDistance(): 207 ↛ 208line 207 didn't jump to line 208 because the condition on line 207 was never true

208 return 

209 if index >= 0 and index != self._pressed: # (dragged over another tab: it takes that one's place) 

210 self.bar.moveTab(self._pressed, index) 

211 self._pressed = index 

212 

213 def mouseReleaseEvent(self, event): 

214 closing, self._closing, self._pressed = self._closing, None, None 

215 index = self.index_at(event.position().toPoint()) 

216 if closing is not None and index == closing and self._on_close(index, event.position().toPoint()): 

217 self.close_requested.emit(index) 

218 

219 def mouseDoubleClickEvent(self, event): 

220 index = self.index_at(event.position().toPoint()) 

221 if index >= 0 and not self._on_close(index, event.position().toPoint()): 221 ↛ exitline 221 didn't return from function 'mouseDoubleClickEvent' because the condition on line 221 was always true

222 self.tabs.tabBarDoubleClicked.emit(index) 

223 

224 def contextMenuEvent(self, event): 

225 if self.index_at(event.pos()) < 0: 

226 self.empty_menu_requested.emit(event.globalPos()) 

227 event.accept() 

228 else: 

229 super().contextMenuEvent(event) 

230 

231 def leaveEvent(self, event): 

232 if self._hover is not None: 232 ↛ 233line 232 didn't jump to line 233 because the condition on line 232 was never true

233 self._hover = None 

234 self.update() 

235 super().leaveEvent(event) 

236 

237 def event(self, event): 

238 if event.type() == event.Type.ToolTip: 238 ↛ 239line 238 didn't jump to line 239 because the condition on line 238 was never true

239 pos = event.pos() 

240 index = self.index_at(pos) 

241 if index < 0: 

242 QToolTip.hideText() 

243 elif self._on_close(index, pos): 

244 button = self.bar.tabButton(index, QTabBar.RightSide) 

245 QToolTip.showText(event.globalPos(), button.toolTip() if button is not None else "Close", self) 

246 else: 

247 QToolTip.showText(event.globalPos(), self.bar.tabToolTip(index), self) 

248 return True 

249 return super().event(event)