Custom Titlebar with frame in PyQt5

Viewed 16905

I'm working on an opensource markdown supported minimal note taking application for Windows/Linux. I'm trying to remove the title bar and add my own buttons. I want something like, a title bar with only two custom buttons as shown in the figureenter image description here

Currently I have this:

enter image description here

I've tried modifying the window flags:

  • With not window flags, the window is both re-sizable and movable. But no custom buttons.
  • Using self.setWindowFlags(QtCore.Qt.FramelessWindowHint), the window has no borders, but cant move or resize the window
  • Using self.setWindowFlags(QtCore.Qt.CustomizeWindowHint), the window is resizable but cannot move and also cant get rid of the white part at the top of the window.

Any help appreciated. You can find the project on GitHub here.

Thanks..

This is my python code:

from PyQt5 import QtCore, QtWidgets, QtWebEngineWidgets, uic
import sys
import os
import markdown2 # https://github.com/trentm/python-markdown2
from PyQt5.QtCore import QRect
from PyQt5.QtGui import QFont

simpleUiForm = uic.loadUiType("Simple.ui")[0]

class SimpleWindow(QtWidgets.QMainWindow, simpleUiForm):
    def __init__(self, parent=None):
        QtWidgets.QMainWindow.__init__(self, parent)
        self.setupUi(self)
        self.markdown = markdown2.Markdown()
        self.css = open(os.path.join("css", "default.css")).read()
        self.editNote.setPlainText("")
        #self.noteView = QtWebEngineWidgets.QWebEngineView(self)
        self.installEventFilter(self)
        self.displayNote.setContextMenuPolicy(QtCore.Qt.NoContextMenu)
        #self.setWindowFlags(QtCore.Qt.FramelessWindowHint)

    def eventFilter(self, object, event):
        if event.type() == QtCore.QEvent.WindowActivate:
            print("widget window has gained focus")
            self.editNote.show()
            self.displayNote.hide()
        elif event.type() == QtCore.QEvent.WindowDeactivate:
            print("widget window has lost focus")
            note = self.editNote.toPlainText()
            htmlNote = self.getStyledPage(note)
            # print(note)
            self.editNote.hide()
            self.displayNote.show()
            # print(htmlNote)
            self.displayNote.setHtml(htmlNote)
        elif event.type() == QtCore.QEvent.FocusIn:
            print("widget has gained keyboard focus")
        elif event.type() == QtCore.QEvent.FocusOut:
            print("widget has lost keyboard focus")
        return False

The UI file is created in the following hierarchy

enter image description here

2 Answers

While the accepted answer can be considered valid, it has some issues.

  • using setGeometry() is not appropriate (and the reason for using it was wrong) since it doesn't consider possible frame margins set by the style;
  • the position computation is unnecessarily complex;
  • resizing the title bar to the total width is wrong, since it doesn't consider the buttons and can also cause recursion problems in certain situations (like not setting the minimum size of the main window); also, if the title is too big, it makes impossible to resize the main window;
  • buttons should not accept focus;
  • setting a layout creates a restraint for the "main widget" or layout, so the title should not be added, but the contents margins of the widget should be used instead;

I revised the code to provide a better base for the main window, simplify the moving code, and add other features like the Qt windowTitle() property support, standard QStyle icons for buttons (instead of text), and proper maximize/normal button icons. Note that the title label is not added to the layout.

class MainWindow(QWidget):
    def __init__(self):
        super(MainWindow, self).__init__()
        self.setWindowFlags(self.windowFlags() | Qt.FramelessWindowHint)

        self.titleBar = MyBar(self)
        self.setContentsMargins(0, self.titleBar.height(), 0, 0)

        self.resize(640, self.titleBar.height() + 480)

    def changeEvent(self, event):
        if event.type() == event.WindowStateChange:
            self.titleBar.windowStateChanged(self.windowState())

    def resizeEvent(self, event):
        self.titleBar.resize(self.width(), self.titleBar.height())


class MyBar(QWidget):
    clickPos = None
    def __init__(self, parent):
        super(MyBar, self).__init__(parent)
        self.setAutoFillBackground(True)
        
        self.setBackgroundRole(QPalette.Shadow)
        # alternatively:
        # palette = self.palette()
        # palette.setColor(palette.Window, Qt.black)
        # palette.setColor(palette.WindowText, Qt.white)
        # self.setPalette(palette)

        layout = QHBoxLayout(self)
        layout.setContentsMargins(1, 1, 1, 1)
        layout.addStretch()

        self.title = QLabel("My Own Bar", self, alignment=Qt.AlignCenter)
        # if setPalette() was used above, this is not required
        self.title.setForegroundRole(QPalette.Light)

        style = self.style()
        ref_size = self.fontMetrics().height()
        ref_size += style.pixelMetric(style.PM_ButtonMargin) * 2
        self.setMaximumHeight(ref_size + 2)

        btn_size = QSize(ref_size, ref_size)
        for target in ('min', 'normal', 'max', 'close'):
            btn = QToolButton(self, focusPolicy=Qt.NoFocus)
            layout.addWidget(btn)
            btn.setFixedSize(btn_size)

            iconType = getattr(style, 
                'SP_TitleBar{}Button'.format(target.capitalize()))
            btn.setIcon(style.standardIcon(iconType))

            if target == 'close':
                colorNormal = 'red'
                colorHover = 'orangered'
            else:
                colorNormal = 'palette(mid)'
                colorHover = 'palette(light)'
            btn.setStyleSheet('''
                QToolButton {{
                    background-color: {};
                }}
                QToolButton:hover {{
                    background-color: {}
                }}
            '''.format(colorNormal, colorHover))

            signal = getattr(self, target + 'Clicked')
            btn.clicked.connect(signal)

            setattr(self, target + 'Button', btn)

        self.normalButton.hide()

        self.updateTitle(parent.windowTitle())
        parent.windowTitleChanged.connect(self.updateTitle)

    def updateTitle(self, title=None):
        if title is None:
            title = self.window().windowTitle()
        width = self.title.width()
        width -= self.style().pixelMetric(QStyle.PM_LayoutHorizontalSpacing) * 2
        self.title.setText(self.fontMetrics().elidedText(
            title, Qt.ElideRight, width))

    def windowStateChanged(self, state):
        self.normalButton.setVisible(state == Qt.WindowMaximized)
        self.maxButton.setVisible(state != Qt.WindowMaximized)

    def mousePressEvent(self, event):
        if event.button() == Qt.LeftButton:
            self.clickPos = event.windowPos().toPoint()

    def mouseMoveEvent(self, event):
        if self.clickPos is not None:
            self.window().move(event.globalPos() - self.clickPos)

    def mouseReleaseEvent(self, QMouseEvent):
        self.clickPos = None

    def closeClicked(self):
        self.window().close()

    def maxClicked(self):
        self.window().showMaximized()

    def normalClicked(self):
        self.window().showNormal()

    def minClicked(self):
        self.window().showMinimized()

    def resizeEvent(self, event):
        self.title.resize(self.minButton.x(), self.height())
        self.updateTitle()


if __name__ == "__main__":
    app = QApplication(sys.argv)
    mw = MainWindow()
    layout = QVBoxLayout(mw)
    widget = QTextEdit()
    layout.addWidget(widget)
    mw.show()
    mw.setWindowTitle('My custom window with a very, very long title')
    sys.exit(app.exec_())
Related