FoldableDockWidget.py
· 13 KiB · Python
Raw
# todo when creating the window, need to be able to set whether can resize height or not? e.g. image / chart
# todo why is there a margin between bottom and top of widgets?
# todo the QLabel should have a width that spans to multilines where necessary instead of just increasing width
import sys
from random import randint
from PySide6.QtCore import QSize, Qt
from PySide6.QtGui import QIcon, QMouseEvent
from PySide6.QtWidgets import QDockWidget, QWidget, QToolButton, QStyle, QHBoxLayout, QStyleFactory, QSizePolicy, \
QApplication, QVBoxLayout, QLabel, QSlider, QMainWindow, QSizeGrip
class FoldableDockWidget(QDockWidget):
"""
A simple Qt Widget that adds a 'minimise' button that vertically reduces the dock widget to just the titlebar to
allow the dock widget to still take up minimal screen real estate
"""
class TitleBarWidget(QWidget):
def __init__(self, title:str, parent:QWidget=None):
"""
We create a custom title bar using QWidget as the base. The title bar has to be wrapped in another widget
so that its background can be styled easier, otherwise it's impossible to style
:param title: the title to appear in the title bar
"""
super().__init__(parent)
# set the default icons
self._init_titlebar_icons()
# set up local properties for the custom title bar
self.title_label = QLabel(title)
# button for floating the dock widget
self.float_button = QToolButton()
# button for "folding" the dock widget into just the title bar
self.minimize_button = QToolButton()
# button for closing the dock widget
self.close_button = QToolButton()
self.float_button.setIcon(self.icon_float)
self.minimize_button.setIcon(self.icon_minimize)
self.close_button.setIcon(self.icon_close)
# set fixed sizes for the icons
self.float_button.setIconSize(QSize(12, 12))
self.minimize_button.setIconSize(QSize(12, 12))
self.close_button.setIconSize(QSize(12, 12))
# we define the widget inner layout
titlebar_layout = QHBoxLayout()
titlebar_layout.setContentsMargins(1, 1, 1, 1) # L,T,R,B
titlebar_layout.setSpacing(0)
titlebar_layout.addWidget(self.title_label)
titlebar_layout.addWidget(self.float_button)
titlebar_layout.addWidget(self.minimize_button)
titlebar_layout.addWidget(self.close_button)
# define the inner widget
titlebar_widget_inner = QWidget(self) # the inner widget that houses the buttons
titlebar_widget_inner.setObjectName("titlebar") # use this #id to actually target the inner widget when styling
titlebar_widget_inner.setLayout(titlebar_layout)
# assign the inner widget to the layout of this class
wrapper_layout = QVBoxLayout()
wrapper_layout.addWidget(titlebar_widget_inner)
wrapper_layout.setSpacing(0)
wrapper_layout.setContentsMargins(0, 0, 0, 0)
self.setLayout(wrapper_layout)
def _init_titlebar_icons(self):
"""
this can be overridden to custom style the title bar of the foldable dock widget
"""
style = QStyleFactory.create("Fusion")
self.icon_minimize = style.standardIcon(QStyle.StandardPixmap.SP_TitleBarMinButton) # minimise icon
self.icon_restore = QIcon.fromTheme('view-restore') # restore after minimise icon
self.icon_dock = style.standardIcon(QStyle.StandardPixmap.SP_TitleBarShadeButton) # dock icon
self.icon_float = style.standardIcon(QStyle.StandardPixmap.SP_TitleBarUnshadeButton) # float icon
self.icon_close = style.standardIcon(QStyle.StandardPixmap.SP_DockWidgetCloseButton) # close the icon
def _set_float_icon(self, is_floating: bool):
""" set the float icon depending on the status of the parent dock widget """
if is_floating:
self.float_button.setIcon(self.icon_dock)
else:
self.float_button.setIcon(self.icon_float)
def _set_minimize_icon(self, is_minimized: bool):
""" set the minimised icon depending on the status of the parent dock widget """
if is_minimized:
self.minimize_button.setIcon(self.icon_restore) # SP_TitleBarRestoreButton))
else:
self.minimize_button.setIcon(self.icon_minimize)
def __init__(self, title:str, minimize_on_titlebar_double_click=True, show_size_grip=True, parent=None):
"""
Initialise a Foldable Dock Widget
:param title: The title of the dock widget
:param minimize_on_titlebar_double_click: when the titlebar is double-clicked, do we want to toggle the minimise/fold status or toggle float/docked
:param parent: The parent widget
"""
super().__init__(parent)
self.minimize_on_double_click = minimize_on_titlebar_double_click
self.setObjectName('foldable-dock-widget')
self.setWindowTitle(title)
self.title_bar = FoldableDockWidget.TitleBarWidget(title)
self.setTitleBarWidget(self.title_bar)
# a dirty hack to tell the evant listeners to ignore the visibility change as we're changing it manually
# when the user drags the dock widget from the dock area to elsewhere, this will fire, but will also fire when
# toggled between float/docked so we use this to
self.ignore_visibility_change = False
# is the widget "folded" or not?
self.is_minimized = False
self.show_size_grip = show_size_grip
# event listener to set icon when user drags the parent widget away from the docked region (floating) or back into the docked region
self.visibilityChanged.connect(self._on_visibility_change)
# handle the closing of the widget
self.title_bar.close_button.clicked.connect(lambda event: self.close()) # close the docked widget
# handle the double click of the titlebar using the mode type defined in this constructor
self.title_bar.mouseDoubleClickEvent = lambda event: self._handle_mouse_double_click()
# set up events to handle minimising / restoring and dock/undock on relevant button clicks
self.title_bar.float_button.clicked.connect(self._toggle_floating) # float / dock as necessary
self.title_bar.minimize_button.clicked.connect(self._toggle_minimize) # minimise / restore as necessary
def setWidget(self, widget:QWidget):
"""
We need to set a custom size policy on the inside widget that's set as the body so that it can be folded and expanded as necessary
:param widget:
"""
super().setWidget(widget)
widget.setObjectName('central-widget')
# todo some widgets should be expanding and others not - so I think this size policy would change depending on the flags set
sp = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
widget.setSizePolicy(sp)
# todo document that this helps resize much easier than default resizing where you have to place the cursor precisely
if self.show_size_grip:
self.size_grip = QSizeGrip(self)
widget.layout().addWidget(self.size_grip, alignment=Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignRight)
# we style the whole widget only after the content of the widget has been assinged
self._set_styles()
def _handle_mouse_double_click(self):
"""
we listen to double-clicks on the title bar widget and depending on how the widget has been defined, we toggle
either fold/expanded or float/docked
"""
if self.minimize_on_double_click:
self._toggle_minimize()
else:
self._toggle_floating()
def _on_visibility_change(self):
""" triggered when the user drags the widget from the dock area to elsewhere, or vice versa """
self.title_bar._set_float_icon(not self.isFloating()) # we need to inverse because this fires before the widget has had its float/dock status changed
self.title_bar._set_minimize_icon(self.is_minimized)
def _toggle_floating(self):
""" float / dock the widget as necessary and toggle the floating/docked icon as necessary """
if self.ignore_visibility_change:
return
self.ignore_visibility_change = True
is_floating = self.isFloating()
self.setFloating(not is_floating) # toggle the float/docked status
self.title_bar._set_float_icon(is_floating) # set relevant icon
self.ignore_visibility_change = False
def _toggle_minimize(self):
if not self.is_minimized:
# "fold" the widget
current_width = self.widget().width()
self.widget().hide()
self.setMinimumWidth(current_width)
# self.widget().resize(current_width, 0) # todo nope, this didn't work but don't understand why not!
else:
# to expand the widget
self.resize(10, 10) # this resizes to the minimum size needed
self.widget().show()
self.title_bar._set_minimize_icon(self.is_minimized)
# invert the flag
self.is_minimized = not self.is_minimized
def _set_styles(self):
"""
Set default styles to the foldable dock widget
"""
# todo document that setting border: none on the base widget removes the ability to resize
self.setStyleSheet("""
FoldableDockWidget {
/* sets the background of the title bar widget and you also need to explicitly set the title bar background colour */
background-color: #aaa;
border: 2px solid #333;
border-bottom: 3px solid #222;
}
FoldableDockWidget #titlebar {
background-color: #ccc;
color: #d8dee9;
}
FoldableDockWidget #titlebar QLabel{
color: #333;
margin-right: 10px;
/*text-transform: uppercase;*/
font-weight:bold;
}
/*
FoldableDockWidget #titlebar QPushButton {
border: 1px solid red;
padding:0;
}
FoldableDockWidget #titlebar QPushButton::hover {
border: 1px solid red;
background-color: #eee;
}*/
FoldableDockWidget #central-widget {
background-color: #fff;
border: 1px solid #aaa;
margin:0;
}
""")
# ----- sample implementation code below ----------
if __name__ == "__main__":
class MainWindow(QMainWindow):
def __init__(self):
super(MainWindow, self).__init__()
self.dock_widget1 = self._create_dock_widget('really long dock widget 1', 'label for dock widget 1')
self.dock_widget2 = self._create_dock_widget('dock widget 2', 'label for dock widget 2')
self.dock_widget3 = self._create_dock_widget('dock widget 3', 'label for dock widget 3')
self.dock_widget4 = self._create_dock_widget('dock widget 4', 'label for dock widget 4')
self.dock_widget5 = self._create_dock_widget('dock widget 5', 'label for dock widget 5')
self.setCentralWidget(QWidget()) # set a dummy widget as the central widget so things can be docked properly
self.resize(800,800)
self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, self.dock_widget1)
self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, self.dock_widget2)
self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, self.dock_widget3)
self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, self.dock_widget4)
self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, self.dock_widget5)
def _create_dock_widget(self, title, text):
dw = FoldableDockWidget(title=f'{title}', minimize_on_titlebar_double_click=True, show_size_grip=True, parent=self)
w = QWidget() # layout widget
num_sliders = randint(2,5)
layout = QVBoxLayout()
layout.addWidget(QLabel(text))
for j in range(num_sliders):
layout.addWidget(QLabel(f'Slider {j}'))
layout.addWidget(QSlider(Qt.Horizontal))
w.setLayout(layout) # assign layout to widget
dw.setWidget(w) # assign widget with sliders and labels to dock widget
return dw
# -----
app = QApplication(sys.argv)
window = MainWindow()
window.show()
sys.exit(app.exec())
1 | # todo when creating the window, need to be able to set whether can resize height or not? e.g. image / chart |
2 | # todo why is there a margin between bottom and top of widgets? |
3 | # todo the QLabel should have a width that spans to multilines where necessary instead of just increasing width |
4 | |
5 | import sys |
6 | from random import randint |
7 | |
8 | from PySide6.QtCore import QSize, Qt |
9 | from PySide6.QtGui import QIcon, QMouseEvent |
10 | from PySide6.QtWidgets import QDockWidget, QWidget, QToolButton, QStyle, QHBoxLayout, QStyleFactory, QSizePolicy, \ |
11 | QApplication, QVBoxLayout, QLabel, QSlider, QMainWindow, QSizeGrip |
12 | |
13 | |
14 | class FoldableDockWidget(QDockWidget): |
15 | """ |
16 | A simple Qt Widget that adds a 'minimise' button that vertically reduces the dock widget to just the titlebar to |
17 | allow the dock widget to still take up minimal screen real estate |
18 | """ |
19 | |
20 | class TitleBarWidget(QWidget): |
21 | def __init__(self, title:str, parent:QWidget=None): |
22 | """ |
23 | We create a custom title bar using QWidget as the base. The title bar has to be wrapped in another widget |
24 | so that its background can be styled easier, otherwise it's impossible to style |
25 | :param title: the title to appear in the title bar |
26 | """ |
27 | |
28 | super().__init__(parent) |
29 | |
30 | # set the default icons |
31 | self._init_titlebar_icons() |
32 | |
33 | # set up local properties for the custom title bar |
34 | self.title_label = QLabel(title) |
35 | |
36 | # button for floating the dock widget |
37 | self.float_button = QToolButton() |
38 | |
39 | # button for "folding" the dock widget into just the title bar |
40 | self.minimize_button = QToolButton() |
41 | |
42 | # button for closing the dock widget |
43 | self.close_button = QToolButton() |
44 | |
45 | self.float_button.setIcon(self.icon_float) |
46 | self.minimize_button.setIcon(self.icon_minimize) |
47 | self.close_button.setIcon(self.icon_close) |
48 | |
49 | # set fixed sizes for the icons |
50 | self.float_button.setIconSize(QSize(12, 12)) |
51 | self.minimize_button.setIconSize(QSize(12, 12)) |
52 | self.close_button.setIconSize(QSize(12, 12)) |
53 | |
54 | # we define the widget inner layout |
55 | titlebar_layout = QHBoxLayout() |
56 | titlebar_layout.setContentsMargins(1, 1, 1, 1) # L,T,R,B |
57 | titlebar_layout.setSpacing(0) |
58 | titlebar_layout.addWidget(self.title_label) |
59 | titlebar_layout.addWidget(self.float_button) |
60 | titlebar_layout.addWidget(self.minimize_button) |
61 | titlebar_layout.addWidget(self.close_button) |
62 | |
63 | # define the inner widget |
64 | titlebar_widget_inner = QWidget(self) # the inner widget that houses the buttons |
65 | titlebar_widget_inner.setObjectName("titlebar") # use this #id to actually target the inner widget when styling |
66 | titlebar_widget_inner.setLayout(titlebar_layout) |
67 | |
68 | # assign the inner widget to the layout of this class |
69 | wrapper_layout = QVBoxLayout() |
70 | wrapper_layout.addWidget(titlebar_widget_inner) |
71 | wrapper_layout.setSpacing(0) |
72 | wrapper_layout.setContentsMargins(0, 0, 0, 0) |
73 | self.setLayout(wrapper_layout) |
74 | |
75 | def _init_titlebar_icons(self): |
76 | """ |
77 | this can be overridden to custom style the title bar of the foldable dock widget |
78 | """ |
79 | style = QStyleFactory.create("Fusion") |
80 | self.icon_minimize = style.standardIcon(QStyle.StandardPixmap.SP_TitleBarMinButton) # minimise icon |
81 | self.icon_restore = QIcon.fromTheme('view-restore') # restore after minimise icon |
82 | self.icon_dock = style.standardIcon(QStyle.StandardPixmap.SP_TitleBarShadeButton) # dock icon |
83 | self.icon_float = style.standardIcon(QStyle.StandardPixmap.SP_TitleBarUnshadeButton) # float icon |
84 | self.icon_close = style.standardIcon(QStyle.StandardPixmap.SP_DockWidgetCloseButton) # close the icon |
85 | |
86 | def _set_float_icon(self, is_floating: bool): |
87 | """ set the float icon depending on the status of the parent dock widget """ |
88 | if is_floating: |
89 | self.float_button.setIcon(self.icon_dock) |
90 | else: |
91 | self.float_button.setIcon(self.icon_float) |
92 | |
93 | def _set_minimize_icon(self, is_minimized: bool): |
94 | """ set the minimised icon depending on the status of the parent dock widget """ |
95 | if is_minimized: |
96 | self.minimize_button.setIcon(self.icon_restore) # SP_TitleBarRestoreButton)) |
97 | else: |
98 | self.minimize_button.setIcon(self.icon_minimize) |
99 | |
100 | |
101 | def __init__(self, title:str, minimize_on_titlebar_double_click=True, show_size_grip=True, parent=None): |
102 | """ |
103 | Initialise a Foldable Dock Widget |
104 | |
105 | :param title: The title of the dock widget |
106 | :param minimize_on_titlebar_double_click: when the titlebar is double-clicked, do we want to toggle the minimise/fold status or toggle float/docked |
107 | :param parent: The parent widget |
108 | """ |
109 | super().__init__(parent) |
110 | |
111 | self.minimize_on_double_click = minimize_on_titlebar_double_click |
112 | |
113 | self.setObjectName('foldable-dock-widget') |
114 | self.setWindowTitle(title) |
115 | |
116 | self.title_bar = FoldableDockWidget.TitleBarWidget(title) |
117 | self.setTitleBarWidget(self.title_bar) |
118 | |
119 | # a dirty hack to tell the evant listeners to ignore the visibility change as we're changing it manually |
120 | # when the user drags the dock widget from the dock area to elsewhere, this will fire, but will also fire when |
121 | # toggled between float/docked so we use this to |
122 | self.ignore_visibility_change = False |
123 | |
124 | # is the widget "folded" or not? |
125 | self.is_minimized = False |
126 | |
127 | self.show_size_grip = show_size_grip |
128 | |
129 | # event listener to set icon when user drags the parent widget away from the docked region (floating) or back into the docked region |
130 | self.visibilityChanged.connect(self._on_visibility_change) |
131 | |
132 | # handle the closing of the widget |
133 | self.title_bar.close_button.clicked.connect(lambda event: self.close()) # close the docked widget |
134 | |
135 | # handle the double click of the titlebar using the mode type defined in this constructor |
136 | self.title_bar.mouseDoubleClickEvent = lambda event: self._handle_mouse_double_click() |
137 | |
138 | # set up events to handle minimising / restoring and dock/undock on relevant button clicks |
139 | self.title_bar.float_button.clicked.connect(self._toggle_floating) # float / dock as necessary |
140 | self.title_bar.minimize_button.clicked.connect(self._toggle_minimize) # minimise / restore as necessary |
141 | |
142 | def setWidget(self, widget:QWidget): |
143 | """ |
144 | We need to set a custom size policy on the inside widget that's set as the body so that it can be folded and expanded as necessary |
145 | :param widget: |
146 | """ |
147 | |
148 | super().setWidget(widget) |
149 | widget.setObjectName('central-widget') |
150 | |
151 | # todo some widgets should be expanding and others not - so I think this size policy would change depending on the flags set |
152 | sp = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) |
153 | widget.setSizePolicy(sp) |
154 | |
155 | # todo document that this helps resize much easier than default resizing where you have to place the cursor precisely |
156 | if self.show_size_grip: |
157 | self.size_grip = QSizeGrip(self) |
158 | widget.layout().addWidget(self.size_grip, alignment=Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignRight) |
159 | |
160 | # we style the whole widget only after the content of the widget has been assinged |
161 | self._set_styles() |
162 | |
163 | def _handle_mouse_double_click(self): |
164 | """ |
165 | we listen to double-clicks on the title bar widget and depending on how the widget has been defined, we toggle |
166 | either fold/expanded or float/docked |
167 | """ |
168 | if self.minimize_on_double_click: |
169 | self._toggle_minimize() |
170 | else: |
171 | self._toggle_floating() |
172 | |
173 | def _on_visibility_change(self): |
174 | """ triggered when the user drags the widget from the dock area to elsewhere, or vice versa """ |
175 | self.title_bar._set_float_icon(not self.isFloating()) # we need to inverse because this fires before the widget has had its float/dock status changed |
176 | self.title_bar._set_minimize_icon(self.is_minimized) |
177 | |
178 | def _toggle_floating(self): |
179 | """ float / dock the widget as necessary and toggle the floating/docked icon as necessary """ |
180 | |
181 | if self.ignore_visibility_change: |
182 | return |
183 | |
184 | self.ignore_visibility_change = True |
185 | |
186 | is_floating = self.isFloating() |
187 | self.setFloating(not is_floating) # toggle the float/docked status |
188 | self.title_bar._set_float_icon(is_floating) # set relevant icon |
189 | self.ignore_visibility_change = False |
190 | |
191 | def _toggle_minimize(self): |
192 | if not self.is_minimized: |
193 | # "fold" the widget |
194 | current_width = self.widget().width() |
195 | self.widget().hide() |
196 | self.setMinimumWidth(current_width) |
197 | # self.widget().resize(current_width, 0) # todo nope, this didn't work but don't understand why not! |
198 | else: |
199 | # to expand the widget |
200 | self.resize(10, 10) # this resizes to the minimum size needed |
201 | self.widget().show() |
202 | |
203 | self.title_bar._set_minimize_icon(self.is_minimized) |
204 | |
205 | # invert the flag |
206 | self.is_minimized = not self.is_minimized |
207 | |
208 | def _set_styles(self): |
209 | """ |
210 | Set default styles to the foldable dock widget |
211 | """ |
212 | |
213 | # todo document that setting border: none on the base widget removes the ability to resize |
214 | self.setStyleSheet(""" |
215 | FoldableDockWidget { |
216 | /* sets the background of the title bar widget and you also need to explicitly set the title bar background colour */ |
217 | background-color: #aaa; |
218 | border: 2px solid #333; |
219 | border-bottom: 3px solid #222; |
220 | } |
221 | FoldableDockWidget #titlebar { |
222 | background-color: #ccc; |
223 | color: #d8dee9; |
224 | } |
225 | FoldableDockWidget #titlebar QLabel{ |
226 | color: #333; |
227 | margin-right: 10px; |
228 | /*text-transform: uppercase;*/ |
229 | font-weight:bold; |
230 | } |
231 | /* |
232 | FoldableDockWidget #titlebar QPushButton { |
233 | border: 1px solid red; |
234 | padding:0; |
235 | } |
236 | FoldableDockWidget #titlebar QPushButton::hover { |
237 | border: 1px solid red; |
238 | background-color: #eee; |
239 | }*/ |
240 | FoldableDockWidget #central-widget { |
241 | background-color: #fff; |
242 | border: 1px solid #aaa; |
243 | margin:0; |
244 | } |
245 | """) |
246 | |
247 | |
248 | # ----- sample implementation code below ---------- |
249 | if __name__ == "__main__": |
250 | |
251 | class MainWindow(QMainWindow): |
252 | def __init__(self): |
253 | super(MainWindow, self).__init__() |
254 | |
255 | self.dock_widget1 = self._create_dock_widget('really long dock widget 1', 'label for dock widget 1') |
256 | self.dock_widget2 = self._create_dock_widget('dock widget 2', 'label for dock widget 2') |
257 | self.dock_widget3 = self._create_dock_widget('dock widget 3', 'label for dock widget 3') |
258 | self.dock_widget4 = self._create_dock_widget('dock widget 4', 'label for dock widget 4') |
259 | self.dock_widget5 = self._create_dock_widget('dock widget 5', 'label for dock widget 5') |
260 | |
261 | self.setCentralWidget(QWidget()) # set a dummy widget as the central widget so things can be docked properly |
262 | self.resize(800,800) |
263 | |
264 | self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, self.dock_widget1) |
265 | self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, self.dock_widget2) |
266 | self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, self.dock_widget3) |
267 | self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, self.dock_widget4) |
268 | self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, self.dock_widget5) |
269 | |
270 | def _create_dock_widget(self, title, text): |
271 | dw = FoldableDockWidget(title=f'{title}', minimize_on_titlebar_double_click=True, show_size_grip=True, parent=self) |
272 | w = QWidget() # layout widget |
273 | |
274 | num_sliders = randint(2,5) |
275 | |
276 | layout = QVBoxLayout() |
277 | layout.addWidget(QLabel(text)) |
278 | for j in range(num_sliders): |
279 | layout.addWidget(QLabel(f'Slider {j}')) |
280 | layout.addWidget(QSlider(Qt.Horizontal)) |
281 | |
282 | w.setLayout(layout) # assign layout to widget |
283 | dw.setWidget(w) # assign widget with sliders and labels to dock widget |
284 | |
285 | return dw |
286 | # ----- |
287 | app = QApplication(sys.argv) |
288 | window = MainWindow() |
289 | window.show() |
290 | sys.exit(app.exec()) |
291 |