diff --git a/docs/guide/styling.rst b/docs/guide/styling.rst index 344cb25d0..351c2266a 100644 --- a/docs/guide/styling.rst +++ b/docs/guide/styling.rst @@ -119,6 +119,8 @@ Controls node appearance: - Background gradient stops * - ``ShadowEnabled`` - Drop shadow on/off + * - ``FastShadowEnabled`` + - Approximate shadow drawn by the default painter; defaults to false * - ``ConnectionPointColor`` - Empty port circles * - ``FilledConnectionPointColor`` @@ -128,6 +130,37 @@ Controls node appearance: * - ``WarningColor`` - Validation warning indicator +Lightweight Node Shadows +~~~~~~~~~~~~~~~~~~~~~~~~ + +Set both ``ShadowEnabled`` and ``FastShadowEnabled`` to true to replace +``QGraphicsDropShadowEffect`` with translucent rounded rectangles drawn by +``DefaultNodePainter``. Configure the style before creating nodes: + +.. code-block:: cpp + + NodeStyle style = StyleCollection::nodeStyle(); + style.ShadowEnabled = true; + style.FastShadowEnabled = true; + StyleCollection::setNodeStyle(style); + +For a custom graph model, return ``style.toJson().toVariantMap()`` for +``NodeRole::Style``. The same setting is available as ``FastShadowEnabled`` +inside the ``NodeStyle`` JSON object. Older JSON without this key keeps the +original graphics effect. ``ShadowEnabled = false`` disables both modes. + +The approximation is not pixel-identical to Qt's blur and does not apply a +graphics effect to embedded widgets. Custom painters must draw their own +shadow when this option is enabled. Custom node geometries must include the +shadow in their ``boundingRect()``: it extends 6 logical pixels above and to +the left of the node body, and 14 below and to the right. Both default +geometries provide sufficient margins. + +This option avoids the separate graphics effect, but a performance gain is +not guaranteed. Measure your own graphs, zoom levels and display scaling +before choosing a mode. It does not change global pixmap caching or resolve +the underlying Qt graphics-effect caching issue. + ConnectionStyle --------------- diff --git a/include/QtNodes/internal/NodeStyle.hpp b/include/QtNodes/internal/NodeStyle.hpp index 01ecd2ea1..865b3ef75 100644 --- a/include/QtNodes/internal/NodeStyle.hpp +++ b/include/QtNodes/internal/NodeStyle.hpp @@ -61,6 +61,9 @@ class NODE_EDITOR_PUBLIC NodeStyle : public Style QColor GradientColor3; QColor ShadowColor; bool ShadowEnabled; + /// Draw an approximate shadow with DefaultNodePainter instead of a graphics effect. + /// Requires ShadowEnabled; custom painters are responsible for their own shadow. + bool FastShadowEnabled{false}; QColor FontColor; QColor FontColorFaded; diff --git a/src/DefaultNodePainter.cpp b/src/DefaultNodePainter.cpp index 2d3dae9ba..439dc1820 100644 --- a/src/DefaultNodePainter.cpp +++ b/src/DefaultNodePainter.cpp @@ -104,6 +104,24 @@ void DefaultNodePainter::drawNodeRect(QPainter *painter, NodeGraphicsObject &ngo double const radius = 3.0; + if (nodeStyle.ShadowEnabled && nodeStyle.FastShadowEnabled) { + // Keep the approximation within the margins reserved by default geometries. + // Unlike a graphics effect, this does not blur embedded widgets. + painter->save(); + painter->setRenderHint(QPainter::Antialiasing, true); + painter->setPen(Qt::NoPen); + + QRectF const shadowBody = boundary.translated(4.0, 4.0); + for (int spread = 10; spread >= 1; --spread) { + QColor shade = nodeStyle.ShadowColor; + shade.setAlphaF(shade.alphaF() * 0.01 * (11 - spread)); + painter->setBrush(shade); + painter->drawRoundedRect(shadowBody.adjusted(-spread, -spread, spread, spread), + radius + spread, radius + spread); + } + painter->restore(); + } + painter->drawRoundedRect(boundary, radius, radius); } diff --git a/src/NodeGraphicsObject.cpp b/src/NodeGraphicsObject.cpp index c13b92086..c80502205 100644 --- a/src/NodeGraphicsObject.cpp +++ b/src/NodeGraphicsObject.cpp @@ -42,7 +42,8 @@ NodeGraphicsObject::NodeGraphicsObject(BasicGraphicsScene &scene, NodeId nodeId) NodeStyle nodeStyle(nodeStyleJson); - if (nodeStyle.ShadowEnabled) { + // The lightweight shadow is drawn by DefaultNodePainter. + if (nodeStyle.ShadowEnabled && !nodeStyle.FastShadowEnabled) { auto effect = new QGraphicsDropShadowEffect; effect->setOffset(4, 4); effect->setBlurRadius(20); diff --git a/src/NodeStyle.cpp b/src/NodeStyle.cpp index 0cfacf51b..26c112632 100644 --- a/src/NodeStyle.cpp +++ b/src/NodeStyle.cpp @@ -120,6 +120,7 @@ void NodeStyle::loadJson(QJsonObject const &json) NODE_STYLE_READ_COLOR(obj, GradientColor3); NODE_STYLE_READ_COLOR(obj, ShadowColor); NODE_STYLE_READ_BOOL(obj, ShadowEnabled); + NODE_STYLE_READ_BOOL(obj, FastShadowEnabled); NODE_STYLE_READ_COLOR(obj, FontColor); NODE_STYLE_READ_COLOR(obj, FontColorFaded); NODE_STYLE_READ_COLOR(obj, ConnectionPointColor); @@ -146,6 +147,7 @@ QJsonObject NodeStyle::toJson() const NODE_STYLE_WRITE_COLOR(obj, GradientColor3); NODE_STYLE_WRITE_COLOR(obj, ShadowColor); NODE_STYLE_WRITE_BOOL(obj, ShadowEnabled); + NODE_STYLE_WRITE_BOOL(obj, FastShadowEnabled); NODE_STYLE_WRITE_COLOR(obj, FontColor); NODE_STYLE_WRITE_COLOR(obj, FontColorFaded); NODE_STYLE_WRITE_COLOR(obj, ConnectionPointColor); diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index e8f5bdc1f..5e63771e2 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -20,6 +20,7 @@ add_executable(test_nodes src/TestDataFlow.cpp src/TestNodeValidation.cpp src/TestCustomPainters.cpp + src/TestFastNodeShadows.cpp src/TestCopyPaste.cpp src/TestZoomFeatures.cpp src/TestLoopDetection.cpp diff --git a/test/src/TestFastNodeShadows.cpp b/test/src/TestFastNodeShadows.cpp new file mode 100644 index 000000000..d3c3fd89c --- /dev/null +++ b/test/src/TestFastNodeShadows.cpp @@ -0,0 +1,118 @@ +#include "ApplicationSetup.hpp" +#include "TestGraphModel.hpp" + +#include + +#include +#include +#include +#include +#include + +#include +#include +#include + +TEST_CASE("Lightweight shadows are opt-in and survive style serialization", "[graphics][shadows]") +{ + auto app = applicationSetup(); + + QtNodes::NodeStyle legacyStyle; + legacyStyle.ShadowEnabled = true; + CHECK_FALSE(legacyStyle.FastShadowEnabled); + + auto legacyJson = legacyStyle.toJson(); + QJsonObject legacyNodeJson = legacyJson.value("NodeStyle").toObject(); + legacyNodeJson.remove("FastShadowEnabled"); + legacyJson["NodeStyle"] = legacyNodeJson; + CHECK_FALSE(QtNodes::NodeStyle(legacyJson).FastShadowEnabled); + + QtNodes::NodeStyle fastStyle; + fastStyle.ShadowEnabled = true; + fastStyle.FastShadowEnabled = true; + CHECK(QtNodes::NodeStyle(fastStyle.toJson()).FastShadowEnabled); + + fastStyle.loadJson(legacyJson); + CHECK_FALSE(fastStyle.FastShadowEnabled); +} + +TEST_CASE("Shadow flags select the graphics effect", "[graphics][shadows]") +{ + bool const shadowEnabled = GENERATE(false, true); + bool const fastShadowEnabled = GENERATE(false, true); + CAPTURE(shadowEnabled, fastShadowEnabled); + auto app = applicationSetup(); + + QtNodes::NodeStyle style; + style.ShadowEnabled = shadowEnabled; + style.FastShadowEnabled = fastShadowEnabled; + + TestGraphModel model; + auto const id = model.addNode("shadow"); + // Match DataFlowGraphModel and the documented NodeRole::Style representation. + REQUIRE(model.setNodeData(id, QtNodes::NodeRole::Style, style.toJson().toVariantMap())); + + QtNodes::BasicGraphicsScene scene(model); + auto *node = scene.nodeGraphicsObject(id); + REQUIRE(node != nullptr); + CHECK((node->graphicsEffect() != nullptr) == (shadowEnabled && !fastShadowEnabled)); +} + +TEST_CASE("Lightweight shadow fits within default node margins", "[graphics][shadows]") +{ + bool const vertical = GENERATE(false, true); + bool const shadowEnabled = GENERATE(false, true); + CAPTURE(vertical, shadowEnabled); + auto app = applicationSetup(); + + TestGraphModel model; + auto const id = model.addNode("fast"); + + QtNodes::NodeStyle style; + style.ShadowEnabled = shadowEnabled; + style.FastShadowEnabled = true; + style.ShadowColor = QColor(0, 0, 0); + REQUIRE(model.setNodeData(id, QtNodes::NodeRole::Style, style.toJson().toVariantMap())); + + QtNodes::BasicGraphicsScene scene(model); + scene.setOrientation(vertical ? Qt::Vertical : Qt::Horizontal); + auto *node = scene.nodeGraphicsObject(id); + REQUIRE(node != nullptr); + + QSize const size = scene.nodeGeometry().size(id); + REQUIRE(size.width() > 0); + REQUIRE(size.height() > 0); + + // The default horizontal and vertical geometries reserve 20 px per side. + QRectF const shadowFootprint(-6, -6, size.width() + 20, size.height() + 20); + CHECK(scene.nodeGeometry().boundingRect(id).contains(shadowFootprint)); + + QImage image(size.width() + 60, size.height() + 60, QImage::Format_ARGB32_Premultiplied); + image.fill(Qt::transparent); + QPainter painter(&image); + painter.translate(25, 25); + QTransform const transform = painter.transform(); + auto const renderHints = painter.renderHints(); + QtNodes::DefaultNodePainter nodePainter; + nodePainter.drawNodeRect(&painter, *node); + CHECK(painter.transform() == transform); + CHECK(painter.renderHints() == renderHints); + CHECK(painter.pen().style() != Qt::NoPen); + CHECK(painter.brush().style() == Qt::LinearGradientPattern); + painter.end(); + + int const midX = 25 + size.width() / 2; + CHECK((qAlpha(image.pixel(midX, 25 + size.height() + 6)) > 0) == shadowEnabled); + CHECK(qAlpha(image.pixel(midX, 25 + size.height() + 19)) == 0); + + QRectF const bounds = scene.nodeGeometry().boundingRect(id).translated(25, 25); + unsigned int pixelsOutsideBounds = 0; + for (int y = 0; y < image.height(); ++y) { + for (int x = 0; x < image.width(); ++x) { + if (qAlpha(image.pixel(x, y)) != 0 && !bounds.contains(QPointF(x, y))) { + ++pixelsOutsideBounds; + } + } + } + CHECK(pixelsOutsideBounds == 0); +}