I'm trying to style my QcalendarWidget using CSS in PySide2, an set my maximum date to 22/12/2022. I'm able to change the color of text for next month to green and normal date to white, but is there any way to change the color for the date in between? (ie. from 22/12/2022 to 08/01/2023)
#qt_calendar_calendarview {
outline: 0px;
selection-background-color: #43ace6;
alternate-background-color: #2c313c;
background_color:rgb(170, 0, 0)
}
QCalendarWidget QAbstractItemView:!enabled {
color:"green"
}
QCalendarWidget QAbstractItemView:enabled{
color:"white"
}
Unfortunately, it's not possible using style sheets nor the palette.
There are some possible solutions, though.
Override paintCell()
This is the simplest possibility, as we can use paintCell() to draw the contents. Unfortunately, this has some limitations: we only get the painter, the rectangle and the date, meaning that it's our complete responsibility to choose how the cell and date would be drawn, and it may not be consistent with the rest of the widget (specifically, the headers).
Set the date text format
QCalendarWidget provides setDateTextFormat(), which allows setting a specific QTextCharFormat for any arbitrary date.
The trick is to set the format for dates outside the range within the minimum/maximum month: the assumption is that the calendar is not able to switch to a month that is outside the available date range, so we only need to set the formats for these specific days of the month boundaries.
class CustomCalendar(QCalendarWidget):
def fixDateFormats(self):
fmt = QTextCharFormat()
# clear existing formats
self.setDateTextFormat(QDate(), fmt)
fmt.setForeground(QBrush(QColor('green')))
for ref, delta in ((self.minimumDate(), -1), (self.maximumDate(), 1)):
month = ref.month()
date = ref.addDays(delta)
while date.month() == month:
self.setDateTextFormat(date, fmt)
date = date.addDays(delta)
def setDateRange(self, minimum, maximum):
super().setDateRange(minimum, maximum)
self.fixDateFormats()
def setMinimumDate(self, date):
super().setMinimumDate(date)
self.fixDateFormats()
def setMaximumDate(self, date):
super().setMaximumDate(date)
self.fixDateFormats()
The only drawback of this is that it doesn't allow to change the color of the cells that belong to another month, and while it's possible to use the stylesheet as written by the OP, this doesn't cover the exception of weekends.
Use a customized item delegate
This solution is a bit too complex, but is also the most ideal, as it's completely consistent with the widget and style, while also allowing some further customization.
Since the calendar is actually a composite widget that uses a QTableView to display the dates, this means that, just like any other Qt item view, we can override its delegate.
The default delegate is a QItemDelegate (the much simpler version of QStyledItemDelegates normally used in item views). While we could manually paint the content of the cell by completely overriding the delegate's paint(), but at that point the first solution would be much simpler. Instead we use the default painting and differentiate when/how the actual display value is shown: if it's within the calendar range, we leave the default behavior, otherwise we alter the QStyleOptionViewItem with our custom color and explicitly call drawDisplay().
class CalDelegate(QItemDelegate):
cachedOpt = QStyleOptionViewItem()
_disabledColor = None
def __init__(self, calendar):
self.calendar = calendar
self.view = calendar.findChild(QAbstractItemView)
super().__init__(self.view)
self.view.setItemDelegate(self)
self.dateReference = self.calendar.yearShown(), self.calendar.monthShown()
self.calendar.currentPageChanged.connect(self.updateReference)
def disabledColor(self):
return self._disabledColor or self.calendar.palette().color(
QPalette.Disabled, QPalette.Text)
def setDisabledColor(self, color):
self._disabledColor = color
self.view.viewport().update()
def updateReference(self, year, month):
self.dateReference = year, month
def dateForCell(self, index):
day = index.data()
row = index.row()
if self.calendar.horizontalHeaderFormat():
if row == 0:
return
row -= 1
col = index.column()
if self.calendar.verticalHeaderFormat():
if col == 0:
return
col -= 1
year, month = self.dateReference
if row < 1 and day > 7:
# previous month
month -= 1
if month < 1:
month = 12
year -= 1
elif row > 3 and day < 15:
# next month
month += 1
if month > 12:
month = 1
year += 1
return QDate(year, month, day)
def drawDisplay(self, qp, opt, rect, text):
if self.doDrawDisplay:
super().drawDisplay(qp, opt, rect, text)
else:
self.cachedOpt = QStyleOptionViewItem(opt)
def paint(self, qp, opt, index):
date = self.dateForCell(index)
self.doDrawDisplay = not bool(date)
super().paint(qp, opt, index)
if self.doDrawDisplay:
return
year, month = self.dateReference
if (
date.month() != month
or not self.calendar.minimumDate() <= date <= self.calendar.maximumDate()
):
self.cachedOpt.palette.setColor(
QPalette.Text, self.disabledColor())
super().drawDisplay(qp, self.cachedOpt,
self.cachedOpt.rect, str(index.data()))
app = QApplication([])
cal = QCalendarWidget()
delegate = CalDelegate(cal)
delegate.setDisabledColor(QColor('green'))
cal.setDateRange(QDate(2022, 12, 4), QDate(2023, 1, 27))
cal.show()
app.exec()
I am not sure of a way using css, it is possible using code though.
If you override the QCalenderWidget.paintCell method you can style each date individually.
For example:
class Calendar(QCalendarWidget):
def __init__(self, parent) -> None:
super().__init__(parent)
self.start_date = QDate(2022, 12, 22)
self.end_date = QDate(2023, 8, 1)
def paintCell(self, painter, rect, date):
if date.daysTo(self.end_date) > 0 and date.daysTo(self.start_date) < 0:
painter.setPen("green")
brush = painter.brush()
brush.setColor("black")
brush.setStyle(Qt.SolidPattern)
painter.setBrush(brush)
painter.drawRect(rect)
painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, str(date.day()))
else:
super().paintCell(painter, rect, date)
Related
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...
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).
i want to implement an event calendar. i am faced with the problem of displaying the closest event to today's date. to find the nearest date, i use __gte in queryset, after queryset finds all the nearest dates, I want to highlight the first one with a different color here is my solution could you tell me what i'm doing wrong?
This is my Model
class Events(models.Model):
title = models.CharField(max_length=100)
slug = models.SlugField()
start_time = models.DateTimeField()
end_time = models.DateTimeField()
def __str__(self):
return self.title
#property
def get_html_url(self):
url = reverse('cal:events', args=(self.slug,))
return f'<a href="{url}">'
And my HTMLCalendar
from datetime import datetime, timedelta
from calendar import HTMLCalendar
from .models import Events
class Calendar(HTMLCalendar):
def __init__(self, year=None, month=datetime.now().month):
self.year = year
self.month = month
super(Calendar, self).__init__()
# formats a day as a td
# filter events by day
def formatday(self, day, events):
events_per_day = events.filter(start_time__day=day)
d = ''
if Events.objects.filter(start_time__day=day, start_time__month=self.month).exists():
for event in events_per_day:
d += f'{event.get_html_url}'
if day != 0:
ev = Events.objects.filter(start_time__gt=datetime.now()).first()
if ev:
return f"<td>{d}<span style='color:red;' class='date'>{day}</span></a></td>"
else:
return f"<td>{d}<span style='color:aliceblue;' class='date'>{day}</span></a></td>"
return '<td></td>'
else:
if day != 0:
return f"<td><b><span class='date'>{day}</span> </b></td>"
return '<td></td>'
# formats a week as a tr
def formatweek(self, theweek, events):
week = ''
for d, weekday in theweek:
week += self.formatday(d, events)
return f'<tr> {week} </tr>'
# formats a month as a table
# filter events by year and month
def formatmonth(self, withyear=True, ):
events = Events.objects.filter(start_time__year=self.year, start_time__month=self.month)
cal = f'<table border="0" cellpadding="0" cellspacing="0" class="calendar">\n'
cal += f'{self.formatmonthname(self.year, self.month, withyear=withyear)}\n'
cal += f'{self.formatweekheader()}\n'
for week in self.monthdays2calendar(self.year, self.month):
cal += f'{self.formatweek(week, events)}\n'
return cal
my solution is in the format day () function I am executing a query and if there is the first element I want to highlight it in red and paint over all the others with a different color
I'd consider moving the formatting to your template. You could then serve the first variable with the red formating and loop through the remaining values with the other formatting choice.
Use the view to generate the data you'll need in the template in the order you'd like to use it. Handle the presentation in the template.
I'm trying to implement a QDateTimeEdit subclass that will show only time part (display format "HH:mm") and a calendar icon, clicking on which will trigger calendar popup, allowing user to set date part. I need it to have a shorter representation of datetime, because changing date is needed only rarely.
Adding .setCalendarPopup(True) is not enough here, because it triggers only if the format string includes date part, which is not the case.
What I have tried (button embedding was taken from this answer):
class ShortDatetimeEdit(QDateTimeEdit):
def __init__(self, *args, **kwargs):
super(ShortDatetimeEdit, self).__init__(*args, **kwargs)
self.setDisplayFormat("HH:mm")
self.setCurrentSection(QDateTimeEdit.MinuteSection)
self.setWrapping(True)
self.setCalendarPopup(True)
self._setup_date_picker()
def _setup_date_picker(self):
self.calendar_trigger = QToolButton(self)
self.calendar_trigger.setCursor(Qt.PointingHandCursor)
self.calendar_trigger.setFocusPolicy(Qt.NoFocus)
self.calendar_trigger.setIcon(QIcon("path/to/icon"))
self.calendar_trigger.setStyleSheet("background: transparent; border: none;")
self.calendar_trigger.setToolTip("Show calendar")
layout = QHBoxLayout(self)
layout.addWidget(self.calendar_trigger, 0, Qt.AlignRight)
layout.setSpacing(0)
self.calendar_trigger.clicked.connect(self._show_calendar)
def _show_calendar(self, _s):
self.calendarWidget().show() # everything breaks here, because calendarWidget() returns None
My solution doesn't work as self.calendarWidget() returns None (see last line).
So the question is, is there any way to trigger calendar popup (and use the value provided by it) from QDateTimeEdit without using date section in display format string?
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)
I know that for QComboBox there is method called editable with which you can manually edit current value:
combo.setEditable(True). I could not find something similar with QDateEdit. I want user be able to delete entire date string and manually enter just a year value or leave it blank.
Editing such as what is requested is not possible, since QDateEdit (which is based on QDateTimeEdit) inherits from QAbstractSpinBox, which already contains a QLineEdit widget, but has strict rules about what can be typed.
While subclassing QDateEdit is possible, it could be a bit complex, as it uses advanced controls (most importantly the "current section", which tells what part of the date is being edited). Switching date formats ("yyyy-MM-dd" and "yyyy") is possible, but not automatically, and it would take lots of checking, possibly with regex and advanced text cursor control.
In my experience, changing the keyboard behavior of QDateTimeEdit classes is really hard to achieve without bugs or unexpected behavior, and since the main features of the spinbox (arrows up/down and up/down arrow keys) are not required here, you can create a control that embeds both a QLineEdit and a child QCalendarWidget that is opened as a popup using a button.
I also implemented a small validator to ignore most invalid inputs (but you could still type an invalid date, for example 2020-31-31).
class SimpleDateValidator(QtGui.QValidator):
def validate(self, text, pos):
if not text:
return self.Acceptable, text, pos
fmt = self.parent().format()
_sep = set(fmt.replace('y', '').replace('M', '').replace('d', ''))
for l in text:
# ensure that the typed text is either a digit or a separator
if not l.isdigit() and l not in _sep:
return self.Invalid, text, pos
years = fmt.count('y')
if len(text) <= years and text.isdigit():
return self.Acceptable, text, pos
if QtCore.QDate.fromString(text, fmt).isValid():
return self.Acceptable, text, pos
return self.Intermediate, text, pos
class DateEdit(QtWidgets.QWidget):
customFormat = 'yyyy-MM-dd'
def __init__(self, parent=None):
super().__init__(parent)
self.setSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Fixed)
layout = QtWidgets.QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
self.lineEdit = QtWidgets.QLineEdit()
layout.addWidget(self.lineEdit)
self.lineEdit.setMaxLength(len(self.format()))
self.validator = SimpleDateValidator(self)
self.lineEdit.setValidator(self.validator)
self.dropDownButton = QtWidgets.QToolButton()
layout.addWidget(self.dropDownButton)
self.dropDownButton.setIcon(
self.style().standardIcon(QtWidgets.QStyle.SP_ArrowDown))
self.dropDownButton.setMaximumHeight(self.lineEdit.sizeHint().height())
self.dropDownButton.setCheckable(True)
self.dropDownButton.setFocusPolicy(QtCore.Qt.NoFocus)
self.calendar = QtWidgets.QCalendarWidget()
self.calendar.setWindowFlags(QtCore.Qt.Popup)
self.calendar.installEventFilter(self)
self.dropDownButton.pressed.connect(self.showPopup)
self.dropDownButton.released.connect(self.calendar.hide)
self.lineEdit.editingFinished.connect(self.editingFinished)
self.calendar.clicked.connect(self.setDate)
self.calendar.activated.connect(self.setDate)
self.setDate(QtCore.QDate.currentDate())
def editingFinished(self):
# optional: clear the text if the date is not valid when leaving focus;
# this will only work if *NO* validator is set
if self.calendar.isVisible():
return
if not self.isValid():
self.lineEdit.setText('')
def format(self):
return self.customFormat or QtCore.QLocale().dateFormat(QtCore.QLocale.ShortFormat)
def setFormat(self, format):
# only accept numeric date formats
if format and 'MMM' in format or 'ddd' in format:
return
self.customFormat = format
self.setDate(self.calendar.selectedDate())
self.calendar.hide()
self.lineEdit.setMaxLength(self.format())
self.validator.setFormat(self.format())
def text(self):
return self.lineEdit.text()
def date(self):
if not self.isValid():
return None
date = QtCore.QDate.fromString(self.text(), self.format())
if date.isValid():
return date
return int(self.text())
def setDate(self, date):
self.lineEdit.setText(date.toString(self.format()))
self.calendar.setSelectedDate(date)
self.calendar.hide()
def setDateRange(self, minimum, maximum):
self.calendar.setDateRange(minimum, maximum)
def isValid(self):
text = self.text()
if not text:
return False
date = QtCore.QDate.fromString(text, self.format())
if date.isValid():
self.setDate(date)
return True
try:
year = int(text)
start = self.calendar.minimumDate().year()
end = self.calendar.maximumDate().year()
if start <= year <= end:
return True
except:
pass
return False
def hidePopup(self):
self.calendar.hide()
def showPopup(self):
pos = self.lineEdit.mapToGlobal(self.lineEdit.rect().bottomLeft())
pos += QtCore.QPoint(0, 1)
rect = QtCore.QRect(pos, self.calendar.sizeHint())
self.calendar.setGeometry(rect)
self.calendar.show()
self.calendar.setFocus()
def eventFilter(self, source, event):
# press or release the button when the calendar is shown/hidden
if event.type() == QtCore.QEvent.Hide:
self.dropDownButton.setDown(False)
elif event.type() == QtCore.QEvent.Show:
self.dropDownButton.setDown(True)
return super().eventFilter(source, event)
def keyPressEvent(self, event):
if event.key() in (QtCore.Qt.Key_Down, QtCore.Qt.Key_F4):
if not self.calendar.isVisible():
self.showPopup()
super().keyPressEvent(event)
Does anyone know of a double valued gauge in wxPython? I would like to show the range of a set of data that has been selected, I have the max and min values of the gauge and then I'd like to highlight the region of the gauge between two values. The normal wx.Gauge highlights from the gauge minimum (always the bottom of the gauge) to the set value.
Thanks
I'm not aware of any built-in widgets with the appearance of wx.Gauge that can do what you're looking for.
One option with a different appearance is RulerCtrl, which can give you two value indicators on a scale with whatever range you want. It just takes a bit of extra work to adapt it since not all of its properties are exposed in the listed methods. Quick example:
import wx
import wx.lib.agw.rulerctrl as rc
class MinMax(rc.RulerCtrl):
def __init__(self, parent, range_min, range_max, orient=wx.HORIZONTAL):
rc.RulerCtrl.__init__(self, parent, orient)
self.SetRange(range_min, range_max)
self.LabelMinor(False)
self.range_min = range_min
self.range_max = range_max
self.AddIndicator(wx.NewId(), range_min)
self.AddIndicator(wx.NewId(), range_max)
self.Bind(rc.EVT_INDICATOR_CHANGING, self.OnIndicatorChanging)
def GetMinIndicatorValue(self):
return self._indicators[0]._value
def GetMaxIndicatorValue(self):
return self._indicators[1]._value
def SetMinIndicatorValue(self, value):
# Value must be within range and <= Max indicator value.
if value < self.range_min or value > self.GetMaxIndicatorValue():
raise ValueError('Out of bounds!')
self._indicators[0]._value=value
self.Refresh()
def SetMaxIndicatorValue(self, value):
# Value must be within range and >= Min indicator value.
if value > self.range_max or value < self.GetMinIndicatorValue():
raise ValueError('Out of bounds!')
self._indicators[1]._value=value
self.Refresh()
def OnIndicatorChanging(self, evt):
# Eat the event so the user can't change values manually.
# Do some validation here and evt.Skip() if you want to allow it.
# Related: EVT_INDICATOR_CHANGED
pass
class MainWindow(wx.Frame):
def __init__(self, parent, id, title):
wx.Frame.__init__(self, parent, id, title)
self.minmax = MinMax(self, 0, 100)
self.minmax.SetSpacing(20)
self.minmax.SetMinIndicatorValue(30)
self.minmax.SetMaxIndicatorValue(84)
self.Show()
app = wx.App(redirect=False)
frame = MainWindow(None, wx.ID_ANY, "Range Indicator")
app.MainLoop()