Back

Pikasso, a simple drawing application in QtQuick with Rust

Following my last blog post about using Rust and Lyon to create custom shapes. I’m happy to announce the creation of Pikasso, a very simple drawing program intended to be used on Plasma Mobile.

Pikasso
Pikasso

Pikasso is very basic and only supports drawing with the mouse/finger and adding rectangles and circles to the scene. An undo feature is also available as well as the possibility to export your beautiful artworks to SVGs. As you can see, Pikasso is not intended to be replacements for Krita. If you want a powerful drawing application just use Krita, it’s awesome. The scope of Pikasso is more similar to Kolourpaint or Paint.exe and intended for children to play a bit with it on Plasma Mobile.

Behind the scene

Behind the scene, Pikasso uses Rust and Lyon to do all the drawing. The entire drawing area is just one QQuickItem with many QSGNodes. This makes Pikasso hardware accelerated.

When drawing, Pikasso creates DrawEvents for storing the drawing. The DrawEvent contains a QPainterPath and a few metadata (line width, pen color and if the QPainterPath should be filled or not. This is inspired by the QtWidgets Scribble example and the Digikam sketch widgets. Using a QPainterPath replaces the usage of the std::variant from my last blog post.

Thanks to Milian on IRC for giving me the hint about just using a QPainterPath. This has the advantage of integrating better with the rest of the Qt ecosystem sine QPainterPath is used to store reusable instructions for QPainter but also supports more features like text rendering. Another advantage of using a QPainterPath is that we get an SVG export feature almost for free with a QSvgGenerator. See the MR from Jonah about this feature..

QPainterPath can be natively used with QPainter, but it can also be in a very simple way transformed to a LyonBuilder object.

rust::Box<LyonBuilder> painterPathToBuilder(const QPainterPath &path)
{
    auto lyonBuilder = new_builder();
    for (int i = 0; i < path.elementCount(); i++) {
        const auto element = path.elementAt(i);
        if (element.isLineTo()) {
            lyonBuilder->line_to(LyonPoint { static_cast<float>(element.x), static_cast<float>(element.y) });
        } else if (element.isMoveTo()) {
            lyonBuilder->move_to(LyonPoint { static_cast<float>(element.x), static_cast<float>(element.y) });
        } else if (element.type ==  QPainterPath::ElementType::CurveToElement) {
            // Cubic is encoded with ctrl1 -> CurveToElement, ctrl2 -> CurveToDataElement and to -> CurveToDataElement
            Q_ASSERT(i + 2 < path.elementCount() && "CurveToElement doesn't have data");
            const auto ctrl1 = path.elementAt(i);
            const auto ctrl2 = path.elementAt(i + 1);
            const auto to = path.elementAt(i + 2);
            lyonBuilder->cubic_bezier_to(
                LyonPoint { static_cast<float>(ctrl1.x), static_cast<float>(ctrl1.y) },
                LyonPoint { static_cast<float>(ctrl2.x), static_cast<float>(ctrl2.y) },
                LyonPoint { static_cast<float>(to.x), static_cast<float>(to.y) }
            );
            i += 2; // we analysed tree elements instead of just one
        }
    }

    return lyonBuilder;
}

And once we have a LyonBuilder object we can render our geometries to the screen. This part didn’t change since the last post.

auto node = new QSGGeometryNode;
auto builder = painterPathToBuilder(drawEvent.path);
LyonGeometry lyonGeometry;
if (!drawEvent.fill) {
    lyonGeometry = build_stroke(std::move(builder), drawEvent.penWidth);
} else {
    lyonGeometry = build_fill(std::move(builder));
}
QSGGeometry *geometry = new QSGGeometry(QSGGeometry::defaultAttributes_Point2D(),
        lyonGeometry.vertices.size(), lyonGeometry.indices.size());

QSGGeometry::Point2D *points = geometry->vertexDataAsPoint2D();
std::size_t i = 0;
for (const auto &vertice: lyonGeometry.vertices) {
    points[i].set(vertice.x, vertice.y);
    i++;
}

quint16* indices = geometry->indexDataAsUShort();
i = 0;
for (const auto indice: lyonGeometry.indices) {
    indices[i] = indice;
    i++;
}

Pikasso, needs to add new nodes for each new DrawEvent but only update the geometry of the last DrawEvent since it’s the only one who could have changed. This makes the painting experience very smooth.

For those interested, this is the code for the updatePaintNodeData method. It’s a bit more complex since Pikasso also handles undo events and needs to cleanup its removed QSGNodes.

Antialiasing

The last problem to solve for Pikasso was antialiasing since the figures on the screen looked pixelized. This was easy to solve by enabling Multisample Antialiasing on my QML DrawingArea. This can be done using the layer.samples property:

DrawingArea {
    anchors.fill: parent
    layer.enabled: true
    layer.samples: 4
}

Future goals

I think Pikasso is already good enough for its intended usage scope. That I might do in the future is to use a similar technique to add annotation supports to KQuickImageEditor, a library I developed for editing images before sending them in NeoChat. This will need a bit of thinking and a lot of refactoring in KQuickImageEditor first.

Comments

With an account on the Fediverse or Mastodon, you can respond to this post. Since Mastodon is decentralized, you can use your existing account hosted by another Mastodon server or compatible platform if you don't have an account on this one. Known non-private replies are displayed below.

Learn how this is implemented here.

Licensed under CC BY-SA 4.0