Blinking Widget with PyQt - python

I simply want some elements inside a QDialog to be blinking (altering background color).
Now preferably I'd like to be able to use something that already exists and encapsulates the blinking state, i.e. blinking with css3 or maybe it is possible with QPropertyAnimation?
Since I didn't find any nice info on that option I tried the less optimal solution:
excerpt from the Dialogs __init__:
self.timer = QTimer()
self.timer.timeout.connect(self.update_blinking)
self.timer.start(250)
self.last_blinked = None
and
def update_blinking(self):
self.frame.setStyleSheet(
self.STYLE_BLINK_ON if self.blink else self.STYLE_BLINK_OFF)
self.blink = not self.blink
where STYLE_BLINK_ON and STYLE_BLINK_OFF are some css specifying the background colors.
That works but
I find it super ugly, it feels like code from the 90s
It isn't usable as the frequent style-update interrupts button-clicks.
Explanation for 2.: Assume the widget that should be blinking is a frame.
When a button inside that frame is clicked, the clicked signal isn't emitted if a style-update of the frame occurs before the mouse-button is released.
A completely different solution that encapsulates things and doesn't require me to manually start a timer would of course be preferred.
But I would be grateful if someone at least came up with a solution which solves point 2.

The one way is to use QPropertyAnimation. QPropertyAnimation interpolates over Qt properties - this fact causes difficulties:
1) Change appearance via style sheet -- animation cannot work with strings, because they're not interpolable.
2) Manipulate background directly -- background color is stored deep inside QWidget.palette, it's not a QProperty. The possible solution is to transform background color into a widget's property:
class AnimatedWidget(QtGui.QWidget):
def __init__(self):
QtGui.QWidget.__init__(self)
color1 = QtGui.QColor(255, 0, 0)
color2 = QtGui.QColor(0, 255, 0)
self.color_anim = QtCore.QPropertyAnimation(self, 'backColor')
self.color_anim.setStartValue(color1)
self.color_anim.setKeyValueAt(0.5, color2)
self.color_anim.setEndValue(color1)
self.color_anim.setDuration(1000)
self.color_anim.setLoopCount(-1)
self.color_anim.start()
def getBackColor(self):
return self.palette().color(QtGui.QPalette.Background)
def setBackColor(self, color):
pal = self.palette()
pal.setColor(QtGui.QPalette.Background, color)
self.setPalette(pal)
backColor = QtCore.pyqtProperty(QtGui.QColor, getBackColor, setBackColor)
The other approach is dealing with QStateMachines. They're able to manipulate any properties, not only interpolable ones:
class StateWidget(QtGui.QWidget):
def __init__(self):
QtGui.QWidget.__init__(self)
style1 = "background-color: yellow"
style2 = "background-color: black"
# animation doesn't work for strings but provides an appropriate delay
animation = QtCore.QPropertyAnimation(self, 'styleSheet')
animation.setDuration(150)
state1 = QtCore.QState()
state2 = QtCore.QState()
state1.assignProperty(self, 'styleSheet', style1)
state2.assignProperty(self, 'styleSheet', style2)
# change a state after an animation has played
# v
state1.addTransition(state1.propertiesAssigned, state2)
state2.addTransition(state2.propertiesAssigned, state1)
self.machine = QtCore.QStateMachine()
self.machine.addDefaultAnimation(animation)
self.machine.addState(state1)
self.machine.addState(state2)
self.machine.setInitialState(state1)
self.machine.start()

Related

PyQt6 set position of input field and button

I'm trying to create in PyQt6 an input field (location: bottom right of the window) and next to It on the right an "Enter" button (I'm using pg.GraphicView()). I can't use the PySide library because of some interaction problems with the rest of my code. How can I achieve that?
I'm using the following code for generating a button but I can't figure out how to place It at the bottom right of the current window:
view = pg.GraphicsView()
l = pg.GraphicsLayout()
view.setCentralItem(l)
view.show()
proxy = QGraphicsProxyWidget()
button = QPushButton("ENTER")
proxy.setWidget(button)
view.addItem(proxy)
Regarding the input field I tried to implement different things without using PySide but they didn't worked.
The pg.GraphicsView class is actually a subclass of QGraphicsView, which is a standard QWidget that inherits from QAbstractScrollArea.
This means that we can potentially add any child widget to it without interfering with the contents of the scene and also ignoring possible transformations (scaling, rotation, etc.).
The solution, in fact, is quite simple: set the view as parent of the widget (either by using the view as argument in the constructor, or by calling setParent()). Then, what's left is to ensure that the widget geometry is always consistent with the view, so we need to wait for resize events and set the new geometry based on the new size. To achieve this, the simplest solution is to create a subclass.
For explanation purposes, I've implemented a system that allows to set widgets for any "corner" of the view, and defaults to the bottom right corner.
Note: since this question could also be valid for Qt5, in the following code I'm using the "old" enum style (Qt.Align...), for newer versions of Qt (and mandatory for PyQt6), you need to change to Qt.Alignment.Align....
class CustomGraphicsView(pg.GraphicsView):
toolWidgets = None
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.toolWidgets = {}
def setToolWidget(self, widget, position=Qt.AlignBottom|Qt.AlignRight):
position = Qt.AlignmentFlag(position)
current = self.toolWidgets.get(position)
if current:
current.deleteLater()
self.toolWidgets[position] = widget
widget.resize(widget.sizeHint())
# ensure that the widget is reparented
widget.setParent(self)
# setParent() automatically hides widgets, we need to
# explicitly call show()
widget.show()
self._updateToolWidgets()
def _updateToolWidgets(self):
if not self.toolWidgets:
return
viewGeo = self.rect()
for pos, widget in self.toolWidgets.items():
rect = widget.rect()
if pos & Qt.AlignLeft:
rect.moveLeft(0)
elif pos & Qt.AlignRight:
rect.moveRight(viewGeo.right())
elif pos & Qt.AlignHCenter:
rect.moveLeft(viewGeo.center().x() - rect.width() / 2)
if pos & Qt.AlignTop:
rect.moveTop(0)
elif pos & Qt.AlignBottom:
rect.moveBottom(viewGeo.bottom())
elif pos & Qt.AlignVCenter:
rect.moveTop(viewGeo.center().y() - rect.height() / 2)
widget.setGeometry(rect)
def resizeEvent(self, event):
super().resizeEvent(event)
self._updateToolWidgets()
# ...
view = CustomGraphicsView()
l = pg.GraphicsLayout()
view.setCentralItem(l)
view.show()
container = QFrame(autoFillBackground=True, objectName='container')
container.setStyleSheet('''
QFrame#container {
background: palette(window);
border: 1px outset palette(mid);
border-radius: 2px;
}
''')
lineEdit = QLineEdit()
button = QPushButton("ENTER")
frameLayout = QHBoxLayout(container)
frameLayout.setContentsMargins(0, 0, 0, 0)
frameLayout.addWidget(lineEdit)
frameLayout.addWidget(button)
view.setToolWidget(container)
bottomLeftButton = QPushButton('Something')
view.setToolWidget(bottomLeftButton, Qt.AlignLeft|Qt.AlignBottom)

Blending text into background while using native font rendering

I'm trying to implement a plain text display widget, which fades-out into the background on both its sides.
Unfortunately the only way I've been able to achieve this fade-out effect while using the Windows' font engine is by overlaying a gradient going from a solid background color into transparency. This method works fine for when the background behind the widget is consistent, but this is not always the case (e.g. when placed into a QTabWidget it uses the Button role instead of the Window role, or anything non uniform) and causes the gradient's color to be mismatched
Here's an example of when I'm using the Window color role for the background but the actual background is using the Button color role
I have tried painting both into QImage and then painting it as a whole into the widget, and a QGraphicsOpacityEffect set on the widget, but both of these do not use the native Windows drawing and thus have degraded looks, which is highlighted on these images compared to the current method.
The first image highlights how it should look, with it being rendered using ClearType. On the second image, painting into a QImage is used which loses the subpixel anti-aliasing. The third image is using the QGraphicsOpacityEffect which causes the text to look even more blurry, and darker.
The current overlaying is done by painting simple gradient images over the text like so:
def paint_event(self, paint_event: QtGui.QPaintEvent) -> None:
"""Paint the text at its current scroll position with the fade-out gradients on both sides."""
text_y = (self.height() - self._text_size.height()) // 2
painter = QtGui.QPainter(self)
painter.set_clip_rect(
QtCore.QRect(
QtCore.QPoint(0, text_y),
self._text_size,
)
)
painter.draw_static_text(
QtCore.QPointF(-self._scroll_pos, text_y),
self._static_text,
)
# Show more transparent half of gradient immediately to prevent text from appearing cut-off.
if self._scroll_pos == 0:
fade_in_width = 0
else:
fade_in_width = min(
self._scroll_pos + self.fade_width // 2, self.fade_width
)
painter.draw_image(
-self.fade_width + fade_in_width,
text_y,
self._fade_in_image,
)
fade_out_width = self._text_size.width() - self.width() - self._scroll_pos
if fade_out_width > 0:
fade_out_width = min(self.fade_width, fade_out_width + self.fade_width // 2)
painter.draw_image(
self.width() - fade_out_width,
text_y,
self._fade_out_image,
)
And the whole widget code can be found at https://github.com/Numerlor/Auto_Neutron/blob/3c1bdb8211411e86846710cceec9dc2b23b91cc6/auto_neutron/windows/gui/plain_text_scroller.py#L16
As far as I know, and at least on Linux, I sincerely doubt that that would be possible, as "blending" the background would require knowing the (possibly cumulative) background of the parent(s), and subpixel rendering is not available whenever the background and/or foreground have alpha value below 1.0 (or 255) for raster drawing.
Also, text rendering with subpixel correction requires a surface that is aware of the screen, which makes drawing on image pointless.
If you're fine with the default text antialiasing, though, there's a much simpler approach to achieve the fading, and there's no need to override the painting, as you can achieve this with a basic QLabel and using a QLinearGradient set for the WindowText palette role.
The trick is to use the minimumSizeHint() to get the optimal width for the text and compute the correct stops of the gradient, since those values are always in the range between 0 and 1.
class FaderLabel(QtWidgets.QLabel):
fadeWidth = 20
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
palette = self.palette()
self.baseColor = palette.color(palette.WindowText)
self.fadeColor = QtGui.QColor(self.baseColor)
self.fadeColor.setAlpha(0)
self.grad = QtGui.QLinearGradient(0, 0, 1, 0)
self.grad.setCoordinateMode(self.grad.ObjectBoundingMode)
self.setMinimumWidth(self.fadeWidth * 2)
def updateColor(self):
fadeRatio = self.fadeWidth / self.minimumSizeHint().width()
self.grad.setStops([
(0, self.fadeColor),
(fadeRatio, self.baseColor),
(1 - fadeRatio, self.baseColor),
(1, self.fadeColor)
])
palette = self.palette()
palette.setBrush(palette.WindowText, QtGui.QBrush(self.grad))
self.setPalette(palette)
def setText(self, text):
super().setText(text)
self.updateColor()
def resizeEvent(self, event):
super().resizeEvent(event)
self.updateColor()
app = QtWidgets.QApplication([])
p = app.palette()
p.setColor(p.Window, QtCore.Qt.black)
p.setColor(p.WindowText, QtCore.Qt.white)
app.setPalette(p)
test = FaderLabel('Hello, I am fading label')
test.show()
app.exec()
The subpixel rendering (like ClearType) will be not be available as written above, since using a gradient makes it almost impossible for the engine to properly draw the "mid" pixels.
Another problem with the above code is that it won't work when using stylesheets. In that case, the solution is to create a helper function that will set the existing stylesheet (including the inherited one), get the actual text color, then create a custom stylesheet with the gradient and finally apply that.
class FaderLabel2(QtWidgets.QLabel):
fadeWidth = 20
_styleSheet = ''
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.updateTimer = QtCore.QTimer(
singleShot=True, interval=1, timeout=self.updateColor)
def updateColor(self):
# restore the default stylesheet (if any)
super().setStyleSheet(self._styleSheet)
# ensure that the palette is properly updated
self.ensurePolished()
baseColor = self.palette().color(QtGui.QPalette.WindowText)
fadeColor = QtGui.QColor(baseColor)
fadeColor.setAlpha(0)
fadeRange = self.fadeWidth / self.minimumSizeHint().width()
styleSheet = '''
color: qlineargradient(x1:0, y1:0, x2:1, y2:0,
stop:0 {fade},
stop:{start} {full},
stop:{end} {full},
stop:1 {fade});
'''.format(
fade=fadeColor.name(QtGui.QColor.HexArgb),
full=baseColor.name(QtGui.QColor.HexArgb),
start=fadeRange, end=1-fadeRange)
super().setStyleSheet(styleSheet)
def changeEvent(self, event):
if event.type() == event.StyleChange:
self.updateTimer.start()
def setText(self, text):
super().setText(text)
self.updateColor()
def setStyleSheet(self, styleSheet):
self._styleSheet = styleSheet
self.updateTimer.start()
def resizeEvent(self, event):
super().resizeEvent(event)
self.updateTimer.start()

How to paint QGraphicsItems from scene onto QImage without changing them in the QGraphicsScene?

I'm converting QGraphicsItem's into rasterized masks at their location in a QGraphicsScene. I'm using those masks for further processing of a video (taking the average intensity inside the mask). To achieve this, I'm painting each item in the scene one by one on a QImage, which has a size just big enough to envelop the item. Everything works well enough, but the items in the scene disappear. That is because I'm removing the pen from the item when I paint it on the QImage. I set the original pen back when I'm done, but the items don't reappear on the scene.
How can I "refresh" the scene to make the items reappear, or alternatively, prevent the items form disappearing altogether?
I couldn't really find anything of people running into this problem. So maybe I'm just doing something fundamentally wrong. Any suggestions are welcome.
Here's my code:
class MyThread(QtCore.QThread):
def __init__(self, scene):
super().__init__()
self.scene = scene
def run(self):
for item in self.scene.items():
# Render the ROI item to create a rasterized mask.
qimage = self.qimage_from_shape_item(item)
# do some stuff
#staticmethod
def qimage_from_shape_item(item: QtWidgets.QAbstractGraphicsShapeItem) -> QtGui.QImage:
# Get items pen and brush to set back later.
pen = item.pen()
brush = item.brush()
# Remove pen, set brush to white.
item.setPen(QtGui.QPen(QtCore.Qt.NoPen))
item.setBrush(QtCore.Qt.white)
# Determine the bounding box in pixel coordinates.
top = int(item.scenePos().y() + item.boundingRect().top())
left = int(item.scenePos().x() + item.boundingRect().left())
bottom = int(item.scenePos().y() + item.boundingRect().bottom()) + 1
right = int(item.scenePos().x() + item.boundingRect().right()) + 1
size = QtCore.QSize(right - left, bottom - top)
# Initialize qimage, use 8-bit grayscale.
qimage = QtGui.QImage(size, QtGui.QImage.Format_Grayscale8)
qimage.fill(QtCore.Qt.transparent)
painter = QtGui.QPainter(qimage)
painter.setRenderHint(QtGui.QPainter.Antialiasing)
# Offset the painter to paint item in its correct pixel location.
painter.translate(item.scenePos().x() - left, item.scenePos().y() - top)
# Paint the item.
item.paint(painter, QtWidgets.QStyleOptionGraphicsItem())
# Set the pen and brush back.
item.setPen(pen)
item.setBrush(brush)
# Set the pixel coordinate offset of the item to the QImage.
qimage.setOffset(QtCore.QPoint(left, top))
return qimage
if __name__ == '__main__':
app = QtWidgets.QApplication(sys.argv)
widget = QtWidgets.QWidget()
layout = QtWidgets.QVBoxLayout(widget)
view = QtWidgets.QGraphicsView()
layout.addWidget(view)
scene = QtWidgets.QGraphicsScene()
view.setScene(scene)
thread = MyThread(scene)
view.setFixedSize(400, 300)
scene.setSceneRect(0, 0, 400, 300)
rect_item = QtWidgets.QGraphicsRectItem()
p = QtCore.QPointF(123.4, 56.78)
rect_item.setPos(p)
r = QtCore.QRectF(0., 0., 161.8, 100.)
rect_item.setRect(r)
scene.addItem(rect_item)
button = QtWidgets.QPushButton("Get masks")
layout.addWidget(button)
button.clicked.connect(thread.start)
widget.show()
sys.exit(app.exec_())
The problem is that you "can only create and use GUI widgets on main thread", see more information in this SO answer here.
The way I solved it was to take the GUI interaction part, i.e. qimage_from_shape_item(), out of the thread and deal with it in the main loop. I suppose it's still not great that I'm using the items directly, although there is no visible flicker effect or anything from temporarily setting NoPen.
An alternative might have been to use QGraphicsScene::render; however, I don't know how to render the items one by one without interacting with the other items on the scene.

Widgets not displaying with macOS vibrancy (NSVisualEffectView)

I have currently got a PyQt5 application which is quite simple (just one button). I'd like for it to have vibrancy, so I've ported ObjC vibrancy code to Python. My vibrancy code is as follows:
frame = NSMakeRect(0, 0, * self.get_screen_size()) # get_screen_size returns the resolution of the monitor
view = objc.objc_object(c_void_p=self.winId().__int__()) # returns NSView of current window
visualEffectView = NSVisualEffectView.new()
visualEffectView.setAutoresizingMask_(NSViewWidthSizable|NSViewHeightSizable) # equivalent to: visualEffectView.autoresizingMask = NSViewW...
visualEffectView.setFrame_(frame)
visualEffectView.setState_(NSVisualEffectStateActive)
visualEffectView.setMaterial_(NSVisualEffectMaterialDark)
visualEffectView.setBlendingMode_(NSVisualEffectBlendingModeBehindWindow)
window = view.window()
window.contentView().addSubview_positioned_relativeTo_(visualEffectView, NSWindowBelow, None)
# equal to: [window.contentView addSubview:visualEffectView positioned:NSWindowBelow relativeTo:nul]
window.setTitlebarAppearsTransparent_(True)
window.setStyleMask_(window.styleMask() | NSFullSizeContentViewWindowMask) # so the title bar is also vibrant
self.repaint()
All I'm doing to draw a button is: btn = QPushButton("test", self)
self is a class inherited from QMainWindow and everything else should be fairly unimportant.
Behaviour with the window.contentView().addSubview... line commented out (no vibrancy)
Behaviour without it commented out (with vibrancy)
Thanks!
The UI elements are actually drawn, but the NSVisualEffectView is covering them.
I fixed that issue by adding another view QMacCocoaViewContainer to the window.
You'll also need to set the Window transparent, otherwise the widgets will have a slight background border.
If you use the dark vibrant window also make sure to set appearance correctly, so buttons, labels etc. are rendered correctly.
frame = NSMakeRect(0, 0, self.width(), self.height())
view = objc.objc_object(c_void_p=self.winId().__int__())
visualEffectView = NSVisualEffectView.new()
visualEffectView.setAutoresizingMask_(NSViewWidthSizable|NSViewHeightSizable)
visualEffectView.setWantsLayer_(True)
visualEffectView.setFrame_(frame)
visualEffectView.setState_(NSVisualEffectStateActive)
visualEffectView.setMaterial_(NSVisualEffectMaterialUltraDark)
visualEffectView.setBlendingMode_(NSVisualEffectBlendingModeBehindWindow)
visualEffectView.setWantsLayer_(True)
self.setAttribute(Qt.WA_TranslucentBackground, True)
window = view.window()
content = window.contentView()
container = QMacCocoaViewContainer(0, self)
content.addSubview_positioned_relativeTo_(visualEffectView, NSWindowBelow, container)
window.setTitlebarAppearsTransparent_(True)
window.setStyleMask_(window.styleMask() | NSFullSizeContentViewWindowMask)
appearance = NSAppearance.appearanceNamed_('NSAppearanceNameVibrantDark')
window.setAppearance_(appearance)
Result:

Is it possible to add text on top of a scrollbar?

I would like to add some text to the left end side, the right end side and on the slider as in the figure below
I don't understand how I can add text on top of a widget
here the minimal example of the Qscrollbar (without texts)
from PyQt5.QtGui import *
from PyQt5.QtCore import *
from PyQt5.QtWidgets import *
import sys
class Viewer(QMainWindow):
def __init__(self, parent=None):
super(Viewer, self).__init__()
self.parent = parent
self.centralWidget = QWidget()
self.setCentralWidget(self.centralWidget)
self.mainVBOX_param_scene = QVBoxLayout()
self.paramPlotV = QVBoxLayout()
self.horizontalSliders = QScrollBar(Qt.Horizontal)
self.horizontalSliders.setMinimum(0)
self.horizontalSliders.setMaximum(10)
self.horizontalSliders.setPageStep(1)
self.paramPlotV.addWidget(self.horizontalSliders)
self.centralWidget.setLayout(self.paramPlotV)
def main():
app = QApplication(sys.argv)
app.setStyle('Windows')
ex = Viewer(app)
ex.showMaximized()
sys.exit(app.exec())
if __name__ == '__main__':
main()
There are two possible approaches, and both of them use QStyle to get the geometry of the slider and the subPage/addPage rectangles (the "spaces" outside the slider and within its buttons, if they are visible).
Subclass QScrollBar and override paintEvent()
Here we override the paintEvent() of the scroll bar, call the base class implementation (which paints the scroll bar widget) and draw the text over it.
To get the rectangle where we're going to draw, we create a QStyleOptionSlider, which is a QStyleOption sub class used for any slider based widget (including scroll bars); a QStyleOption contains all the information QStyle needs to draw graphical elements, and its subclasses allow QStyle to find out how to draw complex elements such as scroll bars or control the behavior against any mouse event.
class PaintTextScrollBar(QScrollBar):
preText = 'pre text'
postText = 'post text'
sliderText = 'slider'
def paintEvent(self, event):
# call the base class paintEvent, which will draw the scrollbar
super().paintEvent(event)
# create a suitable styleoption and "init" it to this instance
option = QStyleOptionSlider()
self.initStyleOption(option)
painter = QPainter(self)
# get the slider rectangle
sliderRect = self.style().subControlRect(QStyle.CC_ScrollBar,
option, QStyle.SC_ScrollBarSlider, self)
# if the slider text is wider than the slider width, adjust its size;
# note: it's always better to add some horizontal margin for text
textWidth = self.fontMetrics().width(self.sliderText)
if textWidth > sliderRect.width():
sideWidth = (textWidth - sliderRect.width()) / 2
sliderRect.adjust(-sideWidth, 0, sideWidth, 0)
painter.drawText(sliderRect, Qt.AlignCenter,
self.sliderText)
# get the "subPage" rectangle and draw the text
subPageRect = self.style().subControlRect(QStyle.CC_ScrollBar,
option, QStyle.SC_ScrollBarSubPage, self)
painter.drawText(subPageRect, Qt.AlignLeft|Qt.AlignVCenter, self.preText)
# get the "addPage" rectangle and draw its text
addPageRect = self.style().subControlRect(QStyle.CC_ScrollBar,
option, QStyle.SC_ScrollBarAddPage, self)
painter.drawText(addPageRect, Qt.AlignRight|Qt.AlignVCenter, self.postText)
This approach is very effective and may be fine for most simple cases, but there will be problems whenever the text is wider than the size of the slider handle, since Qt decides the extent of the slider based on its overall size and the range between its minimum and maximum values.
While you can adjust the size of the rectangle you're drawing text (as I've done in the example), it will be far from perfect: whenever the slider text is too wide it might draw over the "pre" and "post" text, and make the whole scrollbar very ugly if the slider is near the edges, since the text might cover the arrow buttons:
Note: the result of a "non adjusted" text rectangle would be the same as the first scroll bar in the image above, with the text "clipped" to the slider geometry.
Use a proxy style
QProxyStyle is a QStyle descendant that makes subclassing easier by providing an easy way to override only methods of an existing style.
The function we're most interested in is drawComplexControl(), which is what Qt uses to draw complex controls like spin boxes and scroll bars. By implementing this function only, the behavior will be exactly the same as the paintEvent() method explained above, as long as you apply the custom style to a standard QScrollBar.
What a (proxy) style could really help with is being able to change the overall appearance and behavior of almost any widget.
To be able to take the most of its features, I've implemented another QScrollBar subclass, allowing much more customization, while overriding other important QProxyStyle functions.
class TextScrollBarStyle(QProxyStyle):
def drawComplexControl(self, control, option, painter, widget):
# call the base implementation which will draw anything Qt will ask
super().drawComplexControl(control, option, painter, widget)
# check if control type and orientation match
if control == QStyle.CC_ScrollBar and option.orientation == Qt.Horizontal:
# the option is already provided by the widget's internal paintEvent;
# from this point on, it's almost the same as explained above, but
# setting the pen might be required for some styles
painter.setPen(widget.palette().color(QPalette.WindowText))
margin = self.frameMargin(widget) + 1
sliderRect = self.subControlRect(control, option,
QStyle.SC_ScrollBarSlider, widget)
painter.drawText(sliderRect, Qt.AlignCenter, widget.sliderText)
subPageRect = self.subControlRect(control, option,
QStyle.SC_ScrollBarSubPage, widget)
subPageRect.setRight(sliderRect.left() - 1)
painter.save()
painter.setClipRect(subPageRect)
painter.drawText(subPageRect.adjusted(margin, 0, 0, 0),
Qt.AlignLeft|Qt.AlignVCenter, widget.preText)
painter.restore()
addPageRect = self.subControlRect(control, option,
QStyle.SC_ScrollBarAddPage, widget)
addPageRect.setLeft(sliderRect.right() + 1)
painter.save()
painter.setClipRect(addPageRect)
painter.drawText(addPageRect.adjusted(0, 0, -margin, 0),
Qt.AlignRight|Qt.AlignVCenter, widget.postText)
painter.restore()
def frameMargin(self, widget):
# a helper function to get the default frame margin which is usually added
# to widgets and sub widgets that might look like a frame, which usually
# includes the slider of a scrollbar
option = QStyleOptionFrame()
option.initFrom(widget)
return self.pixelMetric(QStyle.PM_DefaultFrameWidth, option, widget)
def subControlRect(self, control, option, subControl, widget):
rect = super().subControlRect(control, option, subControl, widget)
if (control == QStyle.CC_ScrollBar
and isinstance(widget, StyledTextScrollBar)
and option.orientation == Qt.Horizontal):
if subControl == QStyle.SC_ScrollBarSlider:
# get the *default* groove rectangle (the space in which the
# slider can move)
grooveRect = super().subControlRect(control, option,
QStyle.SC_ScrollBarGroove, widget)
# ensure that the slider is wide enough for its text
width = max(rect.width(),
widget.sliderWidth + self.frameMargin(widget))
# compute the position of the slider according to the
# scrollbar value and available space (the "groove")
pos = self.sliderPositionFromValue(widget.minimum(),
widget.maximum(), widget.sliderPosition(),
grooveRect.width() - width)
# return the new rectangle
return QRect(grooveRect.x() + pos,
(grooveRect.height() - rect.height()) / 2,
width, rect.height())
elif subControl == QStyle.SC_ScrollBarSubPage:
# adjust the rectangle based on the slider
sliderRect = self.subControlRect(
control, option, QStyle.SC_ScrollBarSlider, widget)
rect.setRight(sliderRect.left())
elif subControl == QStyle.SC_ScrollBarAddPage:
# same as above
sliderRect = self.subControlRect(
control, option, QStyle.SC_ScrollBarSlider, widget)
rect.setLeft(sliderRect.right())
return rect
def hitTestComplexControl(self, control, option, pos, widget):
if control == QStyle.CC_ScrollBar:
# check click events against the resized slider
sliderRect = self.subControlRect(control, option,
QStyle.SC_ScrollBarSlider, widget)
if pos in sliderRect:
return QStyle.SC_ScrollBarSlider
return super().hitTestComplexControl(control, option, pos, widget)
class StyledTextScrollBar(QScrollBar):
def __init__(self, sliderText='', preText='', postText=''):
super().__init__(Qt.Horizontal)
self.setStyle(TextScrollBarStyle())
self.preText = preText
self.postText = postText
self.sliderText = sliderText
self.sliderTextMargin = 2
self.sliderWidth = self.fontMetrics().width(sliderText) + self.sliderTextMargin + 2
def setPreText(self, text):
self.preText = text
self.update()
def setPostText(self, text):
self.postText = text
self.update()
def setSliderText(self, text):
self.sliderText = text
self.sliderWidth = self.fontMetrics().width(text) + self.sliderTextMargin + 2
def setSliderTextMargin(self, margin):
self.sliderTextMargin = margin
self.sliderWidth = self.fontMetrics().width(self.sliderText) + margin + 2
def sizeHint(self):
# give the scrollbar enough height for the font
hint = super().sizeHint()
if hint.height() < self.fontMetrics().height() + 4:
hint.setHeight(self.fontMetrics().height() + 4)
return hint
There's a lot of difference between using the basic paintEvent override, applying the style to a standard QScrollBar and using a full "style-enabled" scroll bar with a fully implemented subclass; as you can see it's always possible that the current style (or the baseStyle chosen for the custom proxy style) might not be very friendly in its appearance:
What changes between the two (three) approaches and what you will finally decide to use depends on your needs; if you need to add other features to the scroll bar (or add more control to text contents or their apparance) and the text is not very wide, you might want to go with subclassing; on the other hand, the QProxyStyle approach might be useful to control other aspects or elements too.
Remember that if the QStyle is not set before the QApplication constructor, it's possible that the applied style won't be perfect to work with: as opposed with QFont and QPalette, QStyle is not propagated to the children of the QWidget it's applied to (meaning that the new proxy style has to be notified about the parent style change and behave accordingly).
class HLine(QFrame):
def __init__(self):
super().__init__()
self.setFrameShape(self.HLine|self.Sunken)
class Example(QWidget):
def __init__(self):
QWidget.__init__(self)
layout = QVBoxLayout(self)
layout.addWidget(QLabel('Base subclass with paintEvent override, small text:'))
shortPaintTextScrollBar = PaintTextScrollBar(Qt.Horizontal)
layout.addWidget(shortPaintTextScrollBar)
layout.addWidget(QLabel('Same as above, long text (text rect adjusted to text width):'))
longPaintTextScrollBar = PaintTextScrollBar(Qt.Horizontal)
longPaintTextScrollBar.sliderText = 'I am a very long slider'
layout.addWidget(longPaintTextScrollBar)
layout.addWidget(HLine())
layout.addWidget(QLabel('Base QScrollBar with drawComplexControl override of proxystyle:'))
shortBasicScrollBar = QScrollBar(Qt.Horizontal)
layout.addWidget(shortBasicScrollBar)
shortBasicScrollBar.sliderText = 'slider'
shortBasicScrollBar.preText = 'pre text'
shortBasicScrollBar.postText = 'post text'
shortBasicScrollBar.setStyle(TextScrollBarStyle())
layout.addWidget(QLabel('Same as above, long text (text rectangle based on slider geometry):'))
longBasicScrollBar = QScrollBar(Qt.Horizontal)
layout.addWidget(longBasicScrollBar)
longBasicScrollBar.sliderText = 'I am a very long slider'
longBasicScrollBar.preText = 'pre text'
longBasicScrollBar.postText = 'post text'
longBasicScrollBar.setStyle(TextScrollBarStyle())
layout.addWidget(HLine())
layout.addWidget(QLabel('Subclasses with full proxystyle implementation, all available styles:'))
for styleName in QStyleFactory.keys():
scrollBar = StyledTextScrollBar()
layout.addWidget(scrollBar)
scrollBar.setSliderText('Long slider with {} style'.format(styleName))
scrollBar.setStyle(TextScrollBarStyle(QStyleFactory.create(styleName)))
scrollBar.valueChanged.connect(self.setScrollBarPreText)
scrollBar.setPostText('Post text')
for scrollBar in self.findChildren(QScrollBar):
scrollBar.setValue(7)
def setScrollBarPreText(self, value):
self.sender().setPreText(str(value))
if __name__ == '__main__':
app = QApplication(sys.argv)
example = Example()
example.show()
sys.exit(app.exec_())

Categories