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