0101100101 revised this gist 11 months ago. Go to revision
No changes
0101100101 revised this gist 11 months ago. Go to revision
1 file changed, 0 insertions, 0 deletions
FoldableDockWidget;py renamed to FoldableDockWidget.py
File renamed without changes
0101100101 revised this gist 11 months ago. Go to revision
1 file changed, 290 insertions
FoldableDockWidget;py(file created)
| @@ -0,0 +1,290 @@ | |||
| 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()) | |