jabbarlee
jabbarlee

Reputation: 140

Pyqt5: How can I create a manually editable QDateEdit widget?

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.

enter image description here

Upvotes: 3

Views: 1112

Answers (1)

musicamante
musicamante

Reputation: 48231

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)

Upvotes: 1

Related Questions