pythonpython-3.xpyqtpyqt6

How to show the selected date with rounded corners in QCalendarWidget?


Implemented at the moment:

Implemented at the moment

The date in the layout does not match the contents of the calendar:

Layout. The date in the layout does not match the contents of the calendar

How to make a rounding for a user-selected date in QCalendar? I'm using PyQt6.

CalendarWidget.py:

from __init__ import *

class CalendarWidget(QCalendarWidget):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.setGridVisible(False)
        self.setVerticalHeaderFormat(QCalendarWidget.VerticalHeaderFormat.NoVerticalHeader)
        self.setHorizontalHeaderFormat(QCalendarWidget.HorizontalHeaderFormat.NoHorizontalHeader)
        self.setSelectedDate(QDate.currentDate())
        self.setNavigationBarVisible(False)

        for dayOff in (Qt.DayOfWeek.Saturday, Qt.DayOfWeek.Sunday):
            format = self.weekdayTextFormat(dayOff)
            format.setForeground(QColor("#CAD3F5"))
            self.setWeekdayTextFormat(dayOff, format)

    def paintCell(self, painter, rect, date):
        if not date.month() == self.selectedDate().month():
            painter.save()
            painter.setPen(QPen(QColor("#939ab7")))
            painter.drawText(rect, int(Qt.AlignmentFlag.AlignCenter), str(date.day()))
            painter.restore()
        else:
            QCalendarWidget.paintCell(self, painter, rect, date)

The fillet should be 45px, according to the layout.


Solution

  • QCalendarWidget internally uses a QTableView to show the date grid.

    There are various ways to achieve the wanted appearance, each one with its pros and cons.

    Overriding the table palette

    The selection is actually drawn internally by the item delegate using the view's palette, specifically the Highlight color role.

    If we override the view's palette by setting the color for that role as transparent, the selection won't be shown, and we can paint it on our own before calling the default implementation:

    class CalendarWidget(QCalendarWidget):
        def __init__(self, parent=None):
            ...
            table = self.findChild(QTableView)
            palette = table.palette()
            palette.setColor(
                QPalette.ColorRole.Highlight, Qt.GlobalColor.transparent)
            table.setPalette(palette)
    
        def paintCell(self, painter, rect, date):
            if not date.month() == self.selectedDate().month():
                painter.save()
                painter.setPen(QPen(QColor("#939ab7")))
                painter.drawText(rect, int(Qt.AlignmentFlag.AlignCenter), str(date.day()))
                painter.restore()
            else:
                if date == self.selectedDate():
                    painter.save()
                    painter.setBrush(self.palette().highlight())
                    painter.setPen(Qt.PenStyle.NoPen)
                    radius = min(rect.width(), rect.height()) / 2
                    painter.drawRoundedRect(rect, radius, radius)
                    painter.restore()
                QCalendarWidget.paintCell(self, painter, rect, date)
    

    This option is quite simple, but has some limitations. Most importantly, if style sheets are in use, and the selection-background-color property is set for item views, the palette may be reinitialized by the style, sometimes inconsistently and unpredictably.

    Using style sheets with a styled item delegate

    The table view of the calendar widget uses the basic QItemDelegate, which only inherits the colors set in the style sheet for the table (::item subcontrol properties are ineffective).

    One possibility is then to use a QStyledItemDelegate instead and set the style sheet accordingly:

    class CalendarWidget(QCalendarWidget):
        def __init__(self, parent=None):
            ...
            table = self.findChild(QTableView)
            table.setItemDelegate(QStyledItemDelegate(table))
            table.setStyleSheet('''
                QTableView::item:selected {
                    border: none;
                    border-radius: 5px;
            ''')
    

    Unfortunately this creates indirect issues.

    Most importantly, paintCell() override won't be called, because it's responsibility of the default delegate to call it (more on this in the next solution).

    Also, all items will be affected (including horizontal and vertical headers), showing more "stylized" display of items; since QSS don't allow per-item selectors, trying to override the background of items will completely ignore any color format set for specific dates through setDateTextFormat(), because QSS colors completely disregard the model's BackgroundRole and ForegroundRole when set setting background and color respectively.

    Finally, the radius of the rounded corners is hardcoded and cannot be based on the actual item size.

    Implement a custom delegate

    Another possibility is to rely on the simple QItemDelegate and override its paint() function. If the index is not selected, the default implementation is called, otherwise we draw the background, then change the option palette similar to what done above and finally call the default implementation.

    class CalendarDelegate(QItemDelegate)
        def paint(self, qp, opt, index):
            if not opt.state & QStyle.State_Selected:
                super().paint(qp, opt, index)
                return
    
            qp.save()
            if opt.state & QStyle.State.State_Active:
                cg = QPalette.ColorGroup.Normal
            else:
                cg = QPalette.ColorGroup.Inactive
            qp.setBrush(opt.palette.brush(cg, QPalette.ColorRoleHighlight))
            qp.setPen(Qt.NoPen)
            radius = min(rect.width(), rect.height()) / 2
            qp.drawRoundedRect(opt.rect, radius, radius)
            qp.restore()
    
            opt.palette.setColor(QPalette.Highlight, Qt.transparent)
            super().paint(qp, opt, index)
    
    
    class CalendarWidget(QCalendarWidget):
        def __init__(self, parent=None):
            ...
            table = self.findChild(QTableView)
            table.setItemDelegate(CalendarDelegate(table))
    

    This approach is normally a good compromise, but it's not without defects: similarly to the styled delegate approach above, using a different delegate completely prevents the possibility of overriding the calendar's paintCell().

    The default internal delegate of QCalendarWidget, in fact, overrides its paint() method on its own and from there it calls the calendar's paintCell() using a private function of the model that retrieves the date from the QModelIndex.

    A custom delegate obviously doesn't have that functionality, and it has to be manually implemented, by considering the visible headers and therefore assuming the date based on the row and column of the given index.

    While feasible, it's not immediate, and the delegate should also be properly "connected" to the calendar (right now, in the example above, it's only parented to the table view) in order to possibly call a function similar to paintCell() to properly access the date based on the index coordinates.