Search code examples
pythonpyqt5python-3.10

Issues with swapping dates using pyqt5 calendarpopup


I got two QDateEdit Objects in (for simplicity here) a QWidget. However I ran in an for me not fixable issue. In order to get my idea:

Qdate1 = datecheckin
Qdate2 = datecheckout

The QDateEdits should switch dates, if checkin > checkout . On top there should be some special treatments if both are equal. If I put the dates via numblock/keyboard the code works somewhat, but still not ideally.

HOWEVER: If i use the CalendarPopup to select the dates: Unexpected behaviour. I can't even explain what's going on.

I tried disconnecting and reconnecting slots and using one change_method instead of two seperate ones (with partial).

from PyQt5.QtWidgets import QApplication, QDateEdit, QVBoxLayout, QWidget
from PyQt5.QtCore import QDate, Qt

class DateWidget(QWidget):
    def __init__(self):
        super().__init__()
        self.initUI()
        
    def initUI(self):
        layout = QVBoxLayout()
        
        self.dateCheckin = QDateEdit()
        self.dateCheckout = QDateEdit()
        self.dateCheckin.setCalendarPopup(True)
        self.dateCheckout.setCalendarPopup(True)
        
        self.dateCheckin.dateChanged.connect(self.checkin_changed)
        self.dateCheckout.dateChanged.connect(self.checkout_changed)
        
        layout.addWidget(self.dateCheckin)
        layout.addWidget(self.dateCheckout)
        
        self.setLayout(layout)
        
    def checkin_changed(self, checkin):
        checkout = self.dateCheckout.date()
        if checkin > checkout:
            self.dateCheckin.setDate(checkout)
            self.dateCheckout.setDate(checkin)
        elif checkin == checkout:
            if checkout == QDate(1752, 9, 14):
                self.dateCheckout.setDate(checkout.addDays(1))
            else:
                self.dateCheckin.setDate(checkin.addDays(-1))
                
    def checkout_changed(self, checkout):
        checkin = self.dateCheckin.date()
        if checkout < checkin:
            self.dateCheckout.setDate(checkin)
            self.dateCheckin.setDate(checkout)
        elif checkout == checkin:
            if checkin == QDate(9999, 12, 31):
                self.dateCheckin.setDate(checkin.addDays(-1))
            else:
                self.dateCheckout.setDate(checkout.addDays(1))

if __name__ == '__main__':
    import sys
    app = QApplication(sys.argv)
    window = DateWidget()
    window.show()
    sys.exit(app.exec_())

Im using python 3.10.13 and pyqt5 5.15.9 (can't upgrade python cause other reasons)

Edit: The "magic dates" i filter for are the max and min dates that qt designer shows for the QDateEdits. The idea behind their implementation was to workaround possible "lower then the lowest date and vise versa issues/possible errors"

Edit2: Here an code example, where at least the swap works correctly for numpad input (but not calendarpopup). I hope you get my confusion: Setting checkin > checkout by numpad vs by calendarpopup.

from PyQt5.QtWidgets import QApplication, QDateEdit, QVBoxLayout, QWidget
from PyQt5.QtCore import QDate

class DateWidget(QWidget):
    def __init__(self):
        super().__init__()
        self.initUI()
        
    def initUI(self):
        layout = QVBoxLayout()
        
        self.checkin_date = QDateEdit()
        self.checkout_date = QDateEdit()
        self.checkin_date.setCalendarPopup(True)
        self.checkout_date.setCalendarPopup(True)
        
        self.checkin_date.dateChanged.connect(self.check_and_swap_dates)
        self.checkout_date.dateChanged.connect(self.check_and_swap_dates)
        
        layout.addWidget(self.checkin_date)
        layout.addWidget(self.checkout_date)
        
        self.setLayout(layout)
        
    def check_and_swap_dates(self):
        checkin = self.checkin_date.date()
        checkout = self.checkout_date.date()
        
        if checkin > checkout:
            self.checkin_date.setDate(checkout)
            self.checkout_date.setDate(checkin)

if __name__ == '__main__':
    import sys
    app = QApplication(sys.argv)
    window = DateWidget()
    window.show()
    sys.exit(app.exec_())


Solution

  • The QDateTimeEdit widget is possibly one of the most complex in the standard Qt widget library, and, along with QAbstractSpinBox based widgets, its complexity makes it a bit ugly to work with.

    In this case, the issue is caused by the internal interactions of the QCalendarWidget (the popup) with the QDateTimeEdit widget.

    The calendar is actually shown in a parent container (similarly to what QComboBox does for its item view) that adds further complexity to event handling that can cause further delayed signals and events, and such increased complexity is augmented by the fact that by doing setDate() you implicitly cause at least one dateChange signal on the target widget, which will call again the connected function.

    This is not a terrible problem for simpler widgets, since almost all Qt objects ignore attempts to change property values that do not result in actual changes: if the new value is identical to the current, nothing happens, and if a signal notifier for that property exists, it will not be emitted.

    In this case, though, the above mentioned complexities makes things worse, because you're attempting to access properties of two different widgets, with both having a signal notifier for the same property call the same function. The presence of another widget (the calendar popup) adds a bit of madness into the mix.

    What actually happens is that calling setDate() with a different date in a function connected to the dateChanged signal of one QDateTimeEdit, will result in internally setting the date at least twice when the date is set from the calendar widget.

    Try to change the connected function to the following, and you'll see that it will be called twice when editing the spinbox (using arrow keys or by typing a new date), and four times when using the calendar widget:

        def check_and_swap_dates(self):
            checkin = self.checkin_date.date()
            checkout = self.checkout_date.date()
    
            if checkin > checkout:
                print('attempting to fix the date', checkin.toString())
                self.checkin_date.setDate(QDate.currentDate())
    

    Note the output of the print() function: the first time the date is the new date, while the second is the previous one; when using the calendar widget, you'll get a repeated result.

    Even blocking signals through widget.blockSignals(True) or QSignalBlocker doesn't suffice, since the second signal will be emitted later. With the following code, the signal will still be emitted twice when using the calendar widget:

        def check_and_swap_dates(self):
            checkin = self.checkin_date.date()
            checkout = self.checkout_date.date()
    
            if checkin > checkout:
                print('attempting to fix the date', checkin.toString())
                with QSignalBlocker(self.checkin_date):
                    self.checkin_date.setDate(QDate.currentDate())
    

    When attempting to change both widgets, things get tricky: when the first signal is emitted, the checkin is greater, so we swap dates, but this causes the checkout to correspond to the checkin; when the signal is emitted again, the checkout is now the old checkin and equal to the "wrong" checkin, so the checkin widget doesn't get restored because the checkin > checkout condition isn't met anymore.

    Considering the above, we need to split things in two parts:

    1. update the sender (the widget that emitted the signal) ensuring that further changes are not emitted unnecessarily;
    2. delay the update of the other widget;
        def check_and_swap_dates(self):
            checkin = self.checkin_date.date()
            checkout = self.checkout_date.date()
    
            if checkin > checkout:
                if self.sender() == self.checkin_date:
                    with QSignalBlocker(self.checkin_date):
                        self.checkin_date.setDate(checkout)
                    targetWidget = self.checkout_date
                    targetDate = checkin
                else:
                    with QSignalBlocker(self.checkout_date):
                        self.checkout_date.setDate(checkin)
                    targetWidget = self.checkin_date
                    targetDate = checkout
    
                def updateTarget():
                    targetWidget.setDate(targetDate)
                    targetWidget.setFocus()
    
                QTimer.singleShot(0, updateTarget)
    

    Finally, an important suggestion about UX. As you probably noted, I added a setFocus() call on the "swapped" widget, which is important to let the user know that something has changed.

    In reality, though, all this is not a good idea: when editing "logically ordered" fields, that order should be maintained, even considering the input focus and the context of each field. If the user edits the start date, we should assume that that is the date reference they want to change, so we either prevent entering invalid input (eg. a start date limits the minimumDate() of the end date), or we update the related field (set the end date equal to the start if it's greater).

    The former approach can be sometimes ideal, but can also make things awkward: if the user selected a date range of a week and wants to change the start to the next week, they will need do things in the opposite way: first change the end date, then the start one.
    The latter is usually better, but doesn't consider a previously date range: considering the case above; a possible further approach could consider previous date range (before the change) and eventually update the target date by shifting by the source date with range.