QGraphicsView double click events and ScrollHandDrag mode item issue - qt

I'm attempting to create a QGraphicsView with the following behaviour:
When the control key is held and the left mouse is down, the view should be set to ScrollHandDrag mode to allow the user to pan around.
When in ScrollHandDrag mode, items should not be selectable/movable, as in the question here: In ScrollHandDrag mode of QGraphicsView, How to stop movement of QGraphicsItems on scene?
If the control key was held, the left mouse was clicked, and then the control key was released, then the view should stay in ScrollHandDrag mode until the mouse is released, or will stay in this mode should the control key be down at the time the mouse is released.
To me this sounds like it should be fairly straightforward. I've implemented the logic from the linked question, and some additional logic for my extra requirements. However this seems to cause the following two showstoppers:
In the mousePressEvent, setting the item under the mouse to not have the movable and selectable flags, calling the base class, and then re-applying the flags causes the item to become "frozen". The only way to solve this seems to be a control + click, release control + release click a few times outside of the item. Also when it gets into this state no items can be moved (although they can still be selected).
Double clicking the view causes a mousePressEvent, which is then followed by two mouseReleaseEvents! This breaks my logic.
So I would like to know how I can solve the issue of the items becoming frozen when the logic from In ScrollHandDrag mode of QGraphicsView, How to stop movement of QGraphicsItems on scene? is used, and how to deal with the strange double click mouse events - is there a way to turn them off?
Here is my code (this is also pretty much my hello world Python, so if I've made some horrible Python mistake please let me know):
#! /usr/bin/env python
# -*- coding: utf-8 -*-
import sys
from PyQt4.QtGui import *
from PyQt4.QtCore import *
class MyMainWindow(QMainWindow):
def __init__(self):
super(MyMainWindow, self).__init__()
self.initUI()
def initUI(self):
self.setWindowTitle("Test")
self.gv = MyGraphicsView()
self.setCentralWidget(self.gv)
self.setGeometry(170, 130, 450, 250)
class MyGraphicsView(QGraphicsView):
def __init__(self):
super(MyGraphicsView, self).__init__()
self.setup()
def setup(self):
self.m_MouseIsDown = False
self.m_ControlKeyDown = False
self.setDragMode(QGraphicsView.RubberBandDrag)
def mouseMoveEvent(self, event):
# print "mouseMoveEvent: " + str(event.pos().x()) + "," + str(event.pos().y())
super(MyGraphicsView, self).mouseMoveEvent(event);
def mousePressEvent(self, event):
print "mousePressEvent"
itemUnderMouse = self.itemAt(event.pos())
if itemUnderMouse is not None:
bHadMovableFlagSet = itemUnderMouse.flags() & QGraphicsItem.ItemIsMovable
bWasSelected = itemUnderMouse.isSelected()
bHadSelectableFlagSet = itemUnderMouse.flags() & QGraphicsItem.ItemIsSelectable
if bHadMovableFlagSet:
print "has ItemIsMovable"
else:
print "hasn't ItemIsMovable"
if bHadSelectableFlagSet:
print "has ItemIsSelectable"
else:
print "hasn't ItemIsSelectable"
if bWasSelected:
print "isSelected true"
else:
print "isSelected false"
itemUnderMouse.setSelected(False)
if event.button() == Qt.LeftButton:
print "mousePressEvent: left button is now down"
self.m_MouseIsDown = True
if self.dragMode() == QGraphicsView.ScrollHandDrag and event.button() == Qt.LeftButton:
print "mousePressEvent: left button down and ScrollHandDrag set"
self.PreventItemsFromMovingOrBeingSelectedWhenPannning(event)
return
print "mousePressEvent: pass through"
super(MyGraphicsView, self).mousePressEvent(event)
def mouseReleaseEvent(self, event):
print "mouseReleaseEvent"
if event.button() == Qt.LeftButton:
print "mouseReleaseEvent - left button is now up"
self.m_MouseIsDown = False
if self.dragMode() == QGraphicsView.ScrollHandDrag and self.m_ControlKeyDown == False:
print "mouseReleaseEvent - left button up, in ScrollHandDrag mode and control key is not pressed, change to RubberBandDrag"
self.setDragMode(QGraphicsView.RubberBandDrag)
super(MyGraphicsView, self).mouseReleaseEvent(event)
def keyPressEvent(self, event):
if event.key() == Qt.Key_Control:
print "control key down"
self.m_ControlKeyDown = True
# ignore if mouse already down since we don't want to suddenly change to pan mode if an item is being moved
if event.key() == Qt.Key_Control and self.dragMode() != QGraphicsView.ScrollHandDrag and self.m_MouseIsDown == False:
print "keyPressEvent - control key down, mouse isn't down and drag mode is not ScrollHandDrag, change to ScrollHandDrag"
self.setDragMode(QGraphicsView.ScrollHandDrag)
super(MyGraphicsView, self).keyPressEvent(event)
def keyReleaseEvent(self, event):
if event.key() == Qt.Key_Control:
print "control key up"
self.m_ControlKeyDown = False
if event.key() == Qt.Key_Control and self.dragMode() == QGraphicsView.ScrollHandDrag and self.m_MouseIsDown == False:
print "keyReleaseEvent - control key up and drag mode is ScrollHandDrag, mouse is not pressed, change to RubberBandDrag"
self.setDragMode(QGraphicsView.RubberBandDrag)
super(MyGraphicsView, self).keyReleaseEvent(event)
def wheelEvent(self, event):
factor = 1.2;
if event.delta() < 0:
factor = 1.0 / factor
self.scale(factor, factor)
def PreventItemsFromMovingOrBeingSelectedWhenPannning(self, mouseEvent):
itemUnderMouse = self.itemAt(mouseEvent.pos())
if itemUnderMouse is not None:
print "preventing item from moving"
bHadMovableFlagSet = itemUnderMouse.flags() & QGraphicsItem.ItemIsMovable
itemUnderMouse.setFlag(QGraphicsItem.ItemIsMovable, False)
bWasSelected = itemUnderMouse.isSelected()
bHadSelectableFlagSet = itemUnderMouse.flags() & QGraphicsItem.ItemIsSelectable
itemUnderMouse.setFlag(QGraphicsItem.ItemIsSelectable, False)
super(MyGraphicsView, self).mousePressEvent(mouseEvent)
if bHadMovableFlagSet:
print "set ItemIsMovable"
itemUnderMouse.setFlag(QGraphicsItem.ItemIsMovable, True)
if bHadSelectableFlagSet:
print "set ItemIsSelectable"
itemUnderMouse.setFlag(QGraphicsItem.ItemIsSelectable, True)
if bWasSelected:
print "setSelected True"
itemUnderMouse.setSelected(True)
else:
print "no item under mouse - pass through"
super(MyGraphicsView, self).mousePressEvent(mouseEvent)
class MyGraphicsScene(QGraphicsScene):
def __init__(self, parent):
super(MyGraphicsScene, self).__init__()
def main():
a = QApplication(sys.argv)
w = MyMainWindow()
w.show()
scene = MyGraphicsScene(w)
w.gv.setScene(scene)
rect = scene.addRect( 10, 10, 40, 40)
rect.setFlag( QGraphicsItem.ItemIsSelectable )
rect.setFlag( QGraphicsItem.ItemIsMovable )
rect = scene.addRect( 40, 40, 40, 40)
rect.setFlag( QGraphicsItem.ItemIsSelectable )
rect.setFlag( QGraphicsItem.ItemIsMovable )
sys.exit(a.exec_())
if __name__ == '__main__':
main()

If you don't call the base implementation in mouse*Events while panning, item selection won't be an issue. However, this now requires re-implementing the built-in panning function. Fortunately, it's not hard to implement it.
After some iteration at IRC (#pyqt # freenode), this is the final implementation:
Pressing and holding CTRL key enables panning. While mouse is pressed, releasing CTRL key keeps panning mode.
Just mouse press activates Rubber selection
All actions are controlled with Left-Button
class MyGraphicsView(QGraphicsView):
def __init__(self):
super(MyGraphicsView, self).__init__()
self.setDragMode(QGraphicsView.RubberBandDrag)
self._isPanning = False
self._mousePressed = False
def mousePressEvent(self, event):
if event.button() == Qt.LeftButton:
self._mousePressed = True
if self._isPanning:
self.setCursor(Qt.ClosedHandCursor)
self._dragPos = event.pos()
event.accept()
else:
super(MyGraphicsView, self).mousePressEvent(event)
def mouseMoveEvent(self, event):
if self._mousePressed and self._isPanning:
newPos = event.pos()
diff = newPos - self._dragPos
self._dragPos = newPos
self.horizontalScrollBar().setValue(self.horizontalScrollBar().value() - diff.x())
self.verticalScrollBar().setValue(self.verticalScrollBar().value() - diff.y())
event.accept()
else:
super(MyGraphicsView, self).mouseMoveEvent(event)
def mouseReleaseEvent(self, event):
if event.button() == Qt.LeftButton:
if event.modifiers() & Qt.ControlModifier:
self.setCursor(Qt.OpenHandCursor)
else:
self._isPanning = False
self.setCursor(Qt.ArrowCursor)
self._mousePressed = False
super(MyGraphicsView, self).mouseReleaseEvent(event)
def mouseDoubleClickEvent(self, event): pass
def keyPressEvent(self, event):
if event.key() == Qt.Key_Control and not self._mousePressed:
self._isPanning = True
self.setCursor(Qt.OpenHandCursor)
else:
super(MyGraphicsView, self).keyPressEvent(event)
def keyReleaseEvent(self, event):
if event.key() == Qt.Key_Control:
if not self._mousePressed:
self._isPanning = False
self.setCursor(Qt.ArrowCursor)
else:
super(MyGraphicsView, self).keyPressEvent(event)
def wheelEvent(self, event):
factor = 1.2;
if event.delta() < 0:
factor = 1.0 / factor
self.scale(factor, factor)

Related

QStyledItemDelegate disabling button in last column moves selection to next row

Setup description
Table in PySide created with
QMainWindow - > QWidget -> QTableView -> TableModel (QAbstractTableModel) -> array[]
For second and third column is created ButtonDelegate(QStyledItemDelegate)
buttons toggle value in the first column - but butons must be separate for specific reason in my application (toggling with one button is not a solution)
button with value of the first column is "hidden"
only whole single row is selected (important in my application where I'm separately showing detailed data on the selected row)
Detailed description of functionality
Buttons in my application don't necesserily toggle value. Easiest to explaining fonctionality of my application is something like configuration list.
Initially in the list are generic items which can be selected and the two buttons are "+" (add/select) and "-" (remove/deselect).
Some items can be added only once, in that case the buttons are really only toggling the item selection. If not selected only the button "+" is show and if selected only button "-" is shown.
Some items can be added multiple times. In that case initially the item is unseleted. Presing "+" selects the item, shows "-" buton, but button "+" is still shown, as the item can be added multiple times. When pressed "+" once again, the next row with the same item is added, again with both "+" and "-" shown. Then pressing "-" works in reverse way, removing row where "-" is pressed until last item of the same type, where "-" results in unselected item. Therefore function of +/- is content dependent.
There few reasons I decided to have buttons in separate columns - keep possibility to sort based on selection state, header to show "Add" for "+" and "Remove" for "-"
Problem description
when button in last column is disabled (pushing False button and then True button), the selection moves to next row - should remain in the same
also, probably the showing and hiding of active button should be done in paint (instead of the openPersistentEditor). I was looking in the documentation and examples from google to find way how to, but still I haven't figured it out. If you could show me how, I would appreciate it. Also if you have link to some good tutorial on this topic (paint) I would be glad, because still I'm not getting how to use it.
Minimal functioning example:
from PySide6.QtWidgets import QApplication, QMainWindow, QVBoxLayout, QAbstractItemView
from PySide6.QtWidgets import QTableView, QWidget, QStyledItemDelegate, QPushButton
from PySide6.QtCore import Qt, QModelIndex, QAbstractTableModel, QItemSelectionModel
class TrueButtonDelegate(QStyledItemDelegate):
def __init__(self, parent):
QStyledItemDelegate.__init__(self, parent)
def paint(self, painter, option, index):
self.parent().openPersistentEditor(index) # this should be somewhere else, not in paint
def createEditor(self, parent, option, index):
editor = QPushButton('True', parent)
editor.setEnabled(False)
editor.clicked.connect(self.buttonClicked)
return editor
def setEditorData(self, editor, index):
if not index.data():
editor.setText('True')
editor.setEnabled(True)
editor.setFlat(False)
else:
editor.setText('')
editor.setEnabled(False)
editor.setFlat(True)
def setModelData(self, editor, model, index):
model.setData(index, True, role=Qt.EditRole)
def buttonClicked(self):
self.commitData.emit(self.sender())
def eventFilter(self, obj, event):
if event.type() == event.Type.Wheel:
event.setAccepted(False)
return True
return super().eventFilter(obj, event)
class FalseButtonDelegate(QStyledItemDelegate):
def __init__(self, parent):
QStyledItemDelegate.__init__(self, parent)
def paint(self, painter, option, index):
self.parent().openPersistentEditor(index) # this should be somewhere else, not in paint
def createEditor(self, parent, option, index):
editor = QPushButton('False', parent)
editor.setEnabled(True)
editor.clicked.connect(self.buttonClicked)
return editor
def setEditorData(self, editor, index):
if index.data():
editor.setText('False')
editor.setEnabled(True)
editor.setFlat(False)
else:
editor.setText('')
editor.setEnabled(False)
editor.setFlat(True)
def setModelData(self, editor, model, index):
model.setData(index, False, role=Qt.EditRole)
def buttonClicked(self):
self.commitData.emit(self.sender())
def eventFilter(self, obj, event):
if event.type() == event.Type.Wheel:
event.setAccepted(False)
return True
return super().eventFilter(obj, event)
class TableModel(QAbstractTableModel):
def __init__(self, localData=[[]], parent=None):
super().__init__(parent)
self.modelData = localData
def headerData(self, section: int, orientation: Qt.Orientation, role: int):
if role == Qt.DisplayRole:
if orientation == Qt.Vertical:
return "Row " + str(section)
def columnCount(self, parent=None):
return 3
def rowCount(self, parent=None):
return len(self.modelData)
def data(self, index: QModelIndex, role: int):
if role == Qt.DisplayRole:
row = index.row()
return self.modelData[row]
def setData(self, index, value = None, role=Qt.DisplayRole):
row = index.row()
self.modelData[row] = value
index = self.index(row, 0)
self.dataChanged.emit(index, index)
index = self.index(row, 1)
self.dataChanged.emit(index, index)
index = self.index(row, 2)
self.dataChanged.emit(index, index)
return True
app = QApplication()
data = [True, True, True, True, True, True, True, True, True, True, True, True, True, True]
model = TableModel(data)
tableView = QTableView()
tableView.setModel(model)
selectionModel = QItemSelectionModel(model)
tableView.setSelectionModel(selectionModel)
tableView.setItemDelegateForColumn(1, FalseButtonDelegate(tableView))
tableView.setItemDelegateForColumn(2, TrueButtonDelegate(tableView))
tableView.setSelectionBehavior(QAbstractItemView.SelectRows)
tableView.setSelectionMode(QAbstractItemView.SingleSelection)
widget = QWidget()
widget.horizontalHeader = tableView.horizontalHeader()
widget.horizontalHeader.setStretchLastSection(True)
widget.mainLayout = QVBoxLayout()
widget.mainLayout.setContentsMargins(1,1,1,1)
widget.mainLayout.addWidget(tableView)
widget.setLayout(widget.mainLayout)
mainWindow = QMainWindow()
mainWindow.setCentralWidget(widget)
mainWindow.setGeometry(0, 0, 380, 300)
mainWindow.show()
exit(app.exec())
The reason for this behavior is that disabling the widget automatically sets the focus to the next available widget in the focus chain.
The actual behavior is based on the QAbstractItemView's re-implementation of focusNextPrevChild, which creates a "virtual" QKeyPressEvent with a tab (or backtab) key that is sent to the keyPressEvent() handler.
By default, this results in calling the table view's reimplementation of moveCursor(), which focuses on the next selectable item (the first item in the next row in your case).
A possible workaround for this would be to use a subclass of QTableView and override focusNextPrevChild(); in this way you can first check if the current widget is a button and a child of the viewport (meaning it's one of your editors), and eventually just return True without doing anything else:
class TableView(QTableView):
def focusNextPrevChild(self, isNext):
if isNext:
current = QApplication.focusWidget()
if isinstance(current, QPushButton) and current.parent() == self.viewport():
return True
return super().focusNextPrevChild(isNext)
Unfortunately, this won't resolve a major issue with your implementation.
Implementing such complex systems like yours, requires some special care and knowledge about how Qt views work, and the main problem is related to the fact that setModelData() can be triggered by various reasons; one of them is whenever the current index of the view changes. This can happen with keyboard navigation (tab/backtab, arrows, etc), but also when the mouse changes the current selection: you can see this in your UI by clicking and keeping the mouse button pressed on an item on the first column, and then begin to drag the mouse on items that have buttons; since that operation changes the selection model, this also triggers the current index change, and consequentially the setModelData of the delegate, since the persistent editor is opened.
A better implementation (which also doesn't require separate delegates) implies knowing whether the current index corresponds to the "true" or "false" column. As long as you know the column used to show contents when the value is True, then setting the value and showing the buttons is just a matter of comparing those three values:
value = index.data()
trueColumn = index.column() == self.TrueColumn
if value == trueColumn:
# we are in the column that should show the widget
else:
# we are in the other column (whatever it is)
Setting the data when the button is pressed follows the same concept; if the button is in the "true" column (the one used to set the value to False), set it to False, and vice versa:
model.setData(index, index.column() != self.TrueColumn, Qt.EditRole)
Then, some further adjustments are also required:
to avoid focus problems, you can make the editor ignore mouse events by setting the attribute Qt.WA_TransparentForMouseEvents, and ignore keyboard focus by setting the focus policy to No.Focus; then restore the default behavior when the editor is "restored";
to make the button transparent, use a stylesheet that makes every component invisible: color: transparent; background: transparent; border: none;;
do not open the editor in the paint method, but properly call openPersistentIndex() both when the model is set and when new rows are added;
if you want to hide the text of the index, just override displayText() and return an empty string; in this way you can keep the default paint behavior which shows selected items;
class ButtonDelegate(QStyledItemDelegate):
TrueColumn = 1
isClicked = False
def buttonClicked(self):
self.isClicked = True
self.commitData.emit(self.sender())
self.isClicked = False
def createEditor(self, parent, option, index):
editor = QPushButton(str(index.column() != self.TrueColumn), parent)
editor.clicked.connect(self.buttonClicked)
return editor
def eventFilter(self, editor, event):
if event.type() == event.MouseMove:
editor.mouseMoveEvent(event)
event.setAccepted(True)
return True
return super().eventFilter(editor, event)
def displayText(self, *args):
return ''
def setEditorData(self, editor, index):
value = index.data()
trueColumn = index.column() == self.TrueColumn
if value == trueColumn:
editor.setAttribute(Qt.WA_TransparentForMouseEvents, False)
editor.setStyleSheet('')
editor.setFocusPolicy(Qt.StrongFocus)
if self.isClicked:
editor.setFocus()
self.parent().setCurrentIndex(index)
else:
editor.setAttribute(Qt.WA_TransparentForMouseEvents, True)
editor.setStyleSheet(
'color:transparent; background: transparent; border: none;')
editor.setFocusPolicy(Qt.NoFocus)
def setModelData(self, editor, model, index):
sender = self.sender()
if sender:
model.setData(index, index.column() != self.TrueColumn, Qt.EditRole)
app = QApplication([])
data = [True] * 16
tableView = QTableView()
tableView.setModel(model)
selectionModel = QItemSelectionModel(model)
tableView.setSelectionModel(selectionModel)
delegate = ButtonDelegate(tableView)
tableView.setItemDelegateForColumn(1, delegate)
tableView.setItemDelegateForColumn(2, delegate)
tableView.setSelectionBehavior(QAbstractItemView.SelectRows)
tableView.setSelectionMode(QAbstractItemView.SingleSelection)
def updateEditors(parent, first, last):
for row in range(first, last + 1):
tableView.openPersistentEditor(model.index(row, 1))
tableView.openPersistentEditor(model.index(row, 2))
updateEditors(None, 0, model.rowCount() - 1)
model.rowsInserted.connect(updateEditors)
# ...
A further improvement would consider tab navigation, and for this you need to tweak the model and the view. With the following modifications, pressing tab only changes between indexes with valid data or active editor:
class TableModel(QAbstractTableModel):
tabPressed = False
def __init__(self, localData=[[]], parent=None):
super().__init__(parent)
self.modelData = localData
def flags(self, index):
flags = super().flags(index)
if 0 < index.column() < self.columnCount() and self.tabPressed:
if (index.column() != 1) == self.modelData[index.row()]:
flags &= ~(Qt.ItemIsSelectable | Qt.ItemIsEnabled)
return flags
def headerData(self, section: int, orientation: Qt.Orientation, role: int):
if role == Qt.DisplayRole and orientation == Qt.Vertical:
return "Row " + str(section)
def columnCount(self, parent=None):
return 3
def rowCount(self, parent=None):
return len(self.modelData)
def data(self, index: QModelIndex, role: int):
if role == Qt.DisplayRole:
return self.modelData[index.row()]
def setData(self, index, value = None, role=Qt.DisplayRole):
row = index.row()
self.modelData[row] = value
# do not emit dataChanged for each index, emit it for the whole range
self.dataChanged.emit(self.index(row, 0), self.index(row, 2))
return True
class TableView(QTableView):
def moveCursor(self, action, modifiers):
self.model().tabPressed = True
new = super().moveCursor(action, modifiers)
self.model().tabPressed = False
return new
# ...
tableView = TableView()
Update: further options
It occured to me that there is another available alternative: while keeping the two-column requirement, it is possible to have a single delegate, as long as the table has properly set spans.
This requests some ingenuity, and a further class (with a proper user property set) is required, but it might provide a better result; the trick is to create a custom widget that contains both buttons. Some further adjustments are required too (especially to ensure that the size of the inner widgets is respected whenever the columns are resized).
class Switch(QWidget):
valueChanged = Signal(bool)
clicked = Signal()
_value = False
def __init__(self, table, column):
super().__init__(table.viewport())
self.setFocusPolicy(Qt.TabFocus)
layout = QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
self.spacing = self.style().pixelMetric(QStyle.PM_HeaderGripMargin)
layout.setSpacing(self.spacing)
self.buttons = []
for v in range(2):
button = QPushButton(str(bool(v)))
self.buttons.append(button)
layout.addWidget(button)
button.setMinimumWidth(10)
button.clicked.connect(self.buttonClicked)
self.header = table.horizontalHeader()
self.columns = column, column + 1
self.updateButtons(False)
self.header.sectionResized.connect(self.updateSizes)
self.resizeTimer = QTimer(self, interval=0, singleShot=True,
timeout=self.updateSizes)
#Property(bool, user=True, notify=valueChanged)
def value(self):
return self._value
#value.setter
def value(self, value):
if self._value != value:
self._value = value
self.valueChanged.emit(value)
self.updateButtons(self._value)
def updateButtons(self, value):
focused = False
self.setFocusProxy(None)
for i, button in enumerate(self.buttons):
if i != value:
button.setAttribute(Qt.WA_TransparentForMouseEvents, False)
self.setFocusProxy(button)
button.setStyleSheet('')
else:
if button.hasFocus():
focused = True
button.setAttribute(Qt.WA_TransparentForMouseEvents, True)
button.setStyleSheet(
'color: transparent; background: transparent; border: none;')
if focused:
self.setFocus(Qt.MouseFocusReason)
def buttonClicked(self):
button = self.sender()
self.value = bool(self.buttons.index(button))
self.clicked.emit()
def updateSizes(self):
for i, column in enumerate(self.columns):
size = self.header.sectionSize(column)
if i == 0:
size -= self.spacing
self.layout().setStretch(i, size)
self.layout().activate()
def focusNextPrevChild(self, isNext):
return False
def resizeEvent(self, event):
self.updateSizes()
class SwitchButtonDelegate(QStyledItemDelegate):
def displayText(self, *args):
return ''
def createEditor(self, parent, option, index):
editor = Switch(self.parent(), index.column())
def clicked():
if persistent.isValid():
index = persistent.model().index(
persistent.row(), persistent.column(), persistent.parent())
view.setCurrentIndex(index)
view = option.widget
persistent = QPersistentModelIndex(index)
editor.clicked.connect(clicked)
editor.valueChanged.connect(lambda: self.commitData.emit(editor))
return editor
# ...
tableView.setItemDelegateForColumn(1, SwitchButtonDelegate(tableView))
def updateEditors(parent, first, last):
for row in range(first, last + 1):
tableView.setSpan(row, 1, 1, 2)
tableView.openPersistentEditor(model.index(row, 1))
Of course, the simpler solution is to avoid any editor at all, and delegate the painting to the item delegate.
class PaintButtonDelegate(QStyledItemDelegate):
_pressIndex = _mousePos = None
def __init__(self, trueColumn=0, parent=None):
super().__init__(parent)
self.trueColumn = trueColumn
def paint(self, painter, option, index):
opt = QStyleOptionViewItem(option)
self.initStyleOption(opt, index)
style = opt.widget.style()
opt.text = ''
opt.state |= style.State_Enabled
style.drawControl(style.CE_ItemViewItem, opt, painter, opt.widget)
if index.data() == (index.column() == self.trueColumn):
btn = QStyleOptionButton()
btn.initFrom(opt.widget)
btn.rect = opt.rect
btn.state = opt.state
btn.text = str(index.column() != self.trueColumn)
if self._pressIndex == index and self._mousePos in btn.rect:
btn.state |= style.State_On
if index == option.widget.currentIndex():
btn.state |= style.State_HasFocus
style.drawControl(style.CE_PushButton, btn, painter, opt.widget)
def editorEvent(self, event, model, option, index):
if event.type() == event.MouseButtonPress:
if index.data() == (index.column() == self.trueColumn):
self._pressIndex = index
self._mousePos = event.pos()
option.widget.viewport().update()
elif event.type() == event.MouseMove and self._pressIndex is not None:
self._mousePos = event.pos()
option.widget.viewport().update()
elif event.type() == event.MouseButtonRelease:
if self._pressIndex == index and event.pos() in option.rect:
model.setData(index, not index.data(), Qt.EditRole)
self._pressIndex = self._mousePos = None
option.widget.viewport().update()
elif event.type() == event.KeyPress:
if event.key() == Qt.Key_Space:
value = not index.data()
model.setData(index, value, Qt.EditRole)
newIndex = model.index(index.row(), self.trueColumn + (not value))
option.widget.setCurrentIndex(newIndex)
option.widget.viewport().update()
return super().editorEvent(event, model, option, index)
# ...
delegate = PaintButtonDelegate(1, tableView)
tableView.setItemDelegateForColumn(1, delegate)
tableView.setItemDelegateForColumn(2, delegate)
Note that in this case, if you want to keep a valid keyboard (Tab) navigation, the model also requires adjustments:
class TableModel(QAbstractTableModel):
# ...
def flags(self, index):
flags = super().flags(index)
if 0 < index.column() < 3:
if index.data() == index.column() - 1:
flags &= ~Qt.ItemIsEnabled
return flags
This unfortunately results in unexpected behavior of the horizontal header, as only the enabled columns will be "highlighted" with some specific styles.
That said, the other important drawback of this approach is that you will completely lose any animation provided by the style: since the style uses actual widgets to create visual animations, and the painting is only based on the current QStylOption value, those animations will not be available.

Extending selection in either direction in a QTextEdit

Currently, QTextEdit permits selecting text and then altering that selection with shift-click-drag only on the side of the selection opposite the anchor. The anchor is placed where the selection started. If the user tries to alter the selection near the start, the selection pivots around the anchor point instead of extending. I'd like to permit changing the selection from either side.
My first attempt is to simply set the anchor on the opposite side from where the cursor is located. Say, for example, the selection is from 10 to 20. If the cursor is shift-click-dragged at position 8, then the anchor would be set to 20. If the cursor is shift-click-dragged at position 22, then the anchor would be set to 10. Later, I'll try something more robust, perhaps based on the center point of the selection.
I thought this code would work, but it does not seem to affect the default behavior at all. What have I missed?
import sys
from PySide.QtCore import *
from PySide.QtGui import *
class TextEditor(QTextEdit):
def __init__(self, parent=None):
super().__init__(parent)
self.setReadOnly(True)
self.setMouseTracking(True)
def mouseMoveEvent(self, event):
point = QPoint()
x = event.x() #these are relative to the upper left corner of the text edit window
y = event.y()
point.setX(x)
point.setY(y)
self.mousepos = self.cursorForPosition(point).position() # get character position of current mouse using local window coordinates
if event.buttons()==Qt.LeftButton:
modifiers = QApplication.keyboardModifiers()
if modifiers == Qt.ShiftModifier:
start = -1 #initialize to something impossible
end = -1
cursor = self.textCursor()
select_point1 = cursor.selectionStart()
select_point2 = cursor.selectionEnd()
if select_point1 < select_point2: # determine order of selection points
start = select_point1
end = select_point2
elif select_point2 < select_point1:
start = select_point2
end = select_point1
if self.mousepos > end: # if past end when shift-click then trying to extend right
cursor.setPosition(start, mode=QTextCursor.MoveAnchor)
elif self.mousepos < start: # if before start when shift-click then trying to extend left
cursor.setPosition(end, mode=QTextCursor.MoveAnchor)
if start != -1 and end != -1: #if selection exists then this should trigger
self.setTextCursor(cursor)
super().mouseMoveEvent(event)
Here's a first stab at implementing shift+click extension of the current selection. It seems to work okay, but I have not tested it to death, so there may be one or two glitches. The intended behaviour is that a shift+click above or below the selection should extend the whole selection in that direction; and a shift+click with drag should do the same thing, only continuously.
Note that I have also set the text-interaction flags so that the caret is visible in read-only mode, and the selection can also be manipulated with the keyboard in various ways (e.g. ctrl+shift+right extends the selection to the next word).
import sys
from PySide.QtCore import *
from PySide.QtGui import *
class TextEditor(QTextEdit):
def __init__(self, parent=None):
super().__init__(parent)
self.setReadOnly(True)
self.setTextInteractionFlags(
Qt.TextSelectableByMouse |
Qt.TextSelectableByKeyboard)
def mouseMoveEvent(self, event):
if not self.setShiftSelection(event, True):
super().mouseMoveEvent(event)
def mousePressEvent(self, event):
if not self.setShiftSelection(event):
super().mousePressEvent(event)
def setShiftSelection(self, event, moving=False):
if (event.buttons() == Qt.LeftButton and
QApplication.keyboardModifiers() == Qt.ShiftModifier):
cursor = self.textCursor()
start = cursor.selectionStart()
end = cursor.selectionEnd()
if not moving or start != end:
anchor = cursor.anchor()
pos = self.cursorForPosition(event.pos()).position()
if pos <= start:
start = pos
elif pos >= end:
end = pos
elif anchor == start:
end = pos
else:
start = pos
if pos <= anchor:
start, end = end, start
cursor.setPosition(start, QTextCursor.MoveAnchor)
cursor.setPosition(end, QTextCursor.KeepAnchor)
self.setTextCursor(cursor)
return True
return False
if __name__ == '__main__':
app = QApplication(sys.argv)
window = TextEditor()
window.setText(open(__file__).read())
window.setGeometry(600, 50, 800, 800)
window.show()
sys.exit(app.exec_())

QGraphicsView: how to make rubber band selection appear only on left mouse button?

I want to make a QGraphicsScene and show it in QGraphicsView. I want to scroll the scene by middle mouse button and make rubber band selection by left button. But I don't know how to make the rubber band selection appear only by left mouse button.
Here's my code:
# -*- coding: utf-8 -*-
import os, sys
from PyQt5 import QtWidgets, QtCore, QtGui, QtSvg
class MegaSceneView(QtWidgets.QGraphicsView):
def __init__(self, parent=None):
super(MegaSceneView, self).__init__(parent)
self._scale_factor = 1.0
self._scale_by = 1.2
self.setAcceptDrops(True)
self.setRenderHint(QtGui.QPainter.Antialiasing)
self.setMouseTracking(True)
self.setRubberBandSelectionMode(QtCore.Qt.IntersectsItemShape)
self.setDragMode(QtWidgets.QGraphicsView.RubberBandDrag)
self._prev_mouse_scene_pos = None
def mousePressEvent(self, event):
if (event.buttons() & QtCore.Qt.MidButton) != QtCore.Qt.NoButton:
self._prev_mouse_scene_pos = (event.pos())
super(MegaSceneView, self).mousePressEvent(event)
def mouseReleaseEvent(self, event):
super(MegaSceneView, self).mouseReleaseEvent(event)
self._prev_mouse_scene_pos = None
def mouseMoveEvent(self, event):
super(MegaSceneView, self).mouseMoveEvent(event)
if (event.buttons() & QtCore.Qt.MidButton) != QtCore.Qt.NoButton:
cur_mouse_pos = (event.pos())
if self._prev_mouse_scene_pos is not None:
delta_x = cur_mouse_pos.x() - self._prev_mouse_scene_pos.x()
delta_y = cur_mouse_pos.y() - self._prev_mouse_scene_pos.y()
self.horizontalScrollBar().setValue(self.horizontalScrollBar().value() - delta_x)
self.verticalScrollBar().setValue(self.verticalScrollBar().value() - delta_y)
self._prev_mouse_scene_pos = (event.pos())
if __name__ == "__main__":
app = QtWidgets.QApplication(sys.argv)
mega_view = MegaSceneView()
mega_scene = QtWidgets.QGraphicsScene(-500, -500, 1000, 1000)
# mega_scene = QtWidgets.QGraphicsScene()
rect_item_1 = QtWidgets.QGraphicsRectItem(-30, -20, 60, 40)
mega_scene.addItem(rect_item_1)
rect_item_2 = QtWidgets.QGraphicsRectItem(-20, -30, 40, 60)
mega_scene.addItem(rect_item_2)
rect_item_2.setPos(300, 200)
mega_view.setScene(mega_scene)
mega_view.show()
sys.exit(app.exec_())
What should I add to make the rubber band appear only by left button?
You can set the drag mode in the mousePressEvent and mouseReleaseEvent functions in your view class, so it stays in the RubberBandDrag as default, but switches to NoDrag mode when the middle mouse button is held.
Like so - c++, but the idea is the same in all languages - :
void YourViewClass::mousePressEvent(QMouseEvent* event) {
if (event->buttons() == Qt::MiddleButton)
setDragMode(QGraphicsView::NoDrag);
}
void YourViewClass::mouseReleaseEvent(QMouseEvent* event) {
if (event->button() == Qt::MiddleButton)
setDragMode(QGraphicsView::RubberBandDrag);
}
There isn't a built-in way to do this. You will need to subclass the mousePressEvent, mouseMoveEvent, and mouseReleaseEvent for your graphics view and create the visible rubber band yourself. (QRubberBand works well for this.) When the user releases the mouse, you then need to convert the rubber band extents into scene coordinates and call QGraphicsScene::setSelectionArea.

QDoubleSpinBox + MousePressEvent

I am trying to implement my own version of the QDoubleSpinBox to have it act like in Maya with int/FloatFields so you can Ctrl+click+drag to change the value.
I have this system working on a QLabel by setting the text from my value but then when I try to do it on a QDoubleSpinBox I have a problem with the mousePressEvent. It only works on the arrowButtons, not it the field itself...
here is my initial code :
class MayaLikeDoubleField(QtGui.QDoubleSpinBox):
def __init__(self, parent):
super(MayaLikeDoubleField, self).__init__()
self.offset = 0
def mousePressEvent(self, event):
self.offset = event.x()
def mouseMoveEvent(self, event):
modifiers = QtGui.QApplication.keyboardModifiers()
if modifiers == QtCore.Qt.ControlModifier:
QtGui.QWidget.mouseMoveEvent(self, event)
relPos = event.x()-self.offset
print relPos*0.01
# instead of printing I would set the value.
In this case, it doesn' work. So I have tried this :
class Widget(QtGui.QWidget):
def __init__(self):
super(Widget, self).__init__()
self.layout = QtGui.QHBoxLayout(self)
self.sbox = QtGui.QDoubleSpinBox()
self.layout.addWidget(self.line)
self.sbox.installEventFilter(self)
def mousePressEvent(self, event):
print "Main Widget Mouse Press"
super(Widget, self).mousePressEvent(event)
def eventFilter(self, obj, event):
modifiers = QtGui.QApplication.keyboardModifiers()
if modifiers == QtCore.Qt.ControlModifier:
if self.layout.indexOf(obj) != -1:
if event.type() == event.MouseButtonPress:
print "Widget click"
if event.type() == event.MouseMove:
print "Widget Move"
return super(Widget, self).eventFilter(obj, event)
And surprise ! it doesn't work ... but if i replace the QDoubleSpinBox by a QLineEdit it does work ... why ? for me it works the same way as by default the mousePressButton doesn't work on it...
Is there anything special that I am missing ?
Thanks !

PyQt: eventFilter to get mouse position in a semi-transparent window

I want to make:
a semi-transparent fullscreen window (rgba(0,0,0,180)).
while moving mouse, display absolute position on label.
user can press on it to get the absolute position of the mouse.
However I cannot achieve the second one. When moving mouse on it, label won't update mouse's position. But I found when moving out of label (after removing layout.setMargin(0) and layout.setSpacing(0)), it works.
# -*- coding: utf-8 -*-
import sys, os, math
from PyQt4 import QtCore, QtGui
class ScreenPositionLabel(QtGui.QWidget):
def __init__(self):
super(ScreenPositionLabel, self).__init__()
self.setStyleSheet("background-color:rgba(0, 0, 0, 180); color:#fff;")
self.setAttribute(QtCore.Qt.WA_TranslucentBackground)
#self.setAttribute(QtCore.Qt.WA_OpaquePaintEvent, False)
#self.setStyleSheet("QMainWindow{opacity:0.5;}")
self.label = QtGui.QLabel("Please click on screen")
self.label.setAlignment(QtCore.Qt.AlignCenter)
layout = QtGui.QHBoxLayout()
layout.addWidget(self.label)
# remove margin and padding
layout.setMargin(0)
layout.setSpacing(0)
self.setLayout(layout)
self.setMouseTracking(True)
self.installEventFilter(self)
self.label.show()
self.show()
def eventFilter(self, source, event):
if (event.type() == QtCore.QEvent.MouseMove and
event.buttons() == QtCore.Qt.NoButton):
pos = event.pos()
self.label.setText('Please click on screen. ( %d : %d )' % (pos.x(), pos.y()))
elif event.type() == QtCore.QEvent.MouseButtonPress:
pos = event.pos()
print('( %d : %d )' % (pos.x(), pos.y()))
self.close()
return QtGui.QWidget.eventFilter(self, source, event)
app = QtGui.QApplication(sys.argv)
main_window = ScreenPositionLabel()
app.exec_()
Any way to solve this problem? Thanks!
Your 4 questions:
1) I want to make: a semi-transparent fullscreen window (rgba(0,0,0,180)).
Yes, you can. Please use QWidget.setWindowOpacity (self, float level).
2) I want to make: while moving mouse, display absolute position on label.
I recommend using QWidget.mouseMoveEvent (self, QMouseEvent) to get current position your mouse and enable QWidget.setMouseTracking (self, bool enable) for track all mouse movement.
QWidget.setMouseTracking (self, bool enable)
QWidget.mouseMoveEvent (self, QMouseEvent)
3) I want to make: user can press on it to get the absolute position of the mouse.
Using QWidget.mousePressEvent (self, QMouseEvent) to track when mouse press.
4) However I cannot achieve the second one. When moving mouse on it, label won't update mouse's position. But I found when moving out of label (after removing layout.setMargin(0) and layout.setSpacing(0)), it works.
Because in default layout height of QLabel has spacing & margin, then real area isn't all area widget solve it is your solution is OK.
Full example for your solution:
import sys
from PyQt4 import QtGui, QtCore
class QCustomLabel (QtGui.QLabel):
def __init__ (self, parent = None):
super(QCustomLabel, self).__init__(parent)
self.setMouseTracking(True)
self.setTextLabelPosition(0, 0)
self.setAlignment(QtCore.Qt.AlignCenter)
def mouseMoveEvent (self, eventQMouseEvent):
self.setTextLabelPosition(eventQMouseEvent.x(), eventQMouseEvent.y())
QtGui.QWidget.mouseMoveEvent(self, eventQMouseEvent)
def mousePressEvent (self, eventQMouseEvent):
if eventQMouseEvent.button() == QtCore.Qt.LeftButton:
QtGui.QMessageBox.information(self, 'Position', '( %d : %d )' % (self.x, self.y))
QtGui.QWidget.mousePressEvent(self, eventQMouseEvent)
def setTextLabelPosition (self, x, y):
self.x, self.y = x, y
self.setText('Please click on screen ( %d : %d )' % (self.x, self.y))
class QCustomWidget (QtGui.QWidget):
def __init__ (self, parent = None):
super(QCustomWidget, self).__init__(parent)
self.setWindowOpacity(0.7)
# Init QLabel
self.positionQLabel = QCustomLabel(self)
# Init QLayout
layoutQHBoxLayout = QtGui.QHBoxLayout()
layoutQHBoxLayout.addWidget(self.positionQLabel)
layoutQHBoxLayout.setMargin(0)
layoutQHBoxLayout.setSpacing(0)
self.setLayout(layoutQHBoxLayout)
self.showFullScreen()
myQApplication = QtGui.QApplication(sys.argv)
myQTestWidget = QCustomWidget()
myQTestWidget.show()
myQApplication.exec_()

Resources