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)
Related
I'm coding a Python CRUD app that shows radiosondes on a map and a QTableView. I'm using QStyledItemDelegate to set an editor and regex validator for each column and it is working great. But for the geometry column I would like to parse the binary data and show it on a custom form (lat, lng, elevation), be able to edit them and if clicked OK encode them back to WKB format and update the data.
When I click OK the filed is not updated, instead it becomes empty. If I try to edit after that any other cell nothing happens and if I try to edit that exact cell the app crashes. The same happens if I click Cancel.
The setData method returns True and the data in the DB gets updated.
I tried with dataChanged.emit() on the QSqlTableModel and also with update() method on the QTableView.
main2.py:
from PyQt5.Qt import QStyledItemDelegate
from PyQt5.QtWebEngineWidgets import QWebEngineView
from PyQt5.QtCore import Qt
from shapely import wkb, wkt
import folium
import io
class Ui_RadioSondes(object):
def setupUi(self, RadioSondes):
self.centerCoord = (44.071800, 17.578125)
RadioSondes.setObjectName("RadioSondes")
.
.
.
self.tableView_2.setItemDelegate(ValidatedItemDelegate())
.
.
.
class ValidatedItemDelegate(QStyledItemDelegate):
def createEditor(self, widget, option, index):
if not index.isValid():
return 0
if index.column() == 0: #only on the cells in the first column
editor = QtWidgets.QLineEdit(widget)
validator = QtGui.QRegExpValidator(QtCore.QRegExp('[\w]{1,10}'), editor)
editor.setValidator(validator)
return editor
if index.column() == 2:
editor = QtWidgets.QSpinBox(widget)
editor.setMaximum(360)
editor.setMinimum(1)
return editor
.
.
.
if index.column() == 9:
self.form = QtWidgets.QWidget()
self.formLayout = QtWidgets.QFormLayout(self.form)
self.formLayout.setVerticalSpacing(12)
self.formLayout.setObjectName("formLayout")
###__________ Latitude__________###
self.latLabel = QtWidgets.QLabel(self.form)
self.latLabel.setObjectName("latLabel")
self.latLabel.setText("Latitude")
self.latLabel.adjustSize()
self.formLayout.setWidget(0, QtWidgets.QFormLayout.LabelRole, self.latLabel)
self.latEdit = QtWidgets.QLineEdit(self.form)
# lineEdit.textChanged.connect(validateFields)
self.latEdit.setObjectName("latEdit")
self.formLayout.setWidget(0, QtWidgets.QFormLayout.FieldRole, self.latEdit)
###__________ Longitude__________###
self.lngLabel = QtWidgets.QLabel(self.form)
self.lngLabel.setObjectName("lngLabel")
self.lngLabel.setText("Longitude")
self.lngLabel.adjustSize()
self.formLayout.setWidget(1, QtWidgets.QFormLayout.LabelRole, self.lngLabel)
self.lngEdit = QtWidgets.QLineEdit(self.form)
# lineEdit.textChanged.connect(validateFields)
self.lngEdit.setObjectName("lngEdit")
self.formLayout.setWidget(1, QtWidgets.QFormLayout.FieldRole, self.lngEdit)
###__________ Elevation__________###
self.elevationLabel = QtWidgets.QLabel(self.form)
self.elevationLabel.setObjectName("elevationLabel")
self.elevationLabel.setText("Elevation")
self.elevationLabel.adjustSize()
self.formLayout.setWidget(2, QtWidgets.QFormLayout.LabelRole, self.elevationLabel)
self.elevationEdit = QtWidgets.QLineEdit(self.form)
# lineEdit.textChanged.connect(validateFields)
self.elevationEdit.setObjectName("elevationEdit")
self.formLayout.setWidget(2, QtWidgets.QFormLayout.FieldRole, self.elevationEdit)
self.buttonBox = QtWidgets.QDialogButtonBox(self.form)
self.buttonBox.setOrientation(QtCore.Qt.Horizontal)
self.buttonBox.setStandardButtons(QtWidgets.QDialogButtonBox.Cancel | QtWidgets.QDialogButtonBox.Ok)
self.buttonBox.setObjectName("buttonBox")
self.formLayout.addWidget(self.buttonBox)
self.form.resize(200, 300)
self.prevData = index.data()
self.index = index
self.widget = widget
self.model = self.widget.parent().parent().parent().parent().parent().parent().parent().objModel
self.t_view = self.widget.parent().parent().parent().parent().parent().parent().parent().tableView_2
data = self.model.data(self.index)
geomWkb = wkb.loads(bytes.fromhex(data))
self.latEdit.setText(str(geomWkb.x))
self.lngEdit.setText(str(geomWkb.y))
self.elevationEdit.setText(str(geomWkb.z))
self.buttonBox.accepted.connect(self.generateGeom)
self.buttonBox.rejected.connect(self.cancelGeomEdit)
return self.form
return super(ValidatedItemDelegate, self).createEditor(widget, option, index)
def generateGeom(self):
print(self.latEdit.text())
print(self.lngEdit.text())
print(self.elevationEdit.text())
geomStr = "POINT Z (" + self.latEdit.text() + " " + self.lngEdit.text() + " " + self.elevationEdit.text() + ")"
geom = wkt.loads(geomStr)
geomWkb = wkb.dumps(geom, hex=True, srid=4326)
try:
self.model.setData(self.index, geomWkb, Qt.EditRole)
self.form.close()
#self.t_view.update()
except AssertionError as error:
print(error)
def cancelGeomEdit(self):
self.form.destroy(destroyWindow=True)
Here is the whole code on GitHub: https://github.com/draugnim/pyCrud
EDIT
I managed to get it working by calling self.model.selet() at the end of generateGeom() and cancelGeomEdit(). But still if I click on the X button and close the form the edited cell becomes blank, also this and all other cells become uneditable.
The item delegate uses the editor's user property, which is considered the main default property of a Qt object. For QLineEdit it's text(), for a QSpinBox it's value(), etc.
If you want to provide a custom, advanced editor, the solution is to create a subclass with a custom user property.
Note that the Qt internal way of dealing with item data is a bit strict on types, and since PyQt doesn't expose the QVariant class, the only option is to use a suitable type. For you case, QVector3D is a perfect choice.
Then, using an external window is a bit tricky, as delegates are normally supposed to be simple field editors that exist inside the view. To work around that, the following must be considered:
the editor must notify the delegate that the inserted data has been accepted, and it must destroy itself whenever it's closed;
key press events must be ignored in the filter (return False), so that the editor can properly handle them;
focus and hide events must ignored too, as the delegate by default tries to update the model when the editor has not been "rejected" but it loses focus or is hidden;
the geometry must be set using the parent's top level window(), and the updateEditorGeometry() must be ignored as the delegate would try to update the geometry whenever the view is shown again after being hidden or it's resized;
Since the given code is not a minimal, reproducible example, I'll provide a generic example of the concept.
from PyQt5 import QtCore, QtGui, QtWidgets
from random import randrange
class CoordinateEditor(QtWidgets.QDialog):
submit = QtCore.pyqtSignal(QtWidgets.QWidget)
def __init__(self, parent):
super().__init__(parent)
self.setWindowModality(QtCore.Qt.WindowModal)
layout = QtWidgets.QFormLayout(self)
self.latitudeSpin = QtWidgets.QDoubleSpinBox(minimum=-90, maximum=90)
layout.addRow('Latitude', self.latitudeSpin)
self.longitudeSpin = QtWidgets.QDoubleSpinBox(minimum=-180, maximum=180)
layout.addRow('Longitude', self.longitudeSpin)
self.elevationSpin = QtWidgets.QDoubleSpinBox(minimum=-100, maximum=100)
layout.addRow('Elevation', self.elevationSpin)
buttonBox = QtWidgets.QDialogButtonBox(
QtWidgets.QDialogButtonBox.Ok|QtWidgets.QDialogButtonBox.Cancel)
layout.addRow(buttonBox)
buttonBox.accepted.connect(self.accept)
buttonBox.rejected.connect(self.reject)
self.finished.connect(self.deleteLater)
def accept(self):
super().accept()
self.submit.emit(self)
#QtCore.pyqtProperty(QtGui.QVector3D, user=True)
def coordinateData(self):
return QtGui.QVector3D(
self.longitudeSpin.value(),
self.latitudeSpin.value(),
self.elevationSpin.value()
)
#coordinateData.setter
def coordinateData(self, data):
self.longitudeSpin.setValue(data.x())
self.latitudeSpin.setValue(data.y())
self.elevationSpin.setValue(data.z())
def showEvent(self, event):
if not event.spontaneous():
geo = self.geometry()
geo.moveCenter(self.parent().window().geometry().center())
self.setGeometry(geo)
QtCore.QTimer.singleShot(0, self.latitudeSpin.setFocus)
class DialogDelegate(QtWidgets.QStyledItemDelegate):
def createEditor(self, parent, option, index):
if index.column() == 1:
editor = CoordinateEditor(parent)
editor.submit.connect(self.commitData)
return editor
else:
return super().createEditor(parent, option, index)
def initStyleOption(self, option, index):
super().initStyleOption(option, index)
if index.column() == 1 and index.data() is not None:
coords = index.data()
option.text = '{:.02f}, {:.02f}, {:.02f}'.format(
coords.y(), coords.x(), coords.z())
def eventFilter(self, source, event):
if isinstance(source, CoordinateEditor):
if event.type() in (event.KeyPress, event.FocusOut, event.Hide):
return False
return super().eventFilter(source, event)
def updateEditorGeometry(self, editor, option, index):
if not isinstance(editor, CoordinateEditor):
super().updateEditorGeometry(editor, option, index)
if __name__ == '__main__':
import sys
app = QtWidgets.QApplication(sys.argv)
test = QtWidgets.QTableView()
test.setItemDelegate(DialogDelegate(test))
model = QtGui.QStandardItemModel(0, 2)
for row in range(10):
coordItem = QtGui.QStandardItem()
coords = QtGui.QVector3D(
randrange(-180, 181),
randrange(-90, 91),
randrange(-100, 101))
coordItem.setData(coords, QtCore.Qt.DisplayRole)
model.appendRow((
QtGui.QStandardItem('Data {}'.format(row + 1)),
coordItem,
))
test.setModel(model)
test.resizeColumnsToContents()
test.show()
sys.exit(app.exec_())
I would like to use QLineEdit to get user input for values. I want to limit the input between a range so I should use QDoubleValidator. I would like it to work such that if they go over the allowed value, it sets the text to the top() value and if they go under then it sets it to the bottom() value.
I looked at using the textChanged, returnPressed, and inputRejected signals. The reason I am having trouble is that once I set the validator with the range, returnPressed will not enter the check_validator function which they mention here. Then, I thought maybe I could catch it with the input rejected signal but for some reason that doesn't seem to work either. Here is some code:
class LineEdit(QLineEdit):
def __init__(self, text, parent=None):
super(LineEdit, self).__init__(parent)
self.validator = QDouble Validator()
self.setValidator(self.validator)
self.text = text
self.textChanged.connect(self.new_text)
self.returnPressed(self.check_validator)
def new_text(self, text):
self.ntext = text
def check validator:
try:
if float(self.ntext) > self.validator.top():
self.text = str(self.validator.top()
if float(self.ntext) < self.validator.bottom():
self.text = str(self.validator.bottom()
else:self.text = self.ntext
self.setText(self.text)
except:
mssg = QMessageBox.about(self, "Error", "Input can only be a number")
mssg.exec()
self.setText(self.text)
def valRange(self, x1, x2):
self.validator.setRange(x1, x2)
I also get an attribute error saying 'noneType' object has no attribute 'exec' when the window does pop up. I think I am missing a step on how to close that window properly.
You could reimplement keyPressEvent to catch return presses when hasAcceptableInput is false. There is also a drawback to overriding the text property, now any programatic calls to setText will not update the text of the QLineEdit. There's no reason to do it.
class LineEdit(QLineEdit):
def __init__(self, *args, **kwargs):
super(LineEdit, self).__init__(*args, **kwargs)
self.validator = QDoubleValidator(0, 10, 4, notation=QDoubleValidator.StandardNotation)
self.setValidator(self.validator)
self.textChanged.connect(self.new_text)
self.returnPressed.connect(self.check_validator)
self.ntext = None
def keyPressEvent(self, event):
super().keyPressEvent(event)
if event.key() == Qt.Key_Return and not self.hasAcceptableInput():
self.check_validator()
def new_text(self, text):
if self.hasAcceptableInput():
self.ntext = text
def check_validator(self):
try:
if float(self.text()) > self.validator.top():
self.setText(str(self.validator.top()))
elif float(self.text()) < self.validator.bottom():
self.setText(str(self.validator.bottom()))
except:
mssg = QMessageBox.about(self, "Error", "Input can only be a number")
self.setText(self.ntext)
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 have a QTreeView, and I've figured out how to set its style by using setStyleSheet in my main class:
self.view.setStyleSheet("""
QTreeView::item {
margin: 2px;
}
""")
That will style the entire QTreeView. But I want certain items in the tree to be bolded. When I create the branches (using [the parent widget].appendRow("the name of the item")), is there a way to 'tag' or isolate specific items so it can be styled the same way? I think the answer has something to do with the 'AccessibleName' or 'ObjectName' properties, but I'm having trouble finding documentation on it.
Update: This is what I have so far:
#!/usr/bin/python
# -*- coding: utf-8 -*-
from __future__ import division
from __future__ import print_function
from future_builtins import *
import os, sys
from PySide.QtCore import *
from PySide.QtGui import *
path_to_media = '/Volumes/show/vfx/webisodes/%d1%/seq/%d2%/%d3%/renders/2d/comp/'
class FileTree(QTreeView):
"""Main file tree element"""
def __init__(self):
QTreeView.__init__(self)
def keyPressEvent(self, event):
if event.key() == Qt.Key_Space or event.key() == Qt.Key_Return:
index = self.selectedIndexes()[0]
crawler = index.model().itemFromIndex(index)
if crawler.uri:
print("launching", crawler.uri)
p = os.popen(('open -a "RV.app" "'+ crawler.uri) +'"', "r")
QTreeView.keyPressEvent(self, event)
class Branch(QStandardItem):
"""Branch element"""
def __init__(self, label, uri = None, tag = None):
QStandardItem.__init__(self, label)
self.uri = uri
class AppForm(QMainWindow):
def __init__(self, parent = None):
super(AppForm, self).__init__(parent)
self.model = QStandardItemModel()
self.view = FileTree()
self.view.setStyleSheet("""
QTreeView::item {
margin: 2px;
}
""")
self.view.setEditTriggers(QAbstractItemView.NoEditTriggers)
self.view.setModel(self.model)
self.setCentralWidget(self.view)
self.Grow()
# self.view.setSortingEnabled(True)
def Grow(self):
"""Populates FileTree (using BuildBranch())"""
global path_to_media
self.path = {}
self.path['source'] = path_to_media
self.path['parts'] = []
self.path['depth'] = 0
self.path['crawl'] = {}
for i in self.path['source'].split('%'):
if i[0] == "d" and i[1].isdigit():
self.path['depth'] += 1
else:
self.path['parts'].append(i)
self.BuildBranch(self.path['parts'], self.path['depth'], parentWidget = self.model.invisibleRootItem())
def BuildBranch(self, parts, depth, uri = '', count = 0, parent = '', parentWidget = ''):
"""Recursively crawls folder structure and adds appropriate branches"""
if not uri: uri = parts[0]
else: uri += parent + parts[count]
try:
if os.listdir(uri):
for i in os.listdir(uri):
if i[0] != '.':
if count != depth:
if os.path.isdir(uri):
thisWidget = Branch(i)
parentWidget.appendRow(thisWidget)
self.BuildBranch(parts, depth, uri, count + 1, i, parentWidget = thisWidget)
else:
thisWidget = Branch(i)
parentWidget.appendRow(thisWidget)
elif count == depth:
thisWidget = Branch(i, uri + i, 'media')
parentWidget.appendRow(thisWidget)
else:
print("nothing here; nuking " + parent)
# Need to add code to nuke unused branch
except OSError:
print("Folder structure error... nuking the branch")
# Need to add code to nuke unused branch
def main():
app = QApplication(sys.argv)
form = AppForm()
form.resize(800, 600)
form.setWindowTitle('Qt Dailies')
form.show()
app.exec_()
if __name__ == "__main__":
main()
Update 2: Okay, I modified my Branch class so that if 'bold' is passed to it, it makes the branch bold (in theory)...
class Branch(QStandardItem):
def __init__(self, label, uri = None, tag = None):
QStandardItem.__init__(self, label)
self.uri = uri
if tag == 'bold':
self.setData(self.createBoldFont(), Qt.FontRole)
def createBoldFont(self):
if self.font: return self.font
self.font = QFont()
self.font.setWeight(QFont.Bold)
return self.font
... but while the code runs, nothing seems to happen. What am I still not getting?
Qt's model-view architecture allows for data that describes the different roles being performed. For example, there is a role for editing data, displaying data, etc. You're interested in the font role (i.e. Qt::FontRole) as the font has a weight enum of which bold is one value.
When you build your branches you first need to identify which items should be bolded. I'll assume you have a method like such that can identify whether or not they should be bold:
def should_be_bolded(self, item):
return 1 # your condition checks
Now just set the weight on the font and set the font role of the item using its setData method:
def BuildBranch(...):
thisWidget = Branch(i)
if self.should_be_bolded(thisWidget):
thisWidget.setData(self.createBoldFont(), Qt.FontRole)
def createFont(self):
if self.font: return self.font
self.font = QFont()
self.font.setWeight(QFont.Bold)
return self.font
But wait... you already have a subclass of QtandardItem, so you can use that:
class Branch(QStandardItem):
"""Branch element"""
def __init__(self, label, uri = None, tag = None):
QStandardItem.__init__(self, label)
self.uri = uri
if self.should_be_bolded():
self.bold_myself()
You'll have to fix the should_be_bolded and bold_myself methods, cleaning up accordingly, but hopefully you get the point.
Stephen pointed out that you can also subclass one of the QAbstractItemModels, like the QStandardItemModel you're using, and return a specific Qt.FontRole. His way makes that knowledge implicit in the model. Decide where that knowledge best belongs and place it in the most appropriate place, whether it's in the item, the tree-creation algorithm, the model, or even a view model.
In your model's data() method you can add code to set the font depending on the content of the item. For example, if you wanted to bold everything in one particular row,
def data(self, index, role):
if role == QtCore.Qt.FontRole:
if index.row() == 1:
boldFont = QtGui.QFont()
boldFont.setBold(True)
return boldFont
You just need a way to retrieve the name of your branch when given an index for it. That depends on the implementation of your tree model.
The Qt Model/View tutorial has a good example, although it's in C++. Look at section 2.2 (Extending the Read Only Example with Roles).
I'm rewriting this post to clarify some things and provide a full class definition for the Virtual List I'm having trouble with. The class is defined like so:
from wx import ListCtrl, LC_REPORT, LC_VIRTUAL, LC_HRULES, LC_VRULES, \
EVT_LIST_COL_CLICK, EVT_LIST_CACHE_HINT, EVT_LIST_COL_RIGHT_CLICK, \
ImageList, IMAGE_LIST_SMALL, Menu, MenuItem, NewId, ITEM_CHECK, Frame, \
EVT_MENU
class VirtualList(ListCtrl):
def __init__(self, parent, datasource = None,
style = LC_REPORT | LC_VIRTUAL | LC_HRULES | LC_VRULES):
ListCtrl.__init__(self, parent, style = style)
self.columns = []
self.il = ImageList(16, 16)
self.Bind(EVT_LIST_CACHE_HINT, self.CheckCache)
self.Bind(EVT_LIST_COL_CLICK, self.OnSort)
if datasource is not None:
self.datasource = datasource
self.Bind(EVT_LIST_COL_RIGHT_CLICK, self.ShowAvailableColumns)
self.datasource.list = self
self.Populate()
def SetDatasource(self, datasource):
self.datasource = datasource
def CheckCache(self, event):
self.datasource.UpdateCache(event.GetCacheFrom(), event.GetCacheTo())
def OnGetItemText(self, item, col):
return self.datasource.GetItem(item, self.columns[col])
def OnGetItemImage(self, item):
return self.datasource.GetImg(item)
def OnSort(self, event):
self.datasource.SortByColumn(self.columns[event.Column])
self.Refresh()
def UpdateCount(self):
self.SetItemCount(self.datasource.GetCount())
def Populate(self):
self.UpdateCount()
self.datasource.MakeImgList(self.il)
self.SetImageList(self.il, IMAGE_LIST_SMALL)
self.ShowColumns()
def ShowColumns(self):
for col, (text, visible) in enumerate(self.datasource.GetColumnHeaders()):
if visible:
self.columns.append(text)
self.InsertColumn(col, text, width = -2)
def Filter(self, filter):
self.datasource.Filter(filter)
self.UpdateCount()
self.Refresh()
def ShowAvailableColumns(self, evt):
colMenu = Menu()
self.id2item = {}
for idx, (text, visible) in enumerate(self.datasource.columns):
id = NewId()
self.id2item[id] = (idx, visible, text)
item = MenuItem(colMenu, id, text, kind = ITEM_CHECK)
colMenu.AppendItem(item)
EVT_MENU(colMenu, id, self.ColumnToggle)
item.Check(visible)
Frame(self, -1).PopupMenu(colMenu)
colMenu.Destroy()
def ColumnToggle(self, evt):
toggled = self.id2item[evt.GetId()]
if toggled[1]:
idx = self.columns.index(toggled[2])
self.datasource.columns[toggled[0]] = (self.datasource.columns[toggled[0]][0], False)
self.DeleteColumn(idx)
self.columns.pop(idx)
else:
self.datasource.columns[toggled[0]] = (self.datasource.columns[toggled[0]][0], True)
idx = self.datasource.GetColumnHeaders().index((toggled[2], True))
self.columns.insert(idx, toggled[2])
self.InsertColumn(idx, toggled[2], width = -2)
self.datasource.SaveColumns()
I've added functions that allow for Column Toggling which facilitate my description of the issue I'm encountering. On the 3rd instance of this class in my application the Column at Index 1 will not display String values. Integer values are displayed properly. If I add print statements to my OnGetItemText method the values show up in my console properly. This behavior is not present in the first two instances of this class, and my class does not contain any type checking code with respect to value display.
It was suggested by someone on the wxPython users' group that I create a standalone sample that demonstrates this issue if I can. I'm working on that, but have not yet had time to create a sample that does not rely on database access. Any suggestions or advice would be most appreciated. I'm tearing my hair out on this one.
Are you building on the wxPython demo code for virtual list controls? There are a couple of bookkeeping things you need to do, like set the ItemCount property.
One comment about your OnGetItemText method: Since there's no other return statement, it will return None if data is None, so your test has no effect.
How about return data or "" instead?
There's a problem with the native object in Windows. If GetImg returns None instead of -1 the list has a problem with column 1 for some reason. That from Robin over on the Google Group post for this issue.