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
« 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.
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"""
12from __future__ import annotations
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)
28from .icons import make_icon
30CLOSE_SIZE = 12 # the close glyph (as the tabs' own close buttons)
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)
40class WrappingTabBar(QWidget):
41 """A ``QTabWidget``'s tabs, wrapped onto rows."""
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
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)
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()
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)
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
87 def hasHeightForWidth(self) -> bool:
88 return True
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
96 def sizeHint(self) -> QSize:
97 return QSize(self.width() or 400, self.heightForWidth(self.width() or 400))
99 def minimumSizeHint(self) -> QSize:
100 return QSize(0, self.sizeHint().height())
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)
108 def index_at(self, pos: QPoint) -> int:
109 return next((i for i, rect in enumerate(self._rects) if rect.contains(pos)), -1)
111 def tab_rect(self, index: int) -> QRect:
112 return QRect(self._rects[index]) if 0 <= index < len(self._rects) else QRect()
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
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())
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
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)
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)
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()
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
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)
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)
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)
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)
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)