Search code examples
qtpyqt

How to specify day of year in QDateTimeEdit display format?


How can I use the day of year in the QDateTimeEdit display format? I know I can specify the display format using the displayFormat method, but the documentation doesn't show a way to use day of year formating. Ex. 29-July-2023 would be 2023-210.

All the data that that I am using uses ordinal dates to keep track of information. As such it is easiest to be able to input an ordinal date rather than converting it and then inputting it.

My current solution uses a line edit with an input mask and a validator method, but I was wondering if there was a more robust solution.

from PyQt5.QtCore import *
from PyQt5.QtGui import *
from PyQt5.QtWidgets import *
import sys

class DateEdit(QLineEdit):
    def __init__(self):
        super().__init__()
        self.textChanged.connect(self.validate)
        self.setPlaceholderText('yyyy-ddd')
    
    def validate(self):
        print(self.cursorPosition())
        text = self.text()
        print(text.split('-'))
        if len(text) > 5 and int(text.split('-')[1]) > 366:
            self.setText(text[:-1])
                
    def focusInEvent(self, event):
        if self.text() == '':
            self.setInputMask('9999-900')
    
    def focusOutEvent(self, event):
        if self.text() == '-':
            self.setInputMask('')

if __name__ == "__main__":
    app = QApplication(sys.argv)
    main = QWidget()
    main.setFixedSize(200, 100)
    edit1 = DateEdit()
    edit2 = DateEdit()
    layout = QHBoxLayout()
    layout.addWidget(edit1)
    layout.addWidget(edit2)
    main.setLayout(layout)
    main.show()
    app.exec()

Solution

  • I modified the current QDateEdit to have a different behavior for displaying and inputting the text, while still maintaining the functionality of QDateEdit (such as the popup and context menu). The current date format is now in the form of ordinal date (e.g. 2020/123).

    * As @musicamante mentioned, I changed the displayFormat back to yyyy/ddd. I also added a separator variable to easily change the separator. There is also an issue with the currentSection which I think can be fixed by re-implementing the onKeyPressEvent.

    To achieve this, I looked into the Qt source code and Qt documentation, and after some trial and error, I found that the following functions needed to be reimplemented:

    • dateTimeFromText(text) [1]: For parsing input in the ordinal date format.
    • textFromDateTime(dt) [2]: For displaying the ordinal date in the proper format.
    • validate(text, pos) [3]: Changing the validation based on the ordinal date format.
    • stepBy(steps) [4] and stepEnabled() [5]: These two functions are also reimplemented to fix a limitation when modifying days using arrow keys.

    The code seems to work correctly in my testing, but there may be some errors. I haven't tested it for locale changes either, but it should provide a good starting point. Also, I'm not really a Python programmer, so the code may not adhere to best practices.

    import sys
    from datetime import datetime
    from PyQt5.QtGui import QValidator
    from PyQt5.QtCore import QDate, QDateTime
    from PyQt5.QtWidgets import QApplication, QVBoxLayout, QWidget, QDateEdit
    
    class CustomDateEdit(QDateEdit):
        def __init__(self, separator = '/'):
            super().__init__()
            self.separator = separator
            self.setDisplayFormat('yyyy{0}ddd'.format(self.separator))
    
        def dateTimeFromText(self, text: str | None) -> QDateTime:
            try:
                year_str, day_str = text.split(self.separator)
                day, year = int(day_str), int(year_str)
            except:
                year, day = 1, 1
    
            date = QDateTime(QDate(year, 1, 1).addDays(day - 1))
            date.setTime(self.time())
    
            return QDateTime(date)
    
        def textFromDateTime(self, dt: QDateTime | datetime) -> str:
            year = dt.date().year()
            day = dt.date().dayOfYear()
            return "{0}{1}{2:0>3}".format(year, self.separator, day)
    
        def stepBy(self, steps: int) -> None:
            if self.currentSection() == QDateEdit.Section.DaySection:
                date = self.date()
                leap = QDate().isLeapYear(date.year())
                if date.dayOfYear() + steps <= (365 + int(leap)):
                    self.setDate(date.addDays(steps))
            else:
                super().stepBy(steps)
    
        def stepEnabled(self) -> QDateEdit.StepEnabled:
            if self.currentSection() == QDateEdit.Section.DaySection:
                date = self.date()
                leap = QDate().isLeapYear(date.year())
                flag = QDateEdit.StepEnabledFlag.StepNone
    
                if date.dayOfYear() < (365 + int(leap)):
                    flag = QDateEdit.StepEnabledFlag.StepUpEnabled
                if 1 < date.dayOfYear():
                    flag |= QDateEdit.StepEnabledFlag.StepDownEnabled
    
                return flag
            else:
                return super().stepEnabled()
    
        def validate(self, input: str | None, pos: int):
            try:
                year_str, day_str = input.split(self.separator)
                day, year = int(day_str), int(year_str)
            except:
                year, day = 1, 1
    
            new_date = QDate(year, 1, 1).addDays(day - 1)
            is_leap = new_date.isLeapYear(year)
    
            have_slash = input.count(self.separator) == 1
            range_valid = self.minimumDate() < new_date and new_date < self.maximumDate()
            day_in_range = day < (365 + int(is_leap))
    
            if have_slash and day_in_range and range_valid:
                state = QValidator.State.Acceptable
            elif have_slash and year_str and len(year_str) < 4:
                state = QValidator.State.Intermediate
            else:
                state = QValidator.State.Invalid
    
            return (state, input, pos)
    
    if __name__ == "__main__":
        app = QApplication(sys.argv)
        main = QWidget()
        cdate_edit = CustomDateEdit()
        cdate_edit.setCalendarPopup(True)
        layout = QVBoxLayout()
        layout.addWidget(cdate_edit)
        main.setLayout(layout)
        main.show()
        sys.exit(app.exec())