widget's leaveEvent() is missed when scrolling through the encapsulating QScrollArea() - python

I have a grid of QWidget()s in a QScrollArea(). I override their leaveEvent() for a particular reason (more on that later). When moving around with the mouse pointer, the leaveEvent() is triggered just fine. But when I keep the mouse pointer still and scroll through the QScrollArea() with my scrollwheel, the mouse pointer effectively leaves the widget without triggering a leaveEvent().
1. Some context
I'm working on an application to browse through Arduino libraries:
What you see in the screenshot is a QScrollArea() in the middle with a scrollbar on the right and one at the bottom. This QScrollArea() contains a large QFrame() that holds all the widgets in its QGridLayout(). Each widget - usually a QLabel() or QPushButton() - has a lightgrey border. This way, I visualize the grid they're in.
2. Lighting up a whole row
When clicking on a widget, I want the whole row to light up. To achieve this, I override the mousePressEvent() for each of them:
Note: In practice, I just make each widget a subclasses from a custom class
CellWidget(), such that I need this method-override only once.
def mousePressEvent(self, e:QMouseEvent) -> None:
'''
Set 'blue' property True for all neighbors.
'''
for w in self.__neighbors:
w.setProperty("blue", True)
w.style().unpolish(w)
w.style().polish(w)
w.update()
super().mousePressEvent(e)
return
The variable self.__neighbors is a reference each widget keeps to all its neighboring widgets at the left and right side. This way, each widget has access to all widgets from its own row.
As you can see, I set the property "blue" from each widget. This has an effect in the StyleSheet. For example, this is the stylesheet I give to the QLabel() widgets:
# Note: 'self' is the QLabel() widget.
self.setStyleSheet(f"""
QLabel {
background-color: #ffffff;
color: #2e3436;
border-left: 1px solid #eeeeec;
border-top: 1px solid #eeeeec;
border-right: 1px solid #d3d7cf;
border-bottom: 1px solid #d3d7cf;
}
QLabel[blue = true] {
background-color: #d5e1f0;
}
""")
The unpolish() and polish() methods ensure that the stylesheet gets reloaded (I think?) and the update() method invokes a repaint. The procedure works nice. I click on any given widget, and the whole row lights up blue!
3. Turn off the (blue) light
To turn it off, I override the leaveEvent() method:
def leaveEvent(self, e:QEvent) -> None:
'''
Clear 'blue' property for all neighbors.
'''
for w in self.__neighbors:
w.setProperty("blue", False)
w.style().unpolish(w)
w.style().polish(w)
w.update()
super().leaveEvent(e)
return
So the whole row remains lighted up until you leave the widget with your mouse pointer. The leaveEvent() clears the 'blue' property for all neighbors and forces a repaint on them.
4. The problem
But there is a weakness. When I keep my mouse at the same position and I scroll the mousewheel down, the mouse pointer effectively leaves the widget without triggering a leaveEvent(). The absence of such leaveEvent() breaks my solution. While keeping the mouse still and scrolling down, you can click several rows and they all remain blue.
5. System info
I'm working on an application for both Windows and Linux. The application is written in Python 3.8 and uses PyQt5.

Both enterEvent and leaveEvent obviously depend on mouse movements, and since the scrolling does not involve any movement your approach won't work until the user slightly moves the mouse.
I can think about two possible workarounds, but both of them are a bit hacky and not very elegant. In both cases, I'm subclassing the scroll area and override the viewportEvent
Slightly move the mouse
Since those events require a mouse movement, let's move the mouse (and then move it back):
class ScrollArea(QtWidgets.QScrollArea):
def fakeMouseMove(self):
pos = QtGui.QCursor.pos()
# slightly move the mouse
QtGui.QCursor.setPos(pos + QtCore.QPoint(1, 1))
# ensure that the application correctly processes the mouse movement
QtWidgets.QApplication.processEvents()
# restore the previous position
QtGui.QCursor.setPos(pos)
def viewportEvent(self, event):
if event.type() == event.Wheel:
# let the viewport handle the event correctly, *then* move the mouse
QtCore.QTimer.singleShot(0, self.fakeMouseMove)
return super().viewportEvent(event)
Emulate the enter and leave events
This uses widgetAt() and creates fake enter and leave events sent to the relative widgets, which is not very good for performance: widgetAt might be slow, and repolishing the widgets also takes some time.
class ScrollArea(QtWidgets.QScrollArea):
def viewportEvent(self, event):
if event.type() == event.Wheel:
old = QtWidgets.QApplication.widgetAt(event.globalPos())
res = super().viewportEvent(event)
new = QtWidgets.QApplication.widgetAt(event.globalPos())
if new != old:
QtWidgets.QApplication.postEvent(old,
QtCore.QEvent(QtCore.QEvent.Leave))
QtWidgets.QApplication.postEvent(new,
QtCore.QEvent(QtCore.QEvent.Enter))
return res
return super().viewportEvent(event)

Related

drawRect() in QTextEdit not functioning properly when cursor is moved

I've got this simple code in the paintEvent of my QTextEdit which draws a grey box under the currently selected QTextBlock:
def paintEvent(self, ev):
painter = QPainter()
painter.begin(self.viewport())
currentPos = self.textCursor().position()
block = self.document().findBlock(currentPos)
rect = self.document().documentLayout().blockBoundingRect(block)
margin = self.document().documentMargin()
rect.setTopLeft(QPoint(int(rect.topLeft().x()-margin), int(rect.topLeft().y()-margin)))
rect.setBottomRight(QPoint(int(rect.bottomRight().x()+margin), int(rect.bottomRight().y())))
painter.fillRect(rect, QBrush(QColor(10, 10, 10,20)))
if self._last_selected_block and (self._last_selected_block != block):
lastrect = self.document().documentLayout().blockBoundingRect(self._last_selected_block) #clean up artifacts
painter.eraseRect(lastrect)
painter.fillRect(self.contentsRect(), QBrush(QColor(123, 111, 145, 80))) #background color
painter.end()
self._last_selected_block = block
super().paintEvent(ev)
(Note, the "clean up artifacts" line erases anything drawn in the region of the previously selected QTextBlock, since a thin grey line would remain under the last block if text was drawn in it. Might be related.)
The effect of this is:
However, when the cursor is moved via clicking on another line, this happens:
The next rectangle is only partially drawn, around the character which the cursor was moved to, and the previous one is not erased. eraseRect() doesn't seem to be able to remove this artifact. When typing is continued or a newline is made, everything goes back to normal (this issue never occurs when changing lines via making a newline). I've confirmed that paintEvent() is called when the cursor moves, and the width of the rectangles to be drawn never changes. What's happening here?
For optimization reasons, Qt only try to repaint only the portions of the widget that actually need updates.
In the case of a QTextEdit, this means that only the portions in which the "caret" was before moving it (by editing, using arrow keys or mouse) and it is now will be updates, while ignoring everything else.
This clearly is an issue in your case, because it will only update small portions of the widget, with the result that the previously highlighted block will not be redrawn showing your custom background.
The solution is to track the current cursor position and correctly update both the previous block and the new one as soon as the block changes. This is achieved by calling update() using a QRegion, created by merging the current block bounding rect and the previous (if any); this will schedule an update that only redraws the contents within that region (which is what QTextEdit normally does, but we're extending it to the whole block area and the previous).
Note that I've completely changed your implementation of the paintEvent, as it was mostly unnecessary, for the following reasons:
the margins of the document should not be used for the block;
the background painting doesn't consider the scroll area background (more about this later);
there is no need to "erase" the previous block rectangle: our update() call includes that area, and since it's not the current block, the background will be just (re)drawn there instead;
the current block shouldn't be updated in the paint event;
The rendering of a scroll area always involves painting of its background based on the backgroundRole, which is automatically set to the Base palette role for QTextEdit. The result is that your background color will not be what you believe, but a composition of the base (usually, a nearly white color) and your background.
In order to ensure that the background is exactly the color you want, you have to update the palette on the widget using that color for that Base role, which should also be an opaque color (otherwise it will be composed using the Window color role).
class TextEdit(QtWidgets.QTextEdit):
_last_selected_block = None
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
palette = self.palette()
palette.setColor(palette.Base, QtGui.QColor(203, 200, 210))
self.setPalette(palette)
self.cursorPositionChanged.connect(self.trackCursorPosition)
def trackCursorPosition(self):
block = self.textCursor().block()
currentRect = self.document().documentLayout().blockBoundingRect(block)
updateRegion = QtGui.QRegion(currentRect.toRect())
if self._last_selected_block and self._last_selected_block != block:
oldRect = self.document().documentLayout().blockBoundingRect(
self._last_selected_block)
updateRegion |= QtGui.QRegion(oldRect.toRect())
updateRegion.translate(0, -self.verticalScrollBar().value())
self._last_selected_block = block
self.viewport().update(updateRegion)
def paintEvent(self, ev):
painter = QtGui.QPainter(self.viewport())
block = self.textCursor().block()
rect = self.document().documentLayout().blockBoundingRect(block)
rect.translate(0, -self.verticalScrollBar().value())
painter.fillRect(rect, QtGui.QColor(10, 10, 10,20))
super().paintEvent(ev)

How to dissable the focus indication in stylesheet

When my QDoubleSpinBox is focused, it gets a blue outline to it (in "Fusion" style):
How do I turn this off?
Doing this with stylesheets only is doable, but has an important drawback: styling complex widgets like a QSpinBox requires to correctly set all sub control properties.
The basic solution is to set the border for the widget:
QSpinBox {
border: 1px inset palette(mid);
border-radius: 2px;
}
Keep in mind that offering proper visible response of the focus is really important; you might not like the "glow" (and color) the Fusion style offers, but nonetheless it should always be visible when a widget has focus or not, even if it has a blinking text cursor. You can do that by specifying a slightly different color with the :focus selector:
QSpinBox:focus {
border: 1px inset palette(dark);
}
Unfortunately, as explained in the beginning, this has an important drawback: as soon as the stylesheet is applied, the widget painting falls back to the basic primitive methods (the spinbox on the right uses the stylesheet above):
Unfortunately, there's almost no direct way to restore the default painting of the arrows, as using the stylesheet prevents that. So, the only solution is to provide the properties for the controls as explained in the examples about customizing QSpinBox.
There is an alternative, though, using QProxyStyle. The trick is to intercept the control in the drawComplexControl() implementation and remove the State_HasFocus flag of the option before calling the default implementation.
In the following example, I also checked the focus before removing the flag in order to provide sufficient visual feedback, and I also removed the State_MouseOver flag which shows the glowing effect when hovering.
class Proxy(QtWidgets.QProxyStyle):
def drawComplexControl(self, cc, opt, qp, widget=None):
if cc == self.CC_SpinBox:
opt = QtWidgets.QStyleOptionSpinBox(opt)
if opt.state & self.State_HasFocus:
opt.palette.setColor(QtGui.QPalette.Window,
opt.palette.color(QtGui.QPalette.Window).darker(100))
else:
opt.palette.setColor(QtGui.QPalette.Window,
opt.palette.color(QtGui.QPalette.Window).lighter(125))
opt.state &= ~(self.State_HasFocus | self.State_MouseOver)
super().drawComplexControl(cc, opt, qp, widget)
# ...
app = QtWidgets.QApplication(sys.argv)
app.setStyle(Proxy())
# ...
Note that the above "color correction" only works for Fusion style and other styles that use the Window palette role for painting the border. For instance, the Windows style doesn't consider it at all, or you might want to use higher values of darker() or lighter() in order to provide better differentiation.

Circular image as button PyQt5

I'm trying to create a circular button with an image.
So far I've been able to create a circular button and a button with an image background but I haven't been able to combine the two.
Here is my current code:
import sys
import PyQt5.QtWidgets
class Window(PyQt5.QtWidgets.QMainWindow):
def __init__(self):
super().__init__()
# setting title
self.setWindowTitle("Python ")
# setting geometry
self.setGeometry(100, 100, 600, 400)
# calling method
self.UiComponents()
# showing all the widgets
self.show()
# method for widgets
def UiComponents(self):
button = PyQt5.QtWidgets.QPushButton("CLICK", self)
button.setGeometry(200, 150, 100, 100)
# Background img and circular
button.setStyleSheet('''border-radius : 5;
border : 2px solid black
background-image : url(image.png);
''')
# adding action to a button
button.clicked.connect(self.clickme)
# action method
def clickme(self):
print("pressed")
App = PyQt5.QtWidgets.QApplication(sys.argv)
window = Window()
sys.exit(App.exec())
Qt documentation has a section exactly about this topic:
When styling a QPushButton, it is often desirable to use an image as the button graphic. It is common to try the background-image property, but this has a number of drawbacks: For instance, the background will often appear hidden behind the button decoration, because it is not considered a background. In addition, if the button is resized, the entire background will be stretched or tiled, which does not always look good.
It is better to use the border-image property, as it will always display the image, regardless of the background (you can combine it with a background if it has alpha values in it), and it has special settings to deal with button resizing.
So, you must ensure that you're using a square (not rectangular) image with the circle margins right at the edges, and use border-image instead of background-image (which tiles the image if it's smaller than the button size); note that you should not set any border in the stylesheet.
button.setStyleSheet('''
border-image: url(image.png);
''')
You obviously need to always use the same value for both the height and the width of the button, possibly by using setFixedSize(), otherwise if you add the button to a layout it will not be circular any more.

QGraphicsView behaving differently when mouse is held over a QGraphicsProxyWidget

I have a pyside2 GUI that uses QGraphicsView.
I use setDragMode(QGraphicsView.ScrollHandDrag) to make the view dragable, but i override the cursor with viewport().setCursor(Qt.ArrowCursor) on mouseReleaseEvent to avoid constantly having the open-hand in stead of the normal arrow cursor.
This is explained here: Changing the cursor in a QGraphicsView (in c++)
In the GUI there is also a QGraphicsProxyWidget with a QLabel. When the mouse is placed over the ProxyWidget, the viewport().setCursor(Qt.ArrowCursor) does not work (the moseReleaseEvent is called, so i know that setCursor is called), and when the mouse leaves the ProxyWidget, the open-hand cursor shows in stead of the arrow-cursor.
When the mouse is placed all other places in the QGraphicsView everything is working as expected.
Does anyone know why setCursor is behaving differently when the mouse is placed over a proxyWidget?
In QGraphicsView:
def mouseReleaseEvent(self, event):
QGraphicsView.mouseReleaseEvent(self, event)
self.viewport().setCursor(Qt.ArrowCursor)
def infoBoxShow(self, edge, mouse_pos):
if self.info_box is None:
self.info_box = VardeInfoBox_v2.InfoBox()
self.info_box.corresponding_edge = edge
self.info_box.setPos(mouse_pos)
self.info_box.setInfoText(edge)
self.main_scene.addItem(self.info_box)
InfoBox (As you can see i have tried to set some flags without success):
class InfoBox(QGraphicsItem):
Type = QGraphicsItem.UserType + 1
def __init__(self):
QGraphicsItem.__init__(self)
self.setFlag(QGraphicsItem.hover)
self.setZValue(4)
proxy = QGraphicsProxyWidget(self)
widget = QLabel("TEST!")
widget.setAttribute(Qt.WA_TransparentForMouseEvents)
widget.setWindowModality(Qt.NonModal)
proxy.setWidget(widget)
self.corresponding_edge = None

Scrolling QGraphicsView programmatically

I've want to implement a scroll/pan-feature on a QGraphicsView in my (Py)Qt application. It's supposed to work like this: The user presses the middle mouse button, and the view scrolls as the user moves the mouse (this is quite a common feature).
I tried using the scroll() method inherited from QWidget. However, this somehow moves the view instead - scrollbars and all. See picture.
So, given that this is not the way I'm supposed to do this, how should I? Or is it the correct way, but I do something else wrong? The code I use:
def __init__(self):
...
self.ui.imageArea.mousePressEvent=self.evImagePress
self.ui.imageArea.mouseMoveEvent=self.evMouseMove
self.scrollOnMove=False
self.scrollOrigin=[]
...
def evImagePress(self, event):
if event.button() == Qt.LeftButton:
self.evImageLeftClick(event)
if event.button() == Qt.MidButton:
self.scrollOnMove=not self.scrollOnMove
if self.scrollOnMove:
self.scrollOrigin=[event.x(), event.y()]
...
def evMouseMove(self, event):
if self.scrollOnMove:
self.ui.imageArea.scroll(event.x()-self.scrollOrigin[0],
event.y()-self.scrollOrigin[1])
It works as I expect, except for the whole move-the-widget business.
Fails to scroll http://img55.imageshack.us/img55/3222/scrollfail.jpg
My addition to translate() method.
It works great unless you scale the scene. If you do this, you'll notice, that the image is not in sync with your mouse movements. That's when mapToScene() comes to help. You should map your points from mouse events to scene coordinates. Then the mapped difference goes to translate(), voila viola- your scene follows your mouse with a great precision.
For example:
QPointF tmp2 = mapToScene(event->pos());
QPointF tmp = tmp2.mapToScene(previous_point);
translate(tmp.x(),tmp.y());
I haven't done this myself but this is from the QGraphicsView documentation
... When the scene is larger
than the scroll bars' values, you can
choose to use translate() to navigate
the scene instead.
By using scroll you are moving the widget, translate should achieve what you are looking for, moving the contents of the QGraphicsScene underneath the view
Answer given by denis is correct to get translate to work. The comment by PF4Public is also valid: this can screw up scaling. My workaround is different than P4FPublc's -- instead of mapToScene I preserve the anchor and restore it after a translation:
previousAnchor = view.transformationAnchor()
#have to set this for self.translate() to work.
view.setTransformationAnchor(QGraphicsView.NoAnchor)
view.translate(x_diff,y_diff)
#have to reset the anchor or scaling (zoom) stops working:
view.setTransformationAnchor(previousAnchor)
You can set the QGraphicsScene's area that will be displayed by the QGraphicsView with the method QGraphicsView::setSceneRect(). So when you press the button and move the mouse, you can change the center of the displayed part of the scene and achieve your goal.

Categories

Resources