Rewriting a shipping Qt application in Rust is how you turn a working product into a two-year migration project that never quite converges, because the UI was never the problem, the logic was, and yet the rewrite always seems to start by throwing the UI away first. The question worth answering is a narrower one: how do you move the interesting code into Rust while the QML, the Kirigami pages, and the designer’s muscle memory stay exactly where they are?

LinSight and LinSync both answer it the same way, with the GUI built as an ordinary Rust binary whose QObjects are written in Rust and consumed from QML, and the layer that makes that possible is cxx-qt, KDAB’s set of crates for bidirectional Rust-to-Qt bindings. This is the post about why that boundary sits where it does, what the mechanism actually looks like in our tree, and what it costs, because it is not free and pretending otherwise would waste your afternoon.

The bridge is a macro, not a runtime

The mental model to reject first is the FFI island, where you write a C ABI, dangle some opaque pointers across it, and spend the rest of the project writing marshaling code by hand. cxx-qt is not that; it is a code generator built on top of cxx, and the unit it generates is a real QObject with a real meta-object, which means properties, signals, slots, and invokables that QML treats as native, because as far as moc and the QML engine are concerned, they are.

You write the bridge once per QObject as a Rust module annotated with #[cxx_qt::bridge], and the macro expands it into both the Rust side and a generated C++ shim that gets compiled into the same binary. Here is the shape of ours, trimmed from LinSight’s preferences_model.rs, which owns the user’s theme and dashboard choices and persists them to disk:

#[cxx_qt::bridge]
pub mod ffi {
    unsafe extern "C++" {
        include!("cxx-qt-lib/qstring.h");
        type QString = cxx_qt_lib::QString;
    }

    #[auto_cxx_name]
    extern "RustQt" {
        #[qobject]
        #[qml_element]
        #[qproperty(QString, theme)]
        #[qproperty(QString, active_dashboard)]
        #[qproperty(QString, start_page)]
        #[qproperty(i32, sample_interval_ms)]
        type PreferencesModel = super::PreferencesModelRust;

        #[qinvokable]
        fn apply_theme(self: Pin<&mut PreferencesModel>, id: &QString);

        #[qinvokable]
        fn color(self: &PreferencesModel, role: &QString) -> QString;
    }
}

pub struct PreferencesModelRust {
    theme: QString,
    active_dashboard: QString,
    start_page: QString,
    sample_interval_ms: i32,
}

The division of labor is the whole design in miniature: the extern "RustQt" block declares what Qt is allowed to see, the #[qproperty] attributes generate the getters, setters, and notify signals that QML bindings require, the #[qinvokable] methods are what a button’s onClicked can call, and the backing struct is a plain Rust struct that the rest of the crate can borrow, test, and refactor without a single thought for the meta-object system. Everything below the bridge, the atomic write to preferences.json, the validation of the start-page route, the theme catalog, is ordinary Rust with ordinary unit tests, and none of it knows Qt exists.

The build script is where the QML module is born

The second half of the mechanism lives in build.rs, because a QObject written in Rust is not visible to QML until it belongs to a module with a URI, and cxx-qt-build is the piece that wires that up at compile time. LinSight’s GUI crate declares cxx-qt = "0.8" and cxx-qt-lib with the qt_gui and qt_qml features as regular dependencies, keeps cxx as a direct dependency because the bridge macro expands to absolute ::cxx paths that cargo-machete cannot see, and then registers the whole module in the build script:

let builder = CxxQtBuilder::new_qml_module(
    QmlModule::new("com.visorcraft.LinSight").version(1, 0).qml_files([
        "qml/Main.qml",
        "qml/OverviewPage.qml",
        // ... twenty-eight more pages and components ...
    ]),
)
.file("src/qobjects/overview_model.rs")
.file("src/qobjects/preferences_model.rs")
.file("src/qobjects/dashboards_model.rs");

On the QML side the result is boring, and boring is the point, because Main.qml imports the Rust-backed module with the same syntax it uses for everything else:

import QtQuick
import QtQuick.Controls as Controls
import org.kde.kirigami as Kirigami
import com.visorcraft.LinSight

There is no plugin loader, no QJSEngine trickery, no context-property soup where objects appear by magic string; the Rust QObjects are elements of the module, they autocomplete in Qt Creator, and the person editing the QML never has to know which language the model behind gpuModel.poll() is written in. LinSync does the identical dance under com.visorcraft.LinSync, which is how the pattern stopped being an experiment and became the default for a new Qt app in our repos.

Nine thin shells over a Rust core

The discipline that keeps this healthy is that the QObjects stay thin. LinSight’s GUI has nine of them, an overview model, a preferences model, models for dashboards, hardware, history, and remote hosts, an alert model, an RPC worker, and a workspace handle, and every one is an adapter whose job is to expose state and take calls, not to contain the application. The application lives in linsight-core and linsight-protocol, pure Rust crates with no Qt anywhere in their dependency trees, and the GUI talks to the daemon over the same postcard-framed Unix socket that linsight-cli and every third-party plugin uses, which means the GUI is one consumer among several rather than the privileged owner of the logic.

That separation is what makes the “without rewriting” claim honest, because the migration never has to cross the UI layer at all: QML files stay QML files, the Kirigami pages keep their designer-friendly declarative shape, and when a piece of logic moves from “good enough in whatever language” to “worth doing properly in Rust,” it moves below the bridge, one model at a time, with tests attached. We did the twenty-years-ago version of this the other way, watching teams port working C++ to whatever the new language was that year, and the strangler pattern won every time it was tried; cxx-qt is simply what the strangler pattern looks like when the wall you grow through is the QObject boundary.

What it costs

The first cost is that cxx-qt is pre-1.0 and its own README says the API changes frequently, so we stay on the 0.8 series and treat version bumps as real work with a changelog review rather than a casual cargo update, which is the price of riding a library that is still settling its attribute grammar.

The second cost is build noise with teeth: GCC 16 added -Wsfinae-incomplete, which fires through Qt 6’s QChar in every generated shim and produces hundreds of lines of warnings per QObject from a system-header interaction nobody can fix downstream, so our build script passes flag_if_supported("-Wno-sfinae-incomplete") and moves on, an ugly little incantation that at least documents itself in a comment.

The third cost is that “QObjects in Rust” does not mean “no C++ ever,” because a few corners of Qt still want a native shim, and LinSight keeps three small ones: a translator hook, an icon-theme hook, and a screenshot path that calls QQuickWindow::grabWindow() because Wayland serves stale cached surfaces for unfocused windows and the compositor cannot be talked out of it. Each is a few dozen lines compiled alongside the generated code, and each is a reminder that the boundary is pragmatic, not ideological; the goal was never purity, it was putting the logic where the tests are.

The rewrite that never has to happen

The modern-equivalent framing writes itself here, because in the old days the choice really was binary: live with the C++ forever, or schedule the rewrite and cancel it eighteen months later. What cxx-qt buys is a third option where the language of the core and the language of the UI are allowed to drift apart over years, where a new app starts with its models in Rust on day one, and where an existing Qt codebase could adopt the same shape one QObject at a time without the QML noticing. The designer keeps the designer’s workflow, the systems programmer gets ownership and the borrow checker where they matter, and the rewrite, the expensive one, the one that kills products, never has to appear on a roadmap at all.