pythonpyqt5python-3.10

Implement Custom QWidgets as Popup for a QTimeEdit


QTimeEdit has no 'popup' widget and it's overall design makes it "not nice to use".

Therefore I coded two custom QWidget classes:

And i embedded them into a Custom QTimeEdit:

import math
import sys
from PyQt5 import QtCore, QtGui, QtWidgets
from PyQt5.QtWidgets import QTimeEdit, QApplication
from PyQt5.QtCore import QTime, Qt, QTimer
from PyQt5.QtGui import QIcon


class Clock_hourWidget(QtWidgets.QWidget):
    return_hour = 15
    first = True

    def __init__(self, hours, callback, parent=None):
        super(Clock_hourWidget, self).__init__(parent)
        self.setWindowFlags(Qt.Popup)
        self.callback = callback
        self.setFixedSize(150,150)
        self.hours = hours

    def mousePressEvent(self, event):
        if self.rect().contains(event.localPos().toPoint()):
            self.first = False
            self.return_hour = self.hours
        self.close()

    @property
    def hour(self):
        if self.first:
            result = -1
        else:
            result = self.return_hour
        return result
    
    def closeEvent(self, event):
        self.callback(self.hour)
        event.accept()


class Clock_minuteWidget(QtWidgets.QWidget):
    return_minutes = 30

    def __init__(self, minutes, callback, parent=None):
        super(Clock_minuteWidget, self).__init__(parent)
        self.setWindowFlags(Qt.Popup)
        self.setFixedSize(150,150)
        self.callback = callback
        self.minutes = minutes

    def mousePressEvent(self, event):
        if self.rect().contains(event.localPos().toPoint()):
            self.return_minutes = self.minutes
        self.close()

    @property
    def minute(self):
        return self.return_minutes

    def closeEvent(self, event):
        self.callback(self.minute)
        event.accept()


class CustomTimeEdit(QTimeEdit):
    def __init__(self, parent=None):
        super(CustomTimeEdit, self).__init__(parent)
        self.setDisplayFormat("HH:mm")
        self.setCalendarPopup(False)
        # calendar_icon = QIcon.fromTheme("calendar")
        # self.setButtonSymbols(calendar_icon)

    def mousePressEvent(self, event):
        if event.button() == Qt.LeftButton:
            self.open_clock_hourwidget()

    def return_hour(self, hour):
        self.clock_hourwidget = None
        if hour != -1:
            self.hours = hour
            self.minutes = self.time().minute()
            self.setTime(QTime(self.hours, self.minutes))
            self.clock_minutewidget = Clock_minuteWidget(self.minutes, self.clock_finalizer)
            pos = self.mapToGlobal(self.rect().bottomLeft())
            pos = self.adjust_Popup_positioning(pos, self.clock_minutewidget)
            self.clock_minutewidget.move(pos)
            self.clock_minutewidget.show()

    def open_clock_hourwidget(self):
        self.hours = self.time().hour()
        self.clock_hourwidget = Clock_hourWidget(self.hours, self.return_hour)
        pos = self.mapToGlobal(self.rect().bottomLeft())
        pos = self.adjust_Popup_positioning(pos, self.clock_hourwidget)
        self.clock_hourwidget.move(pos)
        self.clock_hourwidget.show()

    def adjust_Popup_positioning(self, pos, widget):
        screen = QApplication.desktop().screenNumber(QApplication.desktop().cursor().pos())
        screen_geometry = QApplication.desktop().screenGeometry(screen)
        if pos.y() + widget.height() > screen_geometry.height():
            pos = self.mapToGlobal(self.rect().topLeft())
            pos.setY(pos.y() - widget.height())
        if pos.x() < screen_geometry.left():
            pos.setX(screen_geometry.left())
        elif pos.x() + widget.width() > screen_geometry.right():
            pos.setX(screen_geometry.right() - widget.width())
        return pos

    def clock_finalizer(self, minute):
        self.clock_minutewidget = None
        self.setTime(QTime(self.hours, minute))


if __name__ == "__main__":
    app = QtWidgets.QApplication(sys.argv)
    time_edit = CustomTimeEdit()
    time_edit.show()
    sys.exit(app.exec_())

I want the popup to behave like the CalendarPopup of QDateEdit (crossed out functionality already in above code snippet):

I can provide full code if needed but for simplicity reasons i tried to cut it down as much as possible.


Solution

  • QTimeEdit and QDateEdit are fundamentally dummy classes: they do not provide any specific difference in behavior than QDateTimeEdit from which they inherit, and, in fact, it's enough to set a proper displayFormat() on QDateTimeEdit to get the same results.

    Now, what QDateTimeEdit does is to partially behave similarly to an editable QComboBox whenever the calendarPopup property is True and it's actually showing a date Section (either one of day, month or year).

    This is achieved (other than in mouse handlers) in the paintEvent(): if the popup shouldn't be shown (calendarPopup is False or no date section is present in displayFormat()), it just calls the basic paintEvent() of QAbstractSpinBox, which internally creates a QStyleOptionSpinBox instance, calls the virtual initStyleOption() (which is that of QDateTimeEdit, but in that case would be identical since no date section is shown) and finally uses the widget's style drawComplexControl() function with it.

    If, otherwise, the popup may be shown, it uses QStyle features to show itself like an editable combo box by adding some subControls specific to it: the combo frame, its edit field and, most importantly, the arrow.

    Therefore, in order to force the painting of the arrow, we need to mimic that same behavior. Note that since the arrow may be shown differently depending on the mouse over state, we need to ensure that the option activeSubControls are also consistent, which is achieved by forcing mouse tracking and checking the position in mouseMoveEvent() and enter/leave events.

    Be aware that the following code also installs an event filter on the inner line edit in order to consistently check if the mouse leaves or enters it: the line edit is a child widget, so entering it doesn't result in a Leave event for the parent.

    class CustomTimeEdit(QTimeEdit):
        clock_hourwidget = clock_minutewidget = None
        _onArrow = False
        def __init__(self, parent=None):
            super(CustomTimeEdit, self).__init__(parent)
            self.setDisplayFormat("HH:mm")
            self.setCalendarPopup(False)
            self.setMouseTracking(True)
            self.lineEdit().installEventFilter(self)
    
        ...
        def _setOnArrow(self, onArrow):
            if self._onArrow != onArrow:
                self._onArrow = onArrow
                self.update()
    
        def _checkOnArrow(self, pos):
            style = self.style()
            opt = QStyleOptionComboBox()
            opt.initFrom(self)
            arrowRect = style.subControlRect(style.CC_ComboBox, opt, 
                style.SC_ComboBoxArrow, self)
            self._setOnArrow(arrowRect.contains(pos))
    
        def mouseMoveEvent(self, event):
            if not event.buttons():
                self._checkOnArrow(event.pos())
            else:
                super().mouseMoveEvent(event)
    
        def enterEvent(self, event):
            super().enterEvent(event)
            self._checkOnArrow(self.mapFromGlobal(QCursor.pos()))
    
        def leaveEvent(self, event):
            super().leaveEvent(event)
            self._setOnArrow(False)
    
        def eventFilter(self, obj, event):
            if event.type() == event.Enter:
                self._setOnArrow(False)
            elif event.type() == event.Leave:
                self._checkOnArrow(self.mapFromGlobal(QCursor.pos()))
            return super().eventFilter(obj, event)            
    
        def paintEvent(self, event):
            opt = QStyleOptionSpinBox()
            QAbstractSpinBox.initStyleOption(self, opt)
            style = self.style()
    
            opt.subControls = (
                style.SC_ComboBoxFrame | style.SC_ComboBoxEditField
                | style.SC_ComboBoxArrow
            )
            if self.clock_hourwidget or self.clock_minutewidget:
                opt.state |= style.State_Sunken
            else:
                opt.state &= ~style.State_Sunken
            optCombo = QStyleOptionComboBox()
            optCombo.initFrom(self)
            optCombo.editable = True
            optCombo.frame = opt.frame
            optCombo.subControls = opt.subControls
            optCombo.state = opt.state
    
            if self._onArrow:
                optCombo.activeSubControls = style.SC_ComboBoxArrow
    
            qp = QPainter(self)
            self.style().drawComplexControl(
                style.CC_ComboBox, optCombo, qp, self)
    

    Note that QDesktopWidget has been declared obsolete for some time now, and QScreen should always be preferred instead. Also, calling the desktop widget's cursor() to get its position is pointless (since it's the same as the static QCursor.pos()), and the geometry computation should be more careful (other than simpler). For instance:

    ...
        def open_clock_hourwidget(self):
            self.hours = self.time().hour()
            self.clock_hourwidget = Clock_hourWidget(self.hours, self.return_hour)
            self.adjustPopup(self.clock_hourwidget)
    
        def adjustPopup(self, widget):
            pos = self.mapToGlobal(self.rect().bottomLeft())
            geo = widget.geometry()
            geo.moveTopLeft(pos)
            screen = (QApplication.screenAt(pos) or self.screen()).geometry()
            if geo.right() > screen.right():
                geo.moveRight(screen.right())
            if geo.x() < screen.x():
                geo.moveLeft(screen.left())
            if geo.bottom() > screen.bottom():
                geo.moveBottom(screen.bottom())
            if geo.y() < screen.y():
                geo.moveTop(screen.y())
            widget.setGeometry(geo)
            widget.show()
    

    Note that I specifically avoided any elif above. While we could safely assume that almost nobody would use a screen so small that wouldn't fit the popup, it's still better to formally consider the possibility anyway.

    Finally, some further suggestions: