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

126 Views Asked by At

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...

1

There are 1 best solutions below

1
On BEST ANSWER

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:

  • optionally override the wheel behavior by setting the cursor position based on the mouse position, allowing to update a specific section without using the keyboard or clicking to change it (i.e.: if the current section is the hour one, and the mouse is on the minutes, then the wheel will update the minutes);
  • updates the arrow buttons (and related stepBy() calls) depending on the available range, without limiting the range to the section: if the current hour is 23 and the current range allows past the midnight, stepping up will update the value accordingly;
  • the validation allows values within the full current range, without limiting it to the section range;

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).