ウィンドウタイトルバーにタブとボタンがあるQMainWindowを作ってみる

2023/09/10 categories:Python| tags:Python|PyQt6|PySide6|QToolBar|

PySide6でQMainWindowのウィンドウタイトルバーにボタンやタブがあるウィンドウを作ってみました。

ウィンドウの外観

 下記画像のようなウィンドウを作りました。QMainWindowをフレームレスにして、QTabBarの部分にボタンなどを配置して、ウィンドウタイトルバーにボタンやタブがあるような見た目にしています。

コーナーウィジェットにボタンを配置

 QTabWidgetのsetCornerWidgetを使用して、タブのコーナーにボタンを追加します。ウィンドウのアイコンとなるボタンをQtCore.Qt.Corner.TopLeftCornerに配置して、ウィンドウの表示関連のボタンをQtCore.Qt.Corner.TopRightCornerに配置しています。単純にQToolButtonを追加するだけだと見た目がいまいちだったので、setStyleSheetで見た目を変えています。right_buttonsの最初に追加しているspacerが無い場合、ウィンドウを一番小さい状態までリサイズしたときにウィンドウを移動するためにドラッグする部分が無くなってしまったので、最小サイズのウィンドウの大きさでもドラッグで移動できるようにするためにスペーサーとしてQLabelを入れています。

class TabWidget(QtWidgets.QTabWidget):
    class Button(QtWidgets.QToolButton):
        def __init__(self, text: str, hover_color: str, background_color: str, standard_pixmap: QtWidgets.QStyle.StandardPixmap) -> None:
            super().__init__()
            style = 'QToolButton:hover {{ background-color: {}; }} '.format(hover_color)
            style += 'QToolButton:pressed {{ background-color: {}; }} '.format(background_color)
            self.setStyleSheet(style)
            self.setDefaultAction( QtGui.QAction(self.style().standardPixmap(standard_pixmap), text) )
            self.setSizePolicy(QtWidgets.QSizePolicy.Policy.Maximum, QtWidgets.QSizePolicy.Policy.MinimumExpanding)

    def __init__(self):
        super().__init__()
        self.icon = self.Button('', '', '', QtWidgets.QStyle.StandardPixmap.SP_TitleBarMenuButton)
        self.shade_button = self.Button('Minimize', '#dddddd', '#dddddd', QtWidgets.QStyle.StandardPixmap.SP_TitleBarUnshadeButton)
        self.minimize_button = self.Button('Minimize', '#dddddd', '#dddddd', QtWidgets.QStyle.StandardPixmap.SP_TitleBarMinButton)
        self.minimize_button = self.Button('Minimize', '#dddddd', '#dddddd', QtWidgets.QStyle.StandardPixmap.SP_TitleBarMinButton)
        self.maximize_button = self.Button('Maximize', '#dddddd', '#dddddd', QtWidgets.QStyle.StandardPixmap.SP_TitleBarMaxButton)
        self.close_button = self.Button('Close', '#dd0000', '#dd0000', QtWidgets.QStyle.StandardPixmap.SP_TitleBarCloseButton)

        spacer = QtWidgets.QLabel()
        spacer.setMinimumWidth(20)
        spacer.setSizePolicy(QtWidgets.QSizePolicy.Policy.Maximum, QtWidgets.QSizePolicy.Policy.Maximum)

        right_buttons = QtWidgets.QWidget()
        right_buttons.setSizePolicy(QtWidgets.QSizePolicy.Policy.Maximum, QtWidgets.QSizePolicy.Policy.MinimumExpanding)
        right_buttons.setLayout(QtWidgets.QHBoxLayout())
        right_buttons.layout().setSpacing(0)
        right_buttons.layout().setContentsMargins(0, 0, 0, 0)
        right_buttons.layout().addWidget(spacer)
        right_buttons.layout().addWidget(self.shade_button)
        right_buttons.layout().addWidget(self.minimize_button)
        right_buttons.layout().addWidget(self.maximize_button)
        right_buttons.layout().addWidget(self.close_button)

        self.setCornerWidget(self.icon, QtCore.Qt.Corner.TopLeftCorner)
        self.setCornerWidget(right_buttons, QtCore.Qt.Corner.TopRightCorner)

タブ追加と移動

 一番最後のタブのテキストを+にして、最後のタブをクリックしたときにタブを追加するような処理を行うことで、タブの追加を実装しています。しかし、それだけだとタブの追加は出来るようになりますが、setMovable(True)でタブを移動可能に設定したときに、最後のタブの位置を変えられるようになってしまいます。+のタブは常に最後に配置したいので、tabBar().tabMovedでタブが移動した後に移動先がタブの最後だった場合に、そのタブを最後の1個手前に移動するような処理で、+以外のタブを最後に移動できないようにしました。

 tabBarClickedで最後のタブに切り替えた時にタブを追加して、最後の一つ手前のタブを現在のタブに設定することで、タブの追加を行っています。ただしこれではマウスでクリックしたときはタブが切り替わりませんが、タブの切り替え時にキーボードやマウスホイールを使用すると+のタブに移動できてしまいます。そこでcurrentChangedでタブが切り替わったときに最後のタブだった場合に最後の1個手前のタブを現在のタブに設定することで+のタブに切り替えられないようにしました。

class TabWidget(QtWidgets.QTabWidget):
    def __init__(self):
        super().__init__()
        self.setMovable(True)
        self.currentChanged.connect(self.tab_changed)
        self.tabBar().tabBarClicked.connect(self.tab_bar_clicked)
        self.tabBar().tabMoved.connect(self.tab_moved)

    def tab_moved(self, to_index, from_index):
        if to_index > self.count() - 2:
            self.tabBar().blockSignals(True)
            self.tabBar().moveTab(to_index, from_index)
            self.tabBar().blockSignals(False)
        self.current_index = self.currentIndex()

    def tab_changed(self, index):
        if index > self.count() - 2:
            self.setCurrentIndex(self.current_index)

    def tab_bar_clicked(self, index):
        if index > self.count() - 2:
            name = 'Tab {}'.format(index + 1)
            self.addTab(QtWidgets.QLabel(name), name)

ウィンドウの移動

タブバーをドラッグしたときにウィンドウを移動できるようにするために、TabWidgetのmousePressEventでボタンやタブがない移動に使用する部分をクリックしているか判定して、移動に使用する部分だった場合にメインウィンドウにシグナルを送って移動をウィンドウの開始しています。

class TabWidget(QtWidgets.QTabWidget):
    def mousePressEvent(self, event):
        if event.button() == QtCore.Qt.MouseButton.LeftButton:
            point = event.position().toPoint()
            tab_index = self.tabBar().tabAt(point)
            if tab_index == -1:
                m = self.start_resize_margin
                rect = QtCore.QRect(m, m, self.width() - m * 2, self.tabBar().height() - m)
                if rect.contains(point):
                    self.move_area_clicked.emit()
        super().mousePressEvent(event)

タブの削除とリネーム

QTabWidgetにカスタムコンテキストメニューを設定して、タブを右クリックしたときのコンテキストメニューでタブの削除とリネームができるようにしています。

class TabWidget(QtWidgets.QTabWidget):
    def __init__(self):
        self.tabBar().setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu)
        self.tabBar().customContextMenuRequested.connect(self.show_tab_context_menu)

    def show_tab_context_menu(self, point: QtCore.QPoint):
        menu = QtWidgets.QMenu()
        menu.addAction('rename tab', self.change_current_tab_name)
        menu.addAction('delete tab', self.remove_current_tab_name)
        menu.exec(self.mapToGlobal(point))

ソースコード

from PySide6 import QtWidgets, QtGui, QtCore


class FramelessTabWindow(QtWidgets.QMainWindow):
    def __init__(self) -> None:
        super().__init__()

        self.start_resize_margin = 5

        self.tab_widget = TabWidget()
        self.tab_widget.move_area_clicked.connect(self.move_window)
        self.tab_widget.close_clicked.connect(self.close)
        self.tab_widget.minimize_clicked.connect(self.showMinimized)
        self.tab_widget.maximize_clicked.connect(self.toggle_maximize)
        self.tab_widget.double_clicked.connect(self.toggle_maximize)
        self.resize(400, 200)
        self.setWindowFlag(QtCore.Qt.WindowType.FramelessWindowHint)
        self.setCentralWidget(self.tab_widget)
        self.setStyleSheet('QMainWindow { background-color: #ccc; } ')
        
        self.tab_widget.add_shade_menu(QtGui.QAction('menu action 1'))
        self.tab_widget.add_shade_menu(QtGui.QAction('menu action 2'))
        self.tab_widget.addTab(QtWidgets.QLabel('Tab 1'), 'Tab 1')
        self.tab_widget.addTab(QtWidgets.QLabel('Tab 2'), 'Tab 2')
        self.tab_widget.setCurrentIndex(0)
        
    def toggle_maximize(self):
        if self.windowState() == QtCore.Qt.WindowState.WindowMaximized:
            self.setWindowState(QtCore.Qt.WindowState.WindowNoState)
        else:
            self.setWindowState(QtCore.Qt.WindowState.WindowMaximized)
        
    def move_window(self):
        self.windowHandle().startSystemMove()

    def mousePressEvent(self, event: QtGui.QMouseEvent) -> None:
        if event.button() == QtCore.Qt.MouseButton.LeftButton:
            p = event.position()
            is_top, is_bottom = p.y() < self.start_resize_margin, p.y() > self.height() - self.start_resize_margin
            is_left, is_right = p.x() < self.start_resize_margin, p.x() > self.width() - self.start_resize_margin
            edges_list = []
            if is_top:
                edges_list.append(QtCore.Qt.Edge.TopEdge)
            if is_bottom:
                edges_list.append(QtCore.Qt.Edge.BottomEdge)
            if is_right:
                edges_list.append(QtCore.Qt.Edge.RightEdge)
            if is_left:
                edges_list.append(QtCore.Qt.Edge.LeftEdge)
            if edges_list:
                edges = edges_list[0]
                for edge in edges_list[1:]:
                    edges |= edge
                if self.windowState() == QtCore.Qt.WindowState.WindowNoState:
                    self.windowHandle().startSystemResize(edges)
        return super().mousePressEvent(event)
        
    def event(self, event):
        if event.type() == QtCore.QEvent.Type.HoverMove and self.windowState() == QtCore.Qt.WindowState.WindowNoState:
            p = event.position()
            is_top, is_bottom = p.y() < self.start_resize_margin, p.y() > self.height() - self.start_resize_margin
            is_left, is_right = p.x() < self.start_resize_margin, p.x() > self.width() - self.start_resize_margin

            cursor_shape = QtCore.Qt.CursorShape.ArrowCursor

            if [is_top, is_bottom, is_left, is_right] == [True, False, False, False]:
                cursor_shape = QtCore.Qt.CursorShape.SizeVerCursor

            if [is_top, is_bottom, is_left, is_right] == [False, True, False, False]:
                cursor_shape = QtCore.Qt.CursorShape.SizeVerCursor
                
            if [is_top, is_bottom, is_left, is_right] == [False, False, True, False]:
                cursor_shape = QtCore.Qt.CursorShape.SizeHorCursor

            if [is_top, is_bottom, is_left, is_right] == [False, False, False, True]:
                cursor_shape = QtCore.Qt.CursorShape.SizeHorCursor
                
            if [is_top, is_bottom, is_left, is_right] == [True, False, True, False]:
                cursor_shape = QtCore.Qt.CursorShape.SizeFDiagCursor

            if [is_top, is_bottom, is_left, is_right] == [False, True, False, True]:
                cursor_shape = QtCore.Qt.CursorShape.SizeFDiagCursor

            if [is_top, is_bottom, is_left, is_right] == [True, False, False, True]:
                cursor_shape = QtCore.Qt.CursorShape.SizeBDiagCursor

            if [is_top, is_bottom, is_left, is_right] == [False, True, True, False]:
                cursor_shape = QtCore.Qt.CursorShape.SizeBDiagCursor

            self.setCursor(cursor_shape)

        return super().event(event)


class TabWidget(QtWidgets.QTabWidget):

    move_area_clicked = QtCore.Signal()
    tab_clicked = QtCore.Signal()
    close_clicked = QtCore.Signal()
    double_clicked = QtCore.Signal()
    icon_clicked = QtCore.Signal()
    minimize_clicked = QtCore.Signal()
    maximize_clicked = QtCore.Signal()

    class Button(QtWidgets.QToolButton):
        def __init__(self, text: str, hover_color: str, background_color: str, standard_pixmap: QtWidgets.QStyle.StandardPixmap) -> None:
            super().__init__()
            style = 'QToolButton:hover {{ background-color: {}; }} '.format(hover_color)
            style += 'QToolButton:pressed {{ background-color: {}; }} '.format(background_color)
            self.setStyleSheet(style)
            self.setDefaultAction( QtGui.QAction(self.style().standardPixmap(standard_pixmap), text) )
            self.setSizePolicy(QtWidgets.QSizePolicy.Policy.Maximum, QtWidgets.QSizePolicy.Policy.MinimumExpanding)

    def __init__(self):
        super().__init__()
        self.start_resize_margin = 5
        self.current_index = 0

        self.icon = self.Button('', '', '', QtWidgets.QStyle.StandardPixmap.SP_TitleBarMenuButton)
        self.icon.triggered.connect(lambda : self.icon_clicked.emit())

        self.shade_button_menu = QtWidgets.QMenu()
        self.shade_button = self.Button('Minimize', '#dddddd', '#dddddd', QtWidgets.QStyle.StandardPixmap.SP_TitleBarUnshadeButton)
        self.shade_button.setPopupMode(self.Button.ToolButtonPopupMode.InstantPopup)
        self.shade_button.setMenu(self.shade_button_menu)
        self.shade_button.setStyleSheet( self.shade_button.styleSheet() + 'QToolButton::menu-indicator { image: none; }' )
        
        self.minimize_button = self.Button('Minimize', '#dddddd', '#dddddd', QtWidgets.QStyle.StandardPixmap.SP_TitleBarMinButton)
        self.minimize_button.triggered.connect(lambda : self.minimize_clicked.emit())
        
        self.minimize_button = self.Button('Minimize', '#dddddd', '#dddddd', QtWidgets.QStyle.StandardPixmap.SP_TitleBarMinButton)
        self.minimize_button.triggered.connect(lambda : self.minimize_clicked.emit())
        
        self.maximize_button = self.Button('Maximize', '#dddddd', '#dddddd', QtWidgets.QStyle.StandardPixmap.SP_TitleBarMaxButton)
        self.maximize_button.triggered.connect(lambda : self.maximize_clicked.emit())
        
        self.close_button = self.Button('Close', '#dd0000', '#dd0000', QtWidgets.QStyle.StandardPixmap.SP_TitleBarCloseButton)
        self.close_button.triggered.connect(lambda : self.close_clicked.emit())

        spacer = QtWidgets.QLabel()
        spacer.setMinimumWidth(20)
        spacer.setSizePolicy(QtWidgets.QSizePolicy.Policy.Maximum, QtWidgets.QSizePolicy.Policy.Maximum)

        right_buttons = QtWidgets.QWidget()
        right_buttons.setSizePolicy(QtWidgets.QSizePolicy.Policy.Maximum, QtWidgets.QSizePolicy.Policy.MinimumExpanding)
        right_buttons.setLayout(QtWidgets.QHBoxLayout())
        right_buttons.layout().setSpacing(0)
        right_buttons.layout().setContentsMargins(0, 0, 0, 0)
        right_buttons.layout().addWidget(spacer)
        right_buttons.layout().addWidget(self.shade_button)
        right_buttons.layout().addWidget(self.minimize_button)
        right_buttons.layout().addWidget(self.maximize_button)
        right_buttons.layout().addWidget(self.close_button)

        self.setCornerWidget(self.icon, QtCore.Qt.Corner.TopLeftCorner)
        self.setCornerWidget(right_buttons, QtCore.Qt.Corner.TopRightCorner)
        self.setStyleSheet('''
            QTabWidget::pane { border: none; background-color: #fff; } 
            QTabBar::tab { border: 1px solid; 
                           border-color: #ccc ; 
                           margin: 5px 0px 0px 0px; 
                           padding: 0px 10px 0px 10px; 
                           height: 25px; 
                           background-color: #aaa; 
                           color: #000; 
                           } 
            QTabBar::tab:selected { 
                           border: none; 
                           margin: 0px 0px 0px 0px; 
                           padding: 0px 10px 0px 10px; 
                           height: 32px; 
                           background-color: #fff; 
                           color: #000; 
                           } 
            QToolButton { height: 32px; width: 32px; border: none; }
        ''')
        self.setMovable(True)
        
        super().addTab(QtWidgets.QWidget(), '+')

        self.currentChanged.connect(self.tab_changed)
        self.tabBar().tabBarClicked.connect(self.tab_bar_clicked)
        self.tabBar().tabMoved.connect(self.tab_moved)
        self.tabBar().setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu)
        self.tabBar().customContextMenuRequested.connect(self.show_tab_context_menu)

    def show_tab_context_menu(self, point: QtCore.QPoint):
        menu = QtWidgets.QMenu()
        menu.addAction('rename tab', self.change_current_tab_name)
        menu.addAction('delete tab', self.remove_current_tab_name)
        menu.exec(self.mapToGlobal(point))

    def findMainWindowRecursive(self, widget):
        if widget is not None:
            if isinstance(widget, QtWidgets.QMainWindow):
                return widget
        return self.findMainWindowRecursive(widget.parent())

    def remove_current_tab_name(self):
        index = self.currentIndex()
        dialog = QtWidgets.QMessageBox()
        dialog.setWindowTitle('delete tab')
        dialog.setText( 'Do you want to delete the "{}" tab?'.format(self.tabText(index)) )
        dialog.show()
        mainwindow = self.findMainWindowRecursive(self)
        rect0 = mainwindow.geometry()
        rect1 = dialog.rect()
        x = rect0.x() + ( rect0.width() -  rect1.width()) / 2
        y = rect0.y() + (rect0.height() - rect1.height()) / 2
        dialog.move(x, y)
        if dialog.exec():
            self.removeTab(index)
            if self.currentIndex() == self.count() - 1:
                self.setCurrentIndex(self.count() - 2)

    def change_current_tab_name(self):
        index = self.currentIndex()
        name = QtWidgets.QLineEdit( self.tabText(index) )
        buttons = QtWidgets.QDialogButtonBox(QtCore.Qt.Orientation.Horizontal)
        dialog = QtWidgets.QDialog()
        dialog.setWindowTitle('input new tab name')
        dialog.setLayout(QtWidgets.QVBoxLayout())
        dialog.layout().addWidget(name)
        dialog.layout().addWidget(buttons)
        buttons.addButton(QtWidgets.QDialogButtonBox.StandardButton.Ok).clicked.connect(dialog.accept)
        buttons.addButton(QtWidgets.QDialogButtonBox.StandardButton.Cancel).clicked.connect(dialog.reject)
        dialog.show()
        mainwindow = self.findMainWindowRecursive(self)
        rect0 = mainwindow.geometry()
        rect1 = dialog.rect()
        x = rect0.x() + ( rect0.width() -  rect1.width()) / 2
        y = rect0.y() + (rect0.height() - rect1.height()) / 2
        dialog.move(x, y)
        if dialog.exec():
            self.setTabText(index, name.text())

    def tab_moved(self, to_index, from_index):
        if to_index > self.count() - 2:
            self.tabBar().blockSignals(True)
            self.tabBar().moveTab(to_index, from_index)
            self.tabBar().blockSignals(False)
        self.current_index = self.currentIndex()

    def add_shade_menu(self, action: QtGui.QAction):
        action.setParent(self.shade_button_menu)
        self.shade_button_menu.addAction(action)

    def setCurrentIndex(self, index: int) -> None:
        self.current_index = index
        return super().setCurrentIndex(index)

    def tab_changed(self, index):
        if index > self.count() - 2:
            self.setCurrentIndex(self.current_index)

    def tab_bar_clicked(self, index):
        if index > self.count() - 2:
            name = 'Tab {}'.format(index + 1)
            self.addTab(QtWidgets.QLabel(name), name)

    def addTab(self, widget: QtWidgets.QWidget, icon: QtGui.QIcon, label: str):
        index = self.count() - 1
        super().insertTab(index, widget, icon, label)
        index = index if index > 0 else 0
        self.setCurrentIndex(index)
        self.current_index = index

    def addTab(self, widget: QtWidgets.QWidget, label: str):
        index = self.count() - 1
        super().insertTab(index, widget, label)
        index = index if index > 0 else 0
        self.setCurrentIndex(index)
        self.current_index = index

    def standard_pixmap(self, standard_pixmap):
        return self.style().standardPixmap(standard_pixmap)
        
    def mousePressEvent(self, event):
        if event.button() == QtCore.Qt.MouseButton.LeftButton:
            point = event.position().toPoint()
            tab_index = self.tabBar().tabAt(point)
            if tab_index == -1:
                m = self.start_resize_margin
                rect = QtCore.QRect(m, m, self.width() - m * 2, self.tabBar().height() - m)
                if rect.contains(point):
                    self.move_area_clicked.emit()
        super().mousePressEvent(event)

    def mouseDoubleClickEvent(self, event: QtGui.QMouseEvent) -> None:
        if event.button() == QtCore.Qt.MouseButton.LeftButton:
            point = event.position().toPoint()
            if self.tabBar().tabAt(point) == -1:
                rect = QtCore.QRect(0, 0, self.width(), self.tabBar().height())
                if rect.contains(point):
                    self.double_clicked.emit()
        return super().mouseDoubleClickEvent(event)


if __name__ == '__main__':
    import sys
    app = QtWidgets.QApplication(sys.argv)
    window = FramelessTabWindow()
    window.show()
    sys.exit(app.exec())

Share post

Related Posts

コメント