DEV Community

Cover image for Build a desktop app with Qt and Rust
Matt Angelosanto for LogRocket

Posted on • Originally published at blog.logrocket.com

Build a desktop app with Qt and Rust

Written by Azzam S.A ✏️

The rise of progressive web applications has resulted in a commensurate increase in the number of desktop apps that are released every day. To see evidence of this, just go to Flathub’s new apps page or GitHub's trending page. For example, immediately after the release of the ChatGPT API, hundreds of desktop applications emerged. Desktop apps are native, fast, and secure and provide experiences that web applications can’t match.

cmOf all the programming languages used in desktop app development, Rust is one of the more popular. Rust is widely regarded as being reliable, performant, productive, and versatile. In fact, many organizations are migrating their applications to Rust. The GNOME Linux development environment is an example. Personally, I especially love Rust’s reliability principle: “If it compiles, it works.” In this article, we’ll demonstrate how to build a desktop application using Qt (a mature, battle-tested framework for cross-platform development) and Rust.

Jump ahead:

Prerequisites

To follow along with the demo and other content included in this guide, you should have a working knowledge of Rust. To learn more, you can read the book.

Choosing Rust Qt bindings

Rust has several Qt bindings. The most popular are Ritual, CXX-Qt, and qmetaobject. Ritual is not maintained anymore, and qmetaobject doesn't support QWidgets. So CXX-Qt is our best bet for now.

Because Rust is a relatively new language. So is the ecosystem. CXX-Qt is not as mature as PyQt. But it is on the way there. The current latest release already has a good and simple API.

Getting started with Rust and Qt

Install Rust using the following command:

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
Enter fullscreen mode Exit fullscreen mode

If you’re using Windows, go to https://rustup.rs/ to download the installer. To ensure everything is okay, run the following command in your terminal:

rustc --version
Enter fullscreen mode Exit fullscreen mode

Next, install Qt:

# Ubuntu
sudo apt install qt6-base-dev qt6-declarative-dev

# Fedora
sudo dnf install qt6-qtbase-devel qt6-qtdeclarative-devel

# If you are unsure. Just install all Qt dependencies
# It is no more than 200 MB
sudo apt install qt6*
sudo dnf install qt6*
Enter fullscreen mode Exit fullscreen mode

To check that Qt is successfully installed, check that you’re able to run the following:

qmake --version
Enter fullscreen mode Exit fullscreen mode

Everything looks great! We are good to go now.

Application components

CXX-Qt is the Rust Qt binding. It provides a safe mechanism for bridging between Qt code and Rust. Unlike typical one-to-one bindings. CXX-Qt uses CXX to bridge Qt and Rust. This gives more powerful code, safe API, and safe multi-threading between both codes. Unlike the previous version. You don't need to touch any C++ code in the latest version.

QML is a programming language to develop the user interface. It is very readable because it offers JSON-like syntax. QML also has support for imperative JavaScript expressions and dynamic property bindings; this will be helpful for writing our Caesar Cipher application. If you need a refresher, see this QML intro.

Qt application demo

To demonstrate how to work with Qt and Rust, we’ll build a simple “Hello World” application.

Creating the Rust project

First, we need to create a Rust project, like so:

 cargo new --bin demo
     Created binary (application) `demo` package
Enter fullscreen mode Exit fullscreen mode

Next, open the Cargo.toml file and add the dependencies:

[dependencies]
cxx = "1.0.83"
cxx-qt = "0.5"
cxx-qt-lib = "0.5"

[build-dependencies]
cxx-qt-build = "0.5"
Enter fullscreen mode Exit fullscreen mode

Now, let's create the entry point for our application. In the src/main.rs file we’ll initialize the GUI application and the QML engine. Then we’ll load the QML file and tell the application to start:

// src/main.rs

use cxx_qt_lib::{QGuiApplication, QQmlApplicationEngine, QUrl};

fn main() {
    // Create the application and engine
    let mut app = QGuiApplication::new();
    let mut engine = QQmlApplicationEngine::new();

    // Load the QML path into the engine
    if let Some(engine) = engine.as_mut() {
        engine.load(&QUrl::from("qrc:/main.qml"));
    }

    // Start the app
    if let Some(app) = app.as_mut() {
        app.exec();
    }
}
Enter fullscreen mode Exit fullscreen mode

To set up communication between Rust and Qt, we’ll define the object in the src/cxxqt_oject.rs file:

// src/cxxqt_object.rs

#[cxx_qt::bridge]
mod my_object {

    #[cxx_qt::qobject(qml_uri = "demo", qml_version = "1.0")]
    #[derive(Default)]
    pub struct Hello {}

    impl qobject::Hello {
        #[qinvokable]
        pub fn say_hello(&self) {
            println!("Hello world!")
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Attribute macro is used to enable CXX-Qt features.

  • #[cxx_qt::bridge]: marks the Rust module to be able to interact with C++
  • #[cxx_qt::qobject]: expose a Rust struct to Qt as a QObject subclass
  • #[qinvokable]: expose a function on the QObject to QML and C++ as a Q_INVOKABLE.

Next, we’ll create a struct, named Hello, derived from the qobject traits. Then we can implement our regular Rust function to print a greeting:

// src/main.rs

+ mod cxxqt_object;

use cxx_qt_lib::{QGuiApplication, QQmlApplicationEngine, QUrl};
Enter fullscreen mode Exit fullscreen mode

N.B., don't forget to tell Rust if you have a new file

Designing the UI

We’ll use QML to design the user interface. The UI file is located in the qml/main.qml file:

// qml/main.qml

import QtQuick.Controls 2.12
import QtQuick.Window 2.12

// This must match the qml_uri and qml_version
// specified with the #[cxx_qt::qobject] macro in Rust.
import demo 1.0

Window {
    title: qsTr("Hello App")
    visible: true
    height: 480
    width: 640
    color: "#e4af79"

    Hello {
        id: hello
    }

    Column {
        anchors.horizontalCenter: parent.horizontalCenter
        anchors.verticalCenter: parent.verticalCenter
        /* space between widget */
        spacing: 10

        Button {
            text: "Say Hello!"
            onClicked: hello.sayHello()
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

If you look closely at sayHello function, you‘ll notice that CXX-Qt converts the Rust function’s snake case to the camelCase C++ convention. Now our QML code doesn't look out of place!

Next, we have to tell Qt about the QML location using the Qt resource file. It should be located in the qml/qml.qrc file:

<!DOCTYPE RCC>
<RCC version="1.0">
    <qresource prefix="/">
        <file>main.qml</file>
    </qresource>
</RCC>
Enter fullscreen mode Exit fullscreen mode

Building the application

The last step is to build the application. To teach Rust how to build the cxxqt_object.rs and QML files, we need to first define it in build.rs file:

// build.rs

use cxx_qt_build::CxxQtBuilder;

fn main() {
    CxxQtBuilder::new()
        // Link Qt's Network library
        // - Qt Core is always linked
        // - Qt Gui is linked by enabling the qt_gui Cargo feature (default).
        // - Qt Qml is linked by enabling the qt_qml Cargo feature (default).
        // - Qt Qml requires linking Qt Network on macOS
        .qt_module("Network")
        // Generate C++ from the `#[cxx_qt::bridge]` module
        .file("src/cxxqt_object.rs")
        // Generate C++ code from the .qrc file with the rcc tool
        // https://doc.qt.io/qt-6/resources.html
        .qrc("qml/qml.qrc")
        .setup_linker()
        .build();
}
Enter fullscreen mode Exit fullscreen mode

The final structure should look like this:

⬢ ❯ exa --tree --git-ignore
.
├── qml
│  ├── main.qml
│  └── qml.qrc
├── src
│  ├── cxxqt_object.rs
│  └── main.rs
├── build.rs
├── Cargo.lock
└── Cargo.toml
Enter fullscreen mode Exit fullscreen mode

Now, let’s use cargo check to make sure we have a correct code.

# `cargo c` is an alias to `cargo check`
❯ cargo c
  Finished dev [unoptimized + debuginfo] target(s) in 0.04s
Enter fullscreen mode Exit fullscreen mode

Finally, let’s run the application:

⬢ ❯ cargo --quiet r
   Compiling demo v0.1.0
    Finished dev [unoptimized + debuginfo] target(s) in 0.49s
     Running `target/debug/demo`
Hello world!
Enter fullscreen mode Exit fullscreen mode

Rust Qt Hello World Application

Using the Just task runner (Optional)

A task runner can save you time by automating repetitive development tasks. I recommend using Just. To install Just, use the following command:

cargo install just
Enter fullscreen mode Exit fullscreen mode

Just is similar to Make, but without Make’s complexity and idiosyncrasies.

Simply add the justfile file into your root directory and put your repetitive tasks there:

#!/usr/bin/env -S just --justfile

alias d := dev
alias r := run
alias f := fmt
alias l := lint
alias t := test

# List available commands.
_default:
    just --list --unsorted

# Develop the app.
dev:
    cargo watch -x 'clippy --locked --all-targets --all-features'

# Develop the app.
run:
    touch qml/qml.qrc && cargo run

# Format the codebase.
fmt:
    cargo fmt --all

# Check if the codebase is properly formatted.
fmt-check:
    cargo fmt --all -- --check

# Lint the codebase.
lint:
    cargo clippy --locked --all-targets --all-features

# Test the codebase.
test:
    cargo test run --all-targets

# Tasks to make the code base comply with the rules. Mostly used in git hooks.
comply: fmt lint test

# Check if the repository complies with the rules and is ready to be pushed.
check: fmt-check lint test
Enter fullscreen mode Exit fullscreen mode

For more convenience, you can use j as an alias for just in your shell:

alias j='just'
Enter fullscreen mode Exit fullscreen mode

Now you can use j r for cargo run and so on.

Adding encryption

Let's further improve the application by modifying the text input. We’ll add some simple encryption using Caesar Cipher encoding.

First, let’s rename the application to "caesar":

# Cargo.toml

[package]
-name = "demo"
+name = "caesar"
Enter fullscreen mode Exit fullscreen mode

Then, we’ll add the encrypt function:

// cxxqt_object.rs

#[cxx_qt::bridge]
mod my_object {

    unsafe extern "C++" {
        include!("cxx-qt-lib/qstring.h");
        type QString = cxx_qt_lib::QString;
    }

    #[cxx_qt::qobject(qml_uri = "caesar", qml_version = "1.0")]
    pub struct Rot {
        #[qproperty]
        plain: QString,
        #[qproperty]
        secret: QString,
    }

    impl Default for Rot {
        fn default() -> Self {
            Self {
                plain: QString::from(""),
                secret: QString::from(""),
            }
        }
    }

    impl qobject::Rot {
        #[qinvokable]
        pub fn encrypt(&self, plain: &QString) -> QString {
            let result = format!("{plain} is a secret");
            QString::from(&result)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Next, we need to import the Qstring, because our encrypt function accepts Qstring as an argument and also has Qstring as a return type. The struct now contains two fields: plain and secret. The encrypt function is a regular Rust function, but it accepts and returns a type from CXX-Qt: Qstring.

In the user interface, let’s add a TextArea text field component and a Button:

// main.qml

import QtQuick.Controls 2.12
import QtQuick.Window 2.12

// This must match the qml_uri and qml_version
// specified with the #[cxx_qt::qobject] macro in Rust.
import caesar 1.0

Window {
    title: qsTr("Caesar")
    visible: true
    height: 480
    width: 640
    color: "#e4af79"

    Rot {
        id: rot
        plain: ""
        secret: ""
    }

    Column {
        anchors.horizontalCenter: parent.horizontalCenter
        anchors.verticalCenter: parent.verticalCenter
        /* space between widget */
        spacing: 10

        Label {
            text: "Keep your secret safe 🔒"
            font.bold: true
        }

        TextArea {
            placeholderText: qsTr("me@caesar.tld")
            text: rot.plain
            onTextChanged: rot.plain = text

            background: Rectangle {
                implicitWidth: 400
                implicitHeight: 50
                radius: 3
                color:  "#e2e8f0"
                border.color:  "#21be2b"
            }
        }

        Button {
            text: "Encrypt!"
            onClicked: rot.secret = rot.encrypt(rot.plain)
        }

        Label {
            text: rot.secret
        }
    }
}

In the TextArea we use the onTextChanged signal to set the plain field of the Rot struct to be assigned the value of the TextArea input whenever anything changes.

Then, we use the onClicked signal in the Button component to assign the return value of the encrypt function to the secret field. Also, we pass the value of plain to the encrypt function.

Finally, we display the value of the secret field in the Label component.

Now, let’s run the application:

⬢ ❯ j r
touch qml/qml.qrc && cargo run
   Compiling caesar v0.1.0
    Finished dev [unoptimized + debuginfo] target(s) in 8.12s
     Running `target/debug/caesar`

Rust Qt App Caesar Cipher Encryption

Encrypting with nrot

One approach for encrypting the user input (secret message) text is to use the nrot third-party library.

First, let’s add the dependency:

# Cargo.toml

+[dependencies]
+nrot = "2.0.0"

+# UI
cxx = "1.0.83"

Then, import the crate:

 mod my_object {

+    use nrot::{rot, rot_letter, Mode};
+
     unsafe extern "C++" {

Next, let’s use nrot to improve our app’s encrypt function:

        pub fn encrypt(&self, plain: &QString) -> QString {
            let rotation = 13; // common ROT rotation
            let plain = plain.to_string();
            let plain = plain.as_bytes();

            let bytes_result = rot(Mode::Encrypt, plain, rotation);
            let mut secret = format!("{}", String::from_utf8_lossy(&bytes_result));

            if plain.len() == 1 {
                let byte_result = rot_letter(Mode::Encrypt, plain[0], rotation);
                secret = format!("{}", String::from_utf8_lossy(&[byte_result]));
            };

            QString::from(&secret)
        }

Rust Qt App Nrot Encryption

Using on-the-fly encryption

Another approach for encrypting the user input (secret message) text is to use on-the-fly encryption. This will enable us to eliminate the old-fashioned button and actually encrypt the secret message as the user is typing.

First, let’s remove the Button component and set the value of the secret field to update every time the user makes a change to the input field:

modified   qml/main.qml
             placeholderText: qsTr("me@caesar.tld")
             text: rot.plain
-            onTextChanged: rot.plain = text
+            onTextChanged: rot.secret = rot.encrypt(text)

-        Button {
-            text: "Encrypt!"
-            onClicked: rot.secret = rot.encrypt(rot.plain)
-        }
-

Rust Qt App On-The-Fly Encryption

Adding decryption

Now, let's add the decryption functionality. We’ll use the following decrypt function:

        #[qinvokable]
        pub fn decrypt(&self, secret: &QString) -> QString {
            let rotation = 13; // common ROT rotation
            let secret = secret.to_string();
            let secret = secret.as_bytes();

            let bytes_result = rot(Mode::Decrypt, secret, rotation);
            let mut plain = format!("{}", String::from_utf8_lossy(&bytes_result));

            if secret.len() == 1 {
                let byte_result = rot_letter(Mode::Decrypt, secret[0], rotation);
                plain = format!("{}", String::from_utf8_lossy(&[byte_result]));
            };

            QString::from(&plain)
        }

Then, we’ll add the second TextArea component for interchangeable input:

        TextArea {
            placeholderText: qsTr("zr@pnrfne.gyq")
            text: rot.secret
            onTextChanged: rot.plain = rot.decrypt(text)

            background: Rectangle {
                implicitWidth: 400
                implicitHeight: 50
                radius: 3
                color: "#e2e8f0"
                border.color: "#21be2b"
            }
        }
    }

Rust Qt App Decryption

Using a custom component to avoid duplication

We have two very similar TextArea text fields. To avoid duplication, let’s create a custom component.

First, create an InputArea.qml file in the qml directory with the content of the previous TextArea component:

// InputArea.qml

import QtQuick 2.12
import QtQuick.Controls 2.12

TextArea {
    background: Rectangle {
        implicitWidth: 400
        implicitHeight: 50
        radius: 3
        color:  "#e2e8f0"
        border.color: "#21be2b"
    }
}

Include the file in the Qt resource:

    
        main.qml
+        InputArea.qml
    

Next, modify the main.qml file to use InputArea:

// main.qml

        InputArea {
            placeholderText: qsTr("me@caesar.tld")
            text: rot.plain
            onTextChanged: rot.secret = rot.encrypt(text)
        }

        InputArea {
            placeholderText: qsTr("zr@pnrfne.gyq")
            text: rot.secret
            onTextChanged: rot.plain = rot.decrypt(text)
        }

Creating a GitHub CI

As a final step, to ensure that we have the correct code in each commit, let’s create a GitHub CI workflow:

name: Caesar

jobs:
  code_quality:
    name: Code quality
    runs-on: ubuntu-22.04

  build:
    name: Build for GNU/Linux
    runs-on: ubuntu-22.04
    strategy:
      fail-fast: false

    steps:
      - name: Checkout source code
        uses: actions/checkout@v3

      - name: Install Rust toolchain
        uses: dtolnay/rust-toolchain@stable
        with:
          target: x86_64-unknown-linux-gnu

      - name: Install Qt
        if: matrix.os == 'ubuntu-22.04'
        run: |
          sudo apt-get update
          sudo apt-get install -y --no-install-recommends --allow-unauthenticated \
            qt6-base-dev                                                          \
            qt6-declarative-dev

      - name: Build
        run: cargo build --release --locked

Conclusion

In this article, we demonstrated how to build a desktop Qt application using Rust and QML. Along the way, we discussed QObject, Qt signals, and CXX-Qt attribute macros.

If you’d like, you can further improve the demo app by adding more rotation features. Currently, the rotation value is hardcoded to 13. Also, you can play with the current QML user interface to make it fancier. The code for this article’s demo application is available on GitHub.

I hope you enjoyed this article. If you have questions, feel free to leave a comment.


LogRocket: Full visibility into web frontends for Rust apps

Debugging Rust applications can be difficult, especially when users experience issues that are difficult to reproduce. If you’re interested in monitoring and tracking performance of your Rust apps, automatically surfacing errors, and tracking slow network requests and load time, try LogRocket.

LogRocket Dashboard Free Trial Banner

LogRocket is like a DVR for web apps, recording literally everything that happens on your Rust app. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors your app’s performance, reporting metrics like client CPU load, client memory usage, and more.

Modernize how you debug your Rust apps — start monitoring for free.

Top comments (0)