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