Custom Titlebar with frame in PyQt5

Question:

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

Asked By: Praveen Kishore

||

Answers:

Here are the steps you just gotta follow:

  1. Have your MainWindow, be it a QMainWindow, or QWidget, or whatever [widget] you want to inherit.
  2. Set its flag, self.setWindowFlags(Qt.FramelessWindowHint)
  3. Implement your own moving around.
  4. Implement your own buttons (close, max, min)
  5. Implement your own resize.

Here is a small example with move around, and buttons implemented. You should still have to implement the resize using the same logic.

import sys

from PyQt5.QtCore import QPoint
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import QApplication
from PyQt5.QtWidgets import QHBoxLayout
from PyQt5.QtWidgets import QLabel
from PyQt5.QtWidgets import QPushButton
from PyQt5.QtWidgets import QVBoxLayout
from PyQt5.QtWidgets import QWidget



class MainWindow(QWidget):

    def __init__(self):
        super(MainWindow, self).__init__()
        self.layout  = QVBoxLayout()
        self.layout.addWidget(MyBar(self))
        self.setLayout(self.layout)
        self.layout.setContentsMargins(0,0,0,0)
        self.layout.addStretch(-1)
        self.setMinimumSize(800,400)
        self.setWindowFlags(Qt.FramelessWindowHint)
        self.pressing = False


class MyBar(QWidget):

    def __init__(self, parent):
        super(MyBar, self).__init__()
        self.parent = parent
        print(self.parent.width())
        self.layout = QHBoxLayout()
        self.layout.setContentsMargins(0,0,0,0)
        self.title = QLabel("My Own Bar")

        btn_size = 35

        self.btn_close = QPushButton("x")
        self.btn_close.clicked.connect(self.btn_close_clicked)
        self.btn_close.setFixedSize(btn_size,btn_size)
        self.btn_close.setStyleSheet("background-color: red;")

        self.btn_min = QPushButton("-")
        self.btn_min.clicked.connect(self.btn_min_clicked)
        self.btn_min.setFixedSize(btn_size, btn_size)
        self.btn_min.setStyleSheet("background-color: gray;")

        self.btn_max = QPushButton("+")
        self.btn_max.clicked.connect(self.btn_max_clicked)
        self.btn_max.setFixedSize(btn_size, btn_size)
        self.btn_max.setStyleSheet("background-color: gray;")

        self.title.setFixedHeight(35)
        self.title.setAlignment(Qt.AlignCenter)
        self.layout.addWidget(self.title)
        self.layout.addWidget(self.btn_min)
        self.layout.addWidget(self.btn_max)
        self.layout.addWidget(self.btn_close)

        self.title.setStyleSheet("""
            background-color: black;
            color: white;
        """)
        self.setLayout(self.layout)

        self.start = QPoint(0, 0)
        self.pressing = False

    def resizeEvent(self, QResizeEvent):
        super(MyBar, self).resizeEvent(QResizeEvent)
        self.title.setFixedWidth(self.parent.width())

    def mousePressEvent(self, event):
        self.start = self.mapToGlobal(event.pos())
        self.pressing = True

    def mouseMoveEvent(self, event):
        if self.pressing:
            self.end = self.mapToGlobal(event.pos())
            self.movement = self.end-self.start
            self.parent.setGeometry(self.mapToGlobal(self.movement).x(),
                                self.mapToGlobal(self.movement).y(),
                                self.parent.width(),
                                self.parent.height())
            self.start = self.end

    def mouseReleaseEvent(self, QMouseEvent):
        self.pressing = False


    def btn_close_clicked(self):
        self.parent.close()

    def btn_max_clicked(self):
        self.parent.showMaximized()

    def btn_min_clicked(self):
        self.parent.showMinimized()


if __name__ == "__main__":
    app = QApplication(sys.argv)
    mw = MainWindow()
    mw.show()
    sys.exit(app.exec_())

Here are some tips:

Option 1:

  1. Have a QGridLayout with widget in each corner and side(e.g. left, top-left, menubar, top-right, right, bottom-right, bottom and bottom left)
  2. With the approach (1) you would know when you are clicking in each border, you just got to define each one size and add each one on their place.
  3. When you click on each one treat them in their respective ways, for example, if you click in the left one and drag to the left, you gotta resize it larger and at the same time move it to the left so it will appear to be stopped at the right place and grow width.
  4. Apply this reasoning to each edge, each one behaving in the way it has to.

Option 2:

  1. Instead of having a QGridLayout you can detect in which place you are clicking by the click pos.

  2. Verify if the x of the click is smaller than the x of the moving pos to know if it’s moving left or right and where it’s being clicked.

  3. The calculation is made in the same way of the Option1

Option 3:

  1. Probably there are other ways, but those are the ones I just thought of. For example using the CustomizeWindowHint you said you are able to resize, so you just would have to implement what I gave you as example. BEAUTIFUL!

Tips:

  1. Be careful with the localPos(inside own widget), globalPos(related to your screen). For example: If you click in the very left of your left widget its ‘x’ will be zero, if you click in the very left of the middle(content)it will be also zero, although if you mapToGlobal you will having different values according to the pos of the screen.
  2. Pay attention when resizing, or moving, when you have to add width or subtract, or just move, or both, I’d recommend you to draw on a paper and figure out how the logic of resizing works before implementing it out of blue.

GOOD LUCK 😀

Answered By: yurisnm

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_())
Answered By: musicamante

This is for the people who are going to implement custom title bar in PyQt6 or PySide6

The below changes should be done in the answer given by @musicamante

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

    def mouseMoveEvent(self, event):
        if self.clickPos is not None:
            # self.window().move(event.globalPos() - self.clickPos)
            self.window().move(event.globalPosition().toPoint() - self.clickPos)
if __name__ == "__main__":
    app = QApplication(sys.argv)
    mw = MainWindow()
    mw.show()
    # sys.exit(app.exec_())
    sys.exit(app.exec())

References:
QMouseEvent.globalPosition(),
QMouseEvent.scenePosition()

This method of moving Windows with Custom Widget doesn’t work with WAYLAND. If anybody has a solution for that please post it here for future reference

Answered By: Krizen Knoz

Working functions for WAYLAND and PyQT6/PySide6 :

def mousePressEvent(self, event):
    if event.button() == Qt.MouseButton.LeftButton:
        self._move()
        return super().mousePressEvent(event)

def _move(self):
    window = self.window().windowHandle()
    window.startSystemMove()

Please check.

Answered By: Slavakamrad
Categories: questions Tags: , , , ,
Answers are sorted by their score. The answer accepted by the question owner as the best is marked with
at the top-right corner.