pythonpyqt5keyboardqspinboxqtimeedit

QTimeEdit locks hour spining with range less than one hour with keyboardTracking deactivated


I have a QTimeEdit in Python with a predefined range less than one hour, let's say from 08:45:00 to 09:15:00. I read about the problematic of entering a new value which gets out these limits when keying (https://doc.qt.io/qt-6/qdatetimeedit.html#keyboard-tracking) and set the keyboardTracking to False. I set the default value to minimum (so 08:45:00), then I can't change it to values above 08:59:59 because the spin arrows are deactivated for hour field, and I can't change 08 to 09 in hour field with the numpad neither.

Do you experience the same limitations for QTimeEdit especially ?

Btw, the wrapping function isn't adapted to times as it loops on the same field without incrementing the next one...


Solution

  • tl;dr

    Some solutions already exist for this issue only related to the wheel and arrow buttons, but they don't consider keyboard editing.

    In order to achieve that, it's necessary to override the validate() function (inherited from QAbstractSpinBox) and eventually try to fix up its contents:

    class FlexibleTimeEdit(QTimeEdit):
        def validate(self, input, pos):
            valid, newInput, newPos = super().validate(input, pos)
            if valid == QValidator.Invalid:
                possible = QTime.fromString(newInput)
                if possible.isValid():
                    fixed = max(self.minimumTime(), min(possible, self.maximumTime()))
                    newInput = fixed.toString(self.displayFormat())
                    valid = QValidator.Acceptable
            return valid, newInput, newPos
    

    A more complete solution

    Since these aspects are actually common within the other related classes (QDateTimeEdit and QDateEdit), I propose a more comprehensive fix that could be used as a mixin with all three types, providing keyboard input and arrow/wheel fixes for these aspects.

    The fix works by using an "abstract" class that has to be used with multiple inheritance (with it taking precedence over the Qt class), and provides the following:

    Note that this is a bit advanced, so I strongly advise to carefully study the following code in order to understand how it works.

    from PyQt5.QtCore import *
    from PyQt5.QtGui import *
    from PyQt5.QtWidgets import *
    
    class _DateTimeEditFix(object):
        _fullRangeStepEnabled = False
        _wheelFollowsMouse = True
        _deltaFuncs = {
            QDateTimeEdit.YearSection: lambda obj, delta: obj.__class__.addYears(obj, delta), 
            QDateTimeEdit.MonthSection: lambda obj, delta: obj.__class__.addMonths(obj, delta), 
            QDateTimeEdit.DaySection: lambda obj, delta: obj.__class__.addDays(obj, delta), 
            QDateTimeEdit.HourSection: lambda obj, delta: obj.__class__.addSecs(obj, delta * 3600), 
            QDateTimeEdit.MinuteSection: lambda obj, delta: obj.__class__.addSecs(obj, delta * 60), 
            QDateTimeEdit.SecondSection: lambda obj, delta: obj.__class__.addSecs(obj, delta), 
            QDateTimeEdit.MSecSection: lambda obj, delta: obj.__class__.addMSecs(obj, delta), 
        }
        _typeRefs = {
            QTimeEdit: ('Time', QTime), 
            QDateEdit: ('Date', QDate), 
            QDateTimeEdit: ('DateTime', QDateTime)
        }
        _sectionTypes = {
            QDateTimeEdit.YearSection: 'date', 
            QDateTimeEdit.MonthSection: 'date', 
            QDateTimeEdit.DaySection: 'date', 
            QDateTimeEdit.HourSection: 'time', 
            QDateTimeEdit.MinuteSection: 'time', 
            QDateTimeEdit.MSecSection: 'time'
        }
    
        def __init__(self, *args, **kwargs):
            super().__init__(*args, **kwargs)
            for cls in QTimeEdit, QDateEdit, QDateTimeEdit:
                if isinstance(self, cls):
                    ref, self._baseType = self._typeRefs[cls]
                    break
            else:
                raise TypeError('Only QDateTimeEdit subclasses can be used')
    
            self._getter = getattr(self, ref[0].lower() + ref[1:])
            self._setter = getattr(self, 'set' + ref)
            self._minGetter = getattr(self, 'minimum' + ref)
            self._maxGetter = getattr(self, 'maximum' + ref)
    
        @pyqtProperty(bool)
        def fullRangeStepEnabled(self):
            '''
                Enable the arrows if the current value is still within the *full*
                range of the widget, even if the current section is at the minimum
                or maximum of its value.
    
                If the value is False (the default), using a maximum time of 20:30, 
                having the current time at 20:29 and the current section at
                HourSection, the up arrow will be disabled. If the value is set to
                True, the arrow is enabled, and going up (using arrow keys or mouse
                wheel) will set the new time to 20:30.
            '''
            return self._fullRangeStepEnabled
    
        @fullRangeStepEnabled.setter
        def fullRangeStepEnabled(self, enabled):
            if self._fullRangeStepEnabled != enabled:
                self._fullRangeStepEnabled = enabled
                self.update()
    
        def setFullRangeStepEnabled(self, enabled):
            self.fullRangeStepEnabled = enabled
    
        @pyqtProperty(bool)
        def wheelFollowsMouse(self):
            '''
                By default, QDateTimeEdit "scrolls" with the mouse wheel updating
                the section in which the cursor currently is, even if the mouse
                pointer hovers another section.
                Setting this property to True always tries to update the section
                that is *closer* to the mouse cursor.
            '''
            return self._wheelFollowsMouse
    
        @wheelFollowsMouse.setter
        def wheelFollowsMouse(self, follow):
            self._wheelFollowsMouse = follow
    
        def wheelEvent(self, event):
            if self._wheelFollowsMouse:
                edit = self.lineEdit()
                edit.setCursorPosition(edit.cursorPositionAt(event.pos() - edit.pos()))
            super().wheelEvent(event)
    
        def stepBy(self, steps):
            section = self.currentSection()
            if section in self._deltaFuncs:
                new = self._deltaFuncs[section](self._getter(), steps)
                self._setter(
                    max(self._minGetter(), min(new, self._maxGetter()))
                )
                self.setSelectedSection(section)
            else:
                super().stepBy(steps)
    
        def _stepPossible(self, value, target, section):
            if self._fullRangeStepEnabled:
                return value < target
            if value > target:
                return False
            if section in self._deltaFuncs:
                return self._deltaFuncs[section](value, 1) < target
            return False
    
        def stepEnabled(self):
            enabled = super().stepEnabled()
            current = self._getter()
            section = self.currentSection()
            if (
                not enabled & self.StepUpEnabled 
                and self._stepPossible(current, self._maxGetter(), section)
            ):
                enabled |= self.StepUpEnabled
            if (
                not enabled & self.StepDownEnabled
                and self._stepPossible(self._minGetter(), current, section)
            ):
                enabled |= self.StepDownEnabled
            return enabled
    
        def validate(self, input, pos):
            valid, newInput, newPos = super().validate(input, pos)
            if valid == QValidator.Invalid:
                # note: Qt6 deprecated some fromString() forms and QLocale functions
                # should be preferred instead; see the documentation
                possible = self._baseType.fromString(newInput, self.displayFormat())
                if possible.isValid():
                    m = self._minGetter()
                    M = self._maxGetter()
                    fixedUp = max(m, min(possible, M))
                    if (
                        self._fullRangeStepEnabled
                        or m <= fixedUp <= M
                    ):
                        newInput = fixedUp.toString(self.displayFormat())
                        valid = QValidator.Acceptable
            return valid, newInput, newPos
    
    
    class BetterDateTimeSpin(_DateTimeEditFix, QDateTimeEdit): pass
    class BetterTimeSpin(_DateTimeEditFix, QTimeEdit): pass
    class BetterDateSpin(_DateTimeEditFix, QDateEdit): pass
    
    
    if __name__ == '__main__':
        import sys
        app = QApplication(sys.argv)
        test = QWidget()
        layout = QVBoxLayout(test)
    
        fullRangeCheck = QCheckBox('Allow full range')
        layout.addWidget(fullRangeCheck)
    
        timeSpin = BetterTimeSpin(
            displayFormat='hh:mm:ss', 
            minimumTime=QTime(8, 45, 0), 
            maximumTime=QTime(9, 15, 50), 
        )
        layout.addWidget(timeSpin)
    
        dateSpin = BetterDateTimeSpin(
            displayFormat='dd/MM/yy hh:mm', 
            minimumDateTime=QDateTime(2022, 9, 15, 19, 25), 
            maximumDateTime=QDateTime(2023, 2, 12, 4, 58), 
        )
        layout.addWidget(dateSpin)
    
        fullRangeCheck.toggled.connect(lambda full: [
            timeSpin.setFullRangeStepEnabled(full), 
            dateSpin.setFullRangeStepEnabled(full), 
        ])
    
        test.show()
        sys.exit(app.exec())
    

    Note: as with the standard QTimeEdit control, it's still not possible to use the time edit with a range having a minimum time greater than the maximum (ie: from 20:00 to 08:00).