QDateTimeEdit is a special type of widget that implements its features also based on its display format. Long story short, if you set a display format that doesn't show any date value, it will behave exactly like a QTimeEdit (and if you don't show time values, it will behave like a QDateEdit).
This is clearly by-design and, I suppose, for optimization reasons, but that approach also presents some issues, exactly like in your case.
In fact, you cannot even set your own calendar widget if the display format doesn't show any date value (Qt will warn you about this).
In order to achieve what you want, some overriding of existing method is required.
Also consider that you cannot try to "hack" your way through this by adding a child widget. Not only your implementation will not work as expected, but it will also probably result in unexpected behavior and painting artifacts, which will make the widget at least ugly (if not even unusable). The best approach usually is to try to work with what the widget already provides, even if it might seem more complex than it would appear. In this case, you can manually paint the combobox arrow that would be shown for a "normal" QDateTimeEdit with setCalendarPopup(True)
by using QStyle functions both for painting and click detection.
Finally, due to the design choices explained before, each time the date, the datetime or the display format are changed, the date range will be limited to the current date if the display format doesn't show date values, so you need to set the range back each time, otherwise it won't be possible to select any other date from the calendar.
Here's a possible implementation:
class ShortDatetimeEdit(QDateTimeEdit):
def __init__(self, *args, **kwargs):
super(ShortDatetimeEdit, self).__init__(*args, **kwargs)
self.setCurrentSection(QDateTimeEdit.MinuteSection)
self.setWrapping(True)
self.setDisplayFormat("HH:mm")
self._calendarPopup = QCalendarWidget()
self._calendarPopup.setWindowFlags(Qt.Popup)
self._calendarPopup.setFocus()
self._calendarPopup.activated.connect(self.setDateFromPopup)
self._calendarPopup.clicked.connect(self.setDateFromPopup)
def getControlAtPos(self, pos):
opt = QStyleOptionComboBox()
opt.initFrom(self)
opt.editable = True
opt.subControls = QStyle.SC_All
return self.style().hitTestComplexControl(
QStyle.CC_ComboBox, opt, pos, self)
def showPopup(self):
self._calendarPopup.setDateRange(self.minimumDate(), self.maximumDate())
# the following lines are required to ensure that the popup will always
# be visible within the current screen geometry boundaries
rect = self.rect()
isRightToLeft = self.layoutDirection() == Qt.RightToLeft
pos = rect.bottomRight() if isRightToLeft else rect.bottomLeft()
pos2 = rect.topRight() if isRightToLeft else rect.topLeft()
pos = self.mapToGlobal(pos)
pos2 = self.mapToGlobal(pos2)
size = self._calendarPopup.sizeHint()
for screen in QApplication.screens():
if pos in screen.geometry():
geo = screen.availableGeometry()
break
else:
geo = QApplication.primaryScreen().availableGeometry()
if isRightToLeft:
pos.setX(pos.x() - size.width())
pos2.setX(pos2.x() - size.width())
if pos.x() < geo.left():
pos.setX(max(pos.x(), geo.left()))
elif pos.x() + size.width() > screen.right():
pos.setX(max(pos.x() - size.width(), geo.right() - size.width()))
else:
if pos.x() + size.width() > geo.right():
pos.setX(geo.right() - size.width())
if pos.y() + size.height() > geo.bottom():
pos.setY(pos2.y() - size.height())
elif pos.y() < geo.top():
pos.setY(geo.top())
if pos.y() < geo.top():
pos.setY(geo.top())
if pos.y() + size.height() > geo.bottom():
pos.setY(geo.bottom() - size.height())
self._calendarPopup.move(pos)
self._calendarPopup.show()
def setDateFromPopup(self, date):
self.setDate(date)
self._calendarPopup.close()
self.setFocus()
def setDate(self, date):
if self.date() == date:
return
dateRange = self.minimumDate(), self.maximumDate()
time = self.time()
# when the format doesn't display the date, QDateTimeEdit tries to reset
# the date range and emits an incorrect dateTimeChanged signal, so we
# need to block signals and emit the correct date change afterwards
self.blockSignals(True)
super().setDateTime(QDateTime(date, time))
self.setDateRange(*dateRange)
self.blockSignals(False)
self.dateTimeChanged.emit(self.dateTime())
def setDisplayFormat(self, fmt):
dateRange = self.minimumDate(), self.maximumDate()
super().setDisplayFormat(fmt)
self.setDateRange(*dateRange)
def mousePressEvent(self, event):
if self.getControlAtPos(event.pos()) == QStyle.SC_ComboBoxArrow:
self.showPopup()
def paintEvent(self, event):
# the "combobox arrow" is not displayed, so we need to draw it manually
opt = QStyleOptionSpinBox()
self.initStyleOption(opt)
optCombo = QStyleOptionComboBox()
optCombo.initFrom(self)
optCombo.editable = True
optCombo.frame = opt.frame
optCombo.subControls = opt.subControls
if self.hasFocus():
optCombo.activeSubControls = self.getControlAtPos(
self.mapFromGlobal(QCursor.pos()))
optCombo.state = opt.state
qp = QPainter(self)
self.style().drawComplexControl(QStyle.CC_ComboBox, optCombo, qp, self)
An important note. Even when using setWrapping(True)
, QDateTimeEdit (and its subclasses) don't follow the time logic: when scrolling down from 01:00
in the minute section, the result will be 01:59
, and the same happens for dates too. If you want to be able to actually step the other units, you need to override the stepBy()
method too; in this way, when you scroll down from 01:00
on the minute section, the result will be 00:59
, and if you scroll down from 00:00
it will go to 23:59
of the previous day.
def stepBy(self, step):
if self.currentSection() == self.MinuteSection:
newDateTime = self.dateTime().addSecs(60 * step)
elif self.currentSection() == self.HourSection:
newDateTime = self.dateTime().addSecs(3600 * step)
else:
super().stepBy(step)
return
if newDateTime.date() == self.date():
self.setTime(newDateTime.time())
else:
self.setDateTime(newDateTime)
Obviously, this is a simplification: for full shown date/time display format, you should add the related implementations.
def stepBy(self, step):
section = self.currentSection()
if section == self.MSecSection:
newDateTime = self.dateTime.addMSecs(step)
elif section == self.SecondSection:
newDateTime = self.dateTime.addSecs(step)
elif self.currentSection() == self.MinuteSection:
newDateTime = self.dateTime().addSecs(60 * step)
elif self.currentSection() == self.HourSection:
newDateTime = self.dateTime().addSecs(3600 * step)
elif section == self.DaySection:
newDateTime = self.dateTime.addDays(step)
elif section == self.MonthSection:
newDateTime = self.dateTime.addMonths(step)
elif section == self.YearSection:
newDateTime = self.dateTime.addYears(step)
else:
super().stepBy(step)
return
if newDateTime.date() == self.date():
self.setTime(newDateTime.time())
else:
self.setDateTime(newDateTime)
Finally (again!), if you want to ensure that the mouse wheel always responds to the section that is under the mouse cursor, you need to place the text cursor to the appropriate position before calling the default wheelEvent()
.
def wheelEvent(self, event):
cursorPosition = self.lineEdit().cursorPositionAt(event.pos())
if cursorPosition < len(self.lineEdit().text()):
letterAt = self.lineEdit().text()[cursorPosition - 1]
letterWidth = self.fontMetrics().width(letterAt) // 2
opt = QStyleOptionFrame()
opt.initFrom(self)
frameWidth = self.style().pixelMetric(
QStyle.PM_DefaultFrameWidth, opt, self)
letterWidth += frameWidth
pos = event.pos() - QPoint(letterWidth, 0)
otherCursorPosition = max(0, self.lineEdit().cursorPositionAt(pos))
if otherCursorPosition != cursorPosition:
cursorPosition = otherCursorPosition
self.lineEdit().setCursorPosition(max(0, cursorPosition))
super().wheelEvent(event)