Blur Effect Over a Qwidget in Qt

Blur effect over a QWidget in Qt

This answer is in a series of my overlay-related answers: first, second, third.

It requires some care if you wish for it to work on all platforms. You can't apply effects directly to top-level windows. The hierarchy needs to look as follows:

ContainerWidget
|
+----------+
| |
**Target** Overlay

You apply the effect to the Target widget (say, a QMainWindow). The ContainerWidget is a helper class that keeps the children occupying the full size of the widget. This obviates the need for an explicit zero-margin layout.

The below works, even on a Mac. It wouldn't, had you foregone the ContainerWidget. This works portably on Qt 5 only, unfortunately. On Qt 4, your "cross platform" support excludes Mac :( It works OK on Windows using either Qt 4 (4.8.5) or Qt 5.

screenshot

// https://github.com/KubaO/stackoverflown/tree/master/questions/overlay-blur-19383427
#include <QtGui>
#if QT_VERSION >= QT_VERSION_CHECK(5,0,0)
#include <QtWidgets>
#endif

class OverlayWidget : public QWidget {
void newParent() {
if (!parent()) return;
parent()->installEventFilter(this);
raise();
}
public:
explicit OverlayWidget(QWidget *parent = {}) : QWidget(parent) {
setAttribute(Qt::WA_NoSystemBackground);
setAttribute(Qt::WA_TransparentForMouseEvents);
newParent();
}
protected:
//! Catches resize and child events from the parent widget
bool eventFilter(QObject *obj, QEvent *ev) override {
if (obj == parent()) {
if (ev->type() == QEvent::Resize)
resize(static_cast<QResizeEvent*>(ev)->size());
else if (ev->type() == QEvent::ChildAdded)
raise();
}
return QWidget::eventFilter(obj, ev);
}
//! Tracks parent widget changes
bool event(QEvent *ev) override {
if (ev->type() == QEvent::ParentAboutToChange) {
if (parent()) parent()->removeEventFilter(this);
}
else if (ev->type() == QEvent::ParentChange)
newParent();
return QWidget::event(ev);
}
};

class ContainerWidget : public QWidget
{
public:
explicit ContainerWidget(QWidget *parent = {}) : QWidget(parent) {}
void setSize(QObject *obj) {
if (obj->isWidgetType()) static_cast<QWidget*>(obj)->setGeometry(rect());
}
protected:
//! Resizes children to fill the extent of this widget
bool event(QEvent *ev) override {
if (ev->type() == QEvent::ChildAdded) {
setSize(static_cast<QChildEvent*>(ev)->child());
}
return QWidget::event(ev);
}
//! Keeps the children appropriately sized
void resizeEvent(QResizeEvent *) override {
for(auto obj : children()) setSize(obj);
}
};

class LoadingOverlay : public OverlayWidget
{
public:
LoadingOverlay(QWidget *parent = {}) : OverlayWidget{parent} {
setAttribute(Qt::WA_TranslucentBackground);
}
protected:
void paintEvent(QPaintEvent *) override {
QPainter p{this};
p.fillRect(rect(), {100, 100, 100, 128});
p.setPen({200, 200, 255});
p.setFont({"arial,helvetica", 48});
p.drawText(rect(), "Loading...", Qt::AlignHCenter | Qt::AlignTop);
}
};

namespace compat {
#if QT_VERSION >= QT_VERSION_CHECK(5,4,0)
using QT_PREPEND_NAMESPACE(QTimer);
#else
using Q_QTimer = QT_PREPEND_NAMESPACE(QTimer);
class QTimer : public Q_QTimer {
public:
QTimer(QTimer *parent = nullptr) : Q_QTimer(parent) {}
template <typename F> static void singleShot(int period, F &&fun) {
struct Helper : public QObject {
F fun;
QBasicTimer timer;
void timerEvent(QTimerEvent *event) override {
if (event->timerId() != timer.timerId()) return;
fun();
deleteLater();
}
Helper(int period, F &&fun) : fun(std::forward<F>(fun)) {
timer.start(period, this);
}
};
new Helper(period, std::forward<F>(fun));
}
};
#endif
}

int main(int argc, char *argv[])
{
QApplication a{argc, argv};
ContainerWidget base;
QLabel label("Dewey, Cheatem and Howe, LLC.", &base);
label.setFont({"times,times new roman", 32});
label.setAlignment(Qt::AlignCenter);
label.setGraphicsEffect(new QGraphicsBlurEffect);
LoadingOverlay overlay(&base);
base.show();
compat::QTimer::singleShot(2000, [&]{
overlay.hide();
label.setGraphicsEffect({});
});
return a.exec();
}

Can FastBlur blur everything behind it?

I'm updating this answer as the question has been partially cleared in the comments, but I will leave the original answer at the end, as it might still be useful.

Besides that, the concept at the base remains the same: a graphics effect is applied to an object, and it modifies that object look, not how underlying objects appear. If you want to apply that effect to multiple objects, they have to be children of a common parent, and the effect has to be set for that parent, but everything that is below that parent (and it's not its child) will only be partially affected by the result of the effect.

Imagine a blur effect as a filter applied to a real life photograph that is partially transparent: while the image in the photograph is blurred, what you can see behind it will not be blurred.

Subclass the graphics effect

QGraphicsEffects don't provide the ability to limit the extent of their processing, as they usually modify the whole "bounding rect" of the object they are set for.

In order to achieve that, subclassing is necessary and the draw() method has to be overridden, as it is the one responsible for the actual drawing.

I'm going to presume that the whole interface is going to be affected by the effect in some way: even if some objects are "outside" the rectangle of the effect, they are still part of the same parent, so this is what we're going to do:

  • create a main widget that acts as container for the full interface
  • add a main layout for the main interface (the one normally shown)
  • create a sub widget that contains the main interface, set a layout for it and add anything you need to that layout
  • set the subclassed graphics effect to the sub widget
  • create a widget for the menu, that has the main widget as parent, so it will not be part of the main layout; it will have its own layout with its buttons, labels, etc.
  • add a system that changes the graphics effect according to the geometry of the menu, and whenever that changes, the effect will be applied to that geometry only

a very very cool menu!

class BlurEffect(QtWidgets.QGraphicsBlurEffect):
effectRect = None

def setEffectRect(self, rect):
self.effectRect = rect
self.update()

def draw(self, qp):
if self.effectRect is None or self.effectRect.isNull():
# no valid effect rect to be used, use the default implementation
super().draw(qp)
else:
qp.save()
# clip the drawing so that it's restricted to the effectRect
qp.setClipRect(self.effectRect)
# call the default implementation, which will draw the effect
super().draw(qp)
# get the full region that should be painted
fullRegion = QtGui.QRegion(qp.viewport())
# and subtract the effect rectangle
fullRegion -= QtGui.QRegion(self.effectRect)
qp.setClipRegion(fullRegion)
# draw the *source*, which has no effect applied
self.drawSource(qp)
qp.restore()

class Window(QtWidgets.QWidget):
def __init__(self):
super().__init__()

background = QtGui.QPixmap('background.png')

# apply a background to this widget, note that this only serves for the
# graphics effect to know what's outside the boundaries
p = self.palette()
p.setBrush(p.Window, QtGui.QBrush(background))
self.setPalette(p)

self.resize(background.size())

# this layout is only for the child "sub" widget
mainLayout = QtWidgets.QVBoxLayout(self)
mainLayout.setContentsMargins(0, 0, 0, 0)

# the "sub" widget, that contains the main interface
self.subWidget = QtWidgets.QWidget()
mainLayout.addWidget(self.subWidget)
# set the background for the subwidget; note that we can't use setPalette()
# because palette and fonts are inherited by children; using ".QWidget"
# we ensure that the background is only applied to the subwidget
self.subWidget.setStyleSheet('''
.QWidget {
background-image: url(background.png);
}
''')

# some random widgets
subLayout = QtWidgets.QGridLayout(self.subWidget)
for row in range(3):
for col in range(3):
btn = QtWidgets.QPushButton()
subLayout.addWidget(btn, row, col)

btn.setText('Open menu')
btn.setFocus()
btn.clicked.connect(self.openMenu)

# create an instance of our effect subclass, and apply it to the subwidget
self.effect = BlurEffect()
self.subWidget.setGraphicsEffect(self.effect)
self.effect.setEnabled(False)
self.effect.setBlurRadius(10)

# create the menu container, that *HAS* to have this main widget as parent
self.topMenu = QtWidgets.QWidget(self)
self.topMenu.setVisible(False)
self.topMenu.setFixedWidth(200)
# move the menu outside the window left margin
self.topMenu.move(-self.topMenu.width(), 0)

menuLayout = QtWidgets.QVBoxLayout(self.topMenu)
menuLayout.addSpacing(20)
for b in range(4):
btn = QtWidgets.QPushButton('Button {}'.format(b + 1))
menuLayout.addWidget(btn)

menuLayout.addSpacing(10)

closeButton = QtWidgets.QPushButton('Close menu')
menuLayout.addWidget(closeButton)
closeButton.clicked.connect(self.closeMenu)
# a stretch to ensure that the items are always aligned on top
menuLayout.addStretch(1)

# an animation that will move the menu laterally
self.menuAnimation = QtCore.QVariantAnimation()
self.menuAnimation.setDuration(500)
self.menuAnimation.setEasingCurve(QtCore.QEasingCurve.OutQuart)
self.menuAnimation.setStartValue(-self.topMenu.width())
self.menuAnimation.setEndValue(0)
self.menuAnimation.valueChanged.connect(self.resizeMenu)
self.menuAnimation.finished.connect(self.animationFinished)

# a simple transparent widget that is used to hide the menu when
# clicking outside it; the event filter is to capture click events
# it may receive
self.clickGrabber = QtWidgets.QWidget(self)
self.clickGrabber.installEventFilter(self)
self.clickGrabber.setVisible(False)

def resizeMenu(self, value):
# move the menu and set its geometry to the effect
self.topMenu.move(value, 0)
self.effect.setEffectRect(self.topMenu.geometry())

def openMenu(self):
if self.topMenu.x() >= 0:
# the menu is already visible
return
# ensure that the menu starts hidden (that is, with its right border
# aligned to the left of the main widget)
self.topMenu.move(-self.topMenu.width(), 0)
self.topMenu.setVisible(True)
self.topMenu.setFocus()

# enable the effect, set the forward direction for the animation, and
# start it; it's important to set the effect rectangle here too, otherwise
# some flickering might show at the beginning
self.effect.setEffectRect(self.topMenu.geometry())
self.effect.setEnabled(True)
self.menuAnimation.setDirection(QtCore.QVariantAnimation.Forward)
self.menuAnimation.start()

# "show" the grabber (it's invisible, but it's there) and resize it
# to cover the whole window area
self.clickGrabber.setGeometry(self.rect())
self.clickGrabber.setVisible(True)
# ensure that it is stacked under the menu and above everything else
self.clickGrabber.stackUnder(self.topMenu)

def closeMenu(self):
# in case that the menu has changed its size, set again the "start" value
# to its negative width, then set the animation direction to backwards
# and start it
self.menuAnimation.setStartValue(-self.topMenu.width())
self.menuAnimation.setDirection(QtCore.QVariantAnimation.Backward)
self.menuAnimation.start()
# hide the click grabber
self.clickGrabber.setVisible(False)

def animationFinished(self):
# if the animation has ended and the direction was backwards it means that
# the menu has been closed, hide it and disable the effect
if self.menuAnimation.direction() == QtCore.QVariantAnimation.Backward:
self.topMenu.hide()
self.effect.setEnabled(False)

def focusNextPrevChild(self, next):
if self.topMenu.isVisible():
# a small hack to prevent tab giving focus to widgets when the
# menu is visible
return False
return super().focusNextPrevChild(next)

def eventFilter(self, source, event):
if source == self.clickGrabber and event.type() == QtCore.QEvent.MouseButtonPress:
# the grabber has been clicked, close the menu
self.closeMenu()
return super().eventFilter(source, event)

def resizeEvent(self, event):
super().resizeEvent(event)
# always set the menu height to that of the window
self.topMenu.setFixedHeight(self.height())
# resize the grabber to the window rectangle, even if it's invisible
self.clickGrabber.setGeometry(self.rect())
if self.topMenu.isVisible():
# resize the effect rectangle
self.effect.setEffectRect(self.topMenu.geometry())

Previous answer

Since you want to apply the effect to the underlying objects, I believe that the solution is to use a "container" to embed them, and then apply the blur effect to that. The same concept would be applied with QGraphicsBlurWidget too.

ApplicationWindow {
property var theme: String("#ffffff")
property var focusColor: String('transparent')
id: applicationWindow
visible: false
width: 600
height:600

Rectangle {
id: container
anchors.fill: parent

Image {
id: image_bug
anchors.fill: parent
source: "im.png"
}

Rectangle {
width: 100; height: 600
color: "green"
Text {
id: helloText
text: "Hello world!"
anchors.verticalCenter: parent.verticalCenter
anchors.horizontalCenter: parent.horizontalCenter
font.pointSize: 10; font.bold: true
}
MouseArea {
anchors.fill: parent
onClicked: { effectSource.width = 1200; effectSource.height = 1200;}
}
}
}
ShaderEffectSource {
id: effectSource
sourceItem: container
anchors.centerIn: image_bug
width: 300
height: 300
sourceRect: Qt.rect(x,y, width, height)
}

FastBlur{
id: blur
anchors.fill: effectSource
source: effectSource
radius: 100
}
}

resulting full blur effect

How do I apply BlurEffect(QtWidgets.QGraphicsBlurEffect) twice?

You can't. Only one effect can be applied at once on a widget (and after that, no effect can be applied on any of its children or parents), at least for QWidgets.

From QWidget.setGraphicsEffect():

If there already is an effect installed on this widget, QWidget will delete the existing effect before installing the new effect.

What happens is that as soon as you apply self.effect2 on subWidget, self.effect is removed from it and actually deleted. In PyQt terms, it means that the python object still exists, but not its C++ counterpart.

UPDATE

It seems that you still don't understand how a QGraphicsEffect works.
The effect is NOT applied on the widgets you see with the blurred background. It is applied on the underlying widget (subWidget, in this case), and only on the rectangle(s) specified using the geometries of the widgets. You could even set the effectRect to any rect you want, even without any other widgets other than subWidget.

If you need to apply the effect to more than one rectangle, then you should use setClipRegion and use a composite QRegion with it.

Assuming you will always use QWidgets as a reference for the effect, and that the effect will always be applied to a widget that occupies the whole area of the window, you can use a "watch list" of widgets that need to be tracked, and update the effect whenever their geometry change.

class BlurEffect(QtWidgets.QGraphicsBlurEffect):
shouldEnable = True
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.watched = []

def watchWidget(self, widget):
widget.installEventFilter(self)
self.watched.append(widget)

def unwatchWidget(self, widget):
if widget in self.watched:
self.watched.remove(widget)
self.update()

def setEnabled(self, enabled):
# in case you want to manually disable the effect, keep track of
# the selected behavior
self.shouldEnable = enabled
super().setEnabled(enabled)

def draw(self, qp):
rects = []
for widget in self.watched:
if widget.isVisible():
rect = widget.rect()
if rect.isNull():
continue
# map the widget geometry to the window
rect.translate(
widget.mapTo(widget.window(), QtCore.QPoint()))
rects.append(rect)
if not self.isEnabled() and self.shouldEnable:
super().setEnabled(True)
if not rects:
# no valid rect to be used, disable the effect if we should
if not self.shouldEnable:
super().setEnabled(False)
# otherwise, keep drawing the source with the effect applied
# to the whole area of the widget
else:
self.drawSource(qp)
else:
qp.save()
# create a region that includes all rects
rectRegion = QtGui.QRegion()
for rect in rects:
rectRegion |= QtGui.QRegion(rect)
# clip the effect painting to the region
qp.setClipRegion(rectRegion)
# call the default implementation, which will draw the effect
super().draw(qp)
# get the full region that should be painted
fullRegion = QtGui.QRegion(qp.viewport())
# and subtract the effect rectangle used before
fullRegion -= rectRegion
qp.setClipRegion(fullRegion)
# draw the *source*, which has no effect applied
self.drawSource(qp)
qp.restore()

def eventFilter(self, source, event):
# update the effect whenever a widget changes its geometry or
# becomes visible
if event.type() in (QtCore.QEvent.Resize, QtCore.QEvent.Move,
QtCore.QEvent.Show) and source.isVisible():
super().setEnabled(True)
self.update()
# if a widget is going to be deleted, remove it from the list
# of watched list; this is **VERY** important
elif event.type() == QtCore.QEvent.DeferredDelete:
self.unwatchWidget(source)
return super().eventFilter(source, event)

Important notes:

  • you have to use watchWidget for any widget for which you want to see the effect, including the topMenu; again, this doesn't mean that the effect is applied to those widget, but that their geometry is used for that;
  • obviously, there's no setEffectRect anymore;
  • with this implementation the effect disables itself automatically if all the watched widgets are hidden or their geometry is null, which means that you don't need to call self.effect.setEnabled() anymore;
  • even in this case (no watched widgets visible), you can still enable the effect to the whole area by explicitly calling setEnabled(True);

Finally, I strongly suggest you to carefully study this code (and the previous) and the documentation about both QGraphicsEffect and QPainter (including the clipping section and all the related pages), and create some simple tests and examples by yourself to better understand how they work, before attempting to do what you're trying to achieve.



Related Topics



Leave a reply



Submit