Pyside - Vertical Tabset with QSplitter

06/01/2025, Sun
Categories: #python
Tags: #pyside

Icon Sidebar as Tabs

A common UI paradigm for grouping access of different functionalities to different portions of a GUI application is use a tabset with tabs that are situated in either the left or vertical portion of the screen.

pyside vertical tabset with qsplitter

Here is code for a basic tab vertical tabset orientated on the left side.

import sys
from PySide6.QtWidgets import (
    QApplication,
    QTabWidget,
    QWidget,
    QHBoxLayout,
    QLabel,
    QTextEdit,
    QSplitter
)
from PySide6.QtGui import QIcon
from PySide6 import QtWidgets, QtGui
from PySide6.QtCore import QSize, Qt

class Window(QWidget):
    def __init__(self):
        super().__init__()

        # Tabset - Tabs and tab Contents
        tab_widget = QTabWidget()

        # The tabs of the tabset is going to be in vertical layout,
        tab_widget.setTabPosition(QTabWidget.TabPosition.West)
        self.tab_widget = tab_widget

        # Get the current palette color for inactive tabs
        app = QtWidgets.QApplication.instance()
        palette = app.palette()

        # Inactive background color
        base_color = palette.color(QtGui.QPalette.Base)
        bg_rgb_color = f"rgb({base_color.red()}, {base_color.green()}, {base_color.blue()})"

        # Inactive border color
        shadow_color = palette.shadow().color()
        shadow_rgb_color = (
            f"rgb({shadow_color.red()}, {shadow_color.green()}, {shadow_color.blue()})"
        )

        # Add stylesheet for high-contrast active tab
        self.tab_widget.setStyleSheet(
            f"""
            QTabBar::tab {{
                background: {bg_rgb_color};
                padding: 0px 8px 10px 8px;
                border: 1px solid transparent;
                border-color: {shadow_rgb_color};
                margin: 0px;
                border-radius: 2px;
                border-right: none;
                border-bottom-right-radius: 0px;
                border-top-right-radius: 0px;
            }}

            QTabBar::tab:selected, QTabBar::tab:focus {{
                background:
                    qlineargradient(x1:0, y1:0, x2:1, y2:0,
                        stop:0 #00c416, stop:1 #00ffb2);
                border: 1px solid #222;
                border-right: none;
            }}
        """
        )

        # Tabset data which includes the icon name and tab contents
        tab_info = [
            {"icon": QIcon.fromTheme("document-open"),
             "text": "First tab contents"},
            {"icon": QIcon.fromTheme("document-new"),
             "text": "Second tab contents"},
        ]

        # Track all splitters to update the widths of the left
        # and right panels based on shared global width values
        self.splitters = []

        # Create tabs and tab contents from above data
        for tab in tab_info:
            icon = tab["icon"]
            text = tab["text"]

            tab = QWidget()
            layout = QHBoxLayout(tab)
            splitter = QSplitter(Qt.Orientation.Horizontal)

            label = QLabel(text)
            label.setWordWrap(True)

            # Include a qsplitter as the tab contents,
            # which replicated the traditional resizable/collapsible
            # left panel view
            splitter.addWidget(label)
            splitter.addWidget(QTextEdit())
            layout.addWidget(splitter)

            tab_widget.addTab(tab, icon, "")

            self.splitters.append(splitter)

        # Increase the tab icon size for better visibility
        tab_widget.setIconSize(QSize(30, 30))

        # Provide default width values for the splitter widths
        # for consisttency
        self.left_splitter_width = 200
        self.right_splitter_width = 300

        layout = QHBoxLayout(self)
        layout.addWidget(tab_widget)
        self.resize(800, 600)

        # Move window to bottom right of the screen
        screen_geometry = app.primaryScreen().geometry()
        x = screen_geometry.right() - self.width()
        y = screen_geometry.bottom() - self.height()
        self.move(x, y)


if __name__ == "__main__":
    app = QApplication(sys.argv)
    window = Window()
    window.show()
    sys.exit(app.exec())

For a vertical tabset, we use a horizontal QHBoxLayout layout. The tabs stay on the left side while the tab's content is on the right. Usually, the left tabs have a group of functionalities nested underneath each tab and this is expressed through have a "middle" panel that groups the sub categories of features that belong to the left tab. In the screenshot, you can see the middle panel as the area which has the text of "First tab contents". This middle panel can also further subdivide content using such things as collapsible containers, or it could simply be a listing of another widget or UI element type. The middle panel is resizable to make room for the contents on the right of the middle panel. This area will be the viewing of the actual content. The middle and right panels are created using the QSplitter widget, and we want to make sure that the middle panel's width is the same as we navigate between the tabs.

Currently, this basic example does not exhibit the behavior of syncing the widths of the middle panel, but we will want to achieve this to create the illusion that the middle panel is the same layout in the lifetime of using this application. The general rule is to track the middle panel when the QSplitter resize handle was dragged which registers the width change. This width value will ultimately get replicated to all the other tabs' middle panel.

The two functions below serve the primary function of tracking the width of the middle panel and updating the widths of the middle panel of the newly switched tab.

import sys
from PySide6.QtWidgets import (
    QApplication,
    QTabWidget,
    QWidget,
    QHBoxLayout,
    QLabel,
    QTextEdit,
    QSplitter
)
from PySide6.QtGui import QIcon
from PySide6 import QtWidgets, QtGui
from PySide6.QtCore import QSize, Qt, QTimer

class Window(QWidget):

    # ...

    # Record the width of the splitter left and right panel widths
    # on tab changes
    def on_tab_change(self, index):
        current_splitter = self.splitters[index]
        current_splitter.setSizes([
            self.left_splitter_width, self.right_splitter_width])

    # Update stored widths when any splitter is moved
    def on_splitter_moved(self, splitter, _pos, _index):
        widths = splitter.sizes()
        self.left_splitter_width = widths[0]
        self.right_splitter_width = widths[1]

It might appear that the methods from above is all we need, but it is also required to record the middle panel width on initial application window load.

We need to track the initial width because the middle panel on the initial tab might not have been resized before switching to another tab. If the user switches to the second tab in this scenario, the width of the middle panel most likely wouldn't align to the first initial tab.

  # ...

        # Move window to bottom right of the screen
        screen_geometry = app.primaryScreen().geometry()
        x = screen_geometry.right() - self.width()
        y = screen_geometry.bottom() - self.height()
        self.move(x, y)

        # Set initial splitter sizes after all the elements are on display,
        # such that the first tab activated other than the initially
        # active tab's qsplitter width will be given a width value more
        # quickly to prevent a lag in the left panel width set
        QTimer.singleShot(0, self.set_initial_splitter_sizes)

    # Set sizes for the initially visible tab's splitter
    def set_initial_splitter_sizes(self):
        current_index = self.tab_widget.currentIndex()
        current_splitter = self.splitters[current_index]
        current_splitter.setSizes([
            self.left_splitter_width, self.right_splitter_width])

The completed example is shown below

import sys
from PySide6.QtWidgets import (
    QApplication,
    QTabWidget,
    QWidget,
    QHBoxLayout,
    QLabel,
    QTextEdit,
    QSplitter
)
from PySide6.QtGui import QIcon
from PySide6 import QtWidgets, QtGui
from PySide6.QtCore import QSize, Qt, QTimer

class Window(QWidget):
    def __init__(self):
        super().__init__()

        # Tabset - Tabs and tab Contents
        tab_widget = QTabWidget()

        # The tabs of the tabset is going to be in vertical layout,
        tab_widget.setTabPosition(QTabWidget.TabPosition.West)
        self.tab_widget = tab_widget

        # Get the current palette color for inactive tabs
        app = QtWidgets.QApplication.instance()
        palette = app.palette()

        # Inactive background color
        base_color = palette.color(QtGui.QPalette.Base)
        bg_rgb_color = f"rgb({base_color.red()}, {base_color.green()}, {base_color.blue()})"

        # Inactive border color
        shadow_color = palette.shadow().color()
        shadow_rgb_color = (
            f"rgb({shadow_color.red()}, {shadow_color.green()}, {shadow_color.blue()})"
        )

        # Add stylesheet for high-contrast active tab
        self.tab_widget.setStyleSheet(
            f"""
            QTabBar::tab {{
                background: {bg_rgb_color};
                padding: 0px 8px 10px 8px;
                border: 1px solid transparent;
                border-color: {shadow_rgb_color};
                margin: 0px;
                border-radius: 2px;
                border-right: none;
                border-bottom-right-radius: 0px;
                border-top-right-radius: 0px;
            }}

            QTabBar::tab:selected, QTabBar::tab:focus {{
                background:
                    qlineargradient(x1:0, y1:0, x2:1, y2:0,
                        stop:0 #00c416, stop:1 #00ffb2);
                border: 1px solid #222;
                border-right: none;
            }}
        """
        )

        # Tabset data which includes the icon name and tab contents
        tab_info = [
            {"icon": QIcon.fromTheme("document-open"),
             "text": "First tab contents"},
            {"icon": QIcon.fromTheme("document-new"),
             "text": "Second tab contents"},
        ]

        # Track all splitters to update the widths of the left
        # and right panels based on shared global width values
        self.splitters = []

        # Create tabs and tab contents from above data
        for tab in tab_info:
            icon = tab["icon"]
            text = tab["text"]

            tab = QWidget()
            layout = QHBoxLayout(tab)
            splitter = QSplitter(Qt.Orientation.Horizontal)

            label = QLabel(text)
            label.setWordWrap(True)

            # Include a qsplitter as the tab contents,
            # which replicated the traditional resizable/collapsible
            # left panel view
            splitter.addWidget(label)
            splitter.addWidget(QTextEdit())
            splitter.splitterMoved.connect(
                lambda pos, index,
                s=splitter: self.on_splitter_moved(s, pos, index)
            )
            layout.addWidget(splitter)

            tab_widget.addTab(tab, icon, "")

            self.splitters.append(splitter)

        # Increase the tab icon size for better visibility
        tab_widget.setIconSize(QSize(30, 30))

        # Track tab changes to adjust the widths of splitters
        self.tab_widget.currentChanged.connect(self.on_tab_change)

        # Provide default width values for the splitter widths
        # for consisttency
        self.left_splitter_width = 200
        self.right_splitter_width = 300

        layout = QHBoxLayout(self)
        layout.addWidget(tab_widget)
        self.resize(800, 600)

        # Move window to bottom right of the screen
        screen_geometry = app.primaryScreen().geometry()
        x = screen_geometry.right() - self.width()
        y = screen_geometry.bottom() - self.height()
        self.move(x, y)

        # Set initial splitter sizes after all the elements are on display,
        # such that the first tab activated other than the initially
        # active tab's qsplitter width will be given a width value more
        # quickly to prevent a lag in the left panel width set
        QTimer.singleShot(0, self.set_initial_splitter_sizes)

    # Set sizes for the initially visible tab's splitter
    def set_initial_splitter_sizes(self):
        current_index = self.tab_widget.currentIndex()
        current_splitter = self.splitters[current_index]
        current_splitter.setSizes([
            self.left_splitter_width, self.right_splitter_width])

    # Record the width of the splitter left and right panel widths
    # on tab changes
    def on_tab_change(self, index):
        current_splitter = self.splitters[index]
        current_splitter.setSizes([
            self.left_splitter_width, self.right_splitter_width])

    # Update stored widths when any splitter is moved
    def on_splitter_moved(self, splitter, _pos, _index):
        widths = splitter.sizes()
        self.left_splitter_width = widths[0]
        self.right_splitter_width = widths[1]

if __name__ == "__main__":
    app = QApplication(sys.argv)
    window = Window()
    window.show()
    sys.exit(app.exec())