DEV Community

Ayush
Ayush

Posted on • Updated on

Using KI18n with Rust and Qml

Introduction

Now that we have a working QML application start point, I would like to start working on my application. I will be using KDE Kirigami to create my application. While using Kirigami doesn't seem to require any extra configuration(at least not initially), the KI18n framework used for localization in KDE does need some work.

This post can serve as a guide to using C++ from Rust and creating stuff to use qt from Rust.

Create a Libray

I just created a library create using cargo:

cargo new ki18n --lib
Enter fullscreen mode Exit fullscreen mode

Rust has the convention to name the minimal wrappers of C libraries as *-sys. However, this doesn't seem to be a convention in C++ libraries. Also, this crate isn't a minimal wrapper in any way.

Also, check that everything is working by running the test:

cargo test
Enter fullscreen mode Exit fullscreen mode

Add Dependencies

Normal Dependencies

We will need to add a few dependencies to Cargo.toml before starting:

[dependencies]
cpp = "0.5"
qttypes = "0.2"
qmetaobject = "0.2"
Enter fullscreen mode Exit fullscreen mode

We need qmetaobject as a dependency since we will need QObject and QmlEngine later, which are not defined in qttypes. Honestly, we probably can avoid specifying qttypes, but I just left it there for now.

Build Dependencies

We also need a few dependencies for our build.rs. Here is documentation covering the basics of build scripts. If I am being honest, I still don't completely understand everything about linking and other things which seem to be needed when interfacing with C/C++.

[build-dependencies]
cpp_build = "0.5"
semver = "1.0"
Enter fullscreen mode Exit fullscreen mode

Writing build.rs

Start point

This is probably the portion that I found the most difficult. The README of qmetaobject-rs gives us a basic idea of the build script, so I started with that. Here is my starting script

use semver::Version;

fn main() {
    eprintln!("cargo:warning={:?}", std::env::vars().collect::<Vec<_>>());

    let qt_include_path = std::env::var("DEP_QT_INCLUDE_PATH").unwrap();
    let qt_library_path = std::env::var("DEP_QT_LIBRARY_PATH").unwrap();
    let qt_version = std::env::var("DEP_QT_VERSION")
        .unwrap()
        .parse::<Version>()
        .expect("Parsing Qt version failed");

    let mut config = cpp_build::Config::new();
    if cfg!(target_os = "macos") {
        config.flag("-F");
        config.flag(&qt_library_path);
    }
    if qt_version >= Version::new(6, 0, 0) {
        config.flag_if_supported("-std=c++17");
        config.flag_if_supported("/std:c++17");
        config.flag_if_supported("/Zc:__cplusplus");
    }
    config.include(&qt_include_path).build("src/lib.rs");

    for minor in 7..=15 {
        if qt_version >= Version::new(5, minor, 0) {
            println!("cargo:rustc-cfg=qt_{}_{}", 5, minor);
        }
    }
    let mut minor = 0;
    while qt_version >= Version::new(6, minor, 0) {
        println!("cargo:rustc-cfg=qt_{}_{}", 6, minor);
        minor += 1;
    }
}
Enter fullscreen mode Exit fullscreen mode

Setting up KI18n

For linking KI18n, I decided to create a new function. This was my first time writing a build script, so honestly, I was pretty clueless. I currently have the include path hardcoded because I couldn't find a better way to locate the header files. If anyone has suggestions, they are welcome to open issue at github or they can comment a solution.

fn ki18n_setup(config: &mut cpp_build::Config) {
    let kf5_i18n_path = "/usr/include/KF5/KI18n";
    config.include(kf5_i18n_path);
    println!("cargo:rustc-link-lib=KF5I18n");
}
Enter fullscreen mode Exit fullscreen mode

Tidying up Qt section

I also extracted the Qt section to it's seperate function to keep things tidy. Here is the new function:

fn qt_setup(config: &mut cpp_build::Config) -> Version {
    let qt_include_path = std::env::var("DEP_QT_INCLUDE_PATH").unwrap();
    let qt_library_path = std::env::var("DEP_QT_LIBRARY_PATH").unwrap();
    let qt_version = std::env::var("DEP_QT_VERSION")
        .unwrap()
        .parse::<Version>()
        .expect("Parsing Qt version failed");

    if cfg!(target_os = "macos") {
        config.flag("-F");
        config.flag(&qt_library_path);
    }

    if qt_version >= Version::new(6, 0, 0) {
        config.flag_if_supported("-std=c++17");
        config.flag_if_supported("/std:c++17");
        config.flag_if_supported("/Zc:__cplusplus");
    }

    config.include(&qt_include_path);

    // Include qtcore
    config.include(&format!("{}/{}", qt_include_path, "QtCore"));

    qt_version
}
Enter fullscreen mode Exit fullscreen mode

I had to include QtCore separately, and I don't currently have any solution for this. It seems the KLocalized header file imports QObject directly rather than using a relative path like include <QtCore/QObject>. So this is probably a quick and dirty fix for now.

Here is my full build script:

use semver::Version;

fn main() {
    eprintln!("cargo:warning={:?}", std::env::vars().collect::<Vec<_>>());

    let mut config = cpp_build::Config::new();

    let qt_version = qt_setup(&mut config);
    ki18n_setup(&mut config);

    config.build("src/lib.rs");

    for minor in 7..=15 {
        if qt_version >= Version::new(5, minor, 0) {
            println!("cargo:rustc-cfg=qt_{}_{}", 5, minor);
        }
    }
    let mut minor = 0;
    while qt_version >= Version::new(6, minor, 0) {
        println!("cargo:rustc-cfg=qt_{}_{}", 6, minor);
        minor += 1;
    }
}

fn qt_setup(config: &mut cpp_build::Config) -> Version {
    let qt_include_path = std::env::var("DEP_QT_INCLUDE_PATH").unwrap();
    let qt_library_path = std::env::var("DEP_QT_LIBRARY_PATH").unwrap();
    let qt_version = std::env::var("DEP_QT_VERSION")
        .unwrap()
        .parse::<Version>()
        .expect("Parsing Qt version failed");

    if cfg!(target_os = "macos") {
        config.flag("-F");
        config.flag(&qt_library_path);
    }

    if qt_version >= Version::new(6, 0, 0) {
        config.flag_if_supported("-std=c++17");
        config.flag_if_supported("/std:c++17");
        config.flag_if_supported("/Zc:__cplusplus");
    }

    config.include(&qt_include_path);

    // Include qtcore
    config.include(&format!("{}/{}", qt_include_path, "QtCore"));

    qt_version
}

fn ki18n_setup(config: &mut cpp_build::Config) {
    let kf5_i18n_path = "/usr/include/KF5/KI18n";

    config.include(kf5_i18n_path);

    println!("cargo:rustc-link-lib=KF5I18n");
}
Enter fullscreen mode Exit fullscreen mode

Writing the Library

Now we can finally work on using KI18n from Rust. The cpp documentation is pretty great, and I would advise everyone to go through it if they are doing anything with C++ and Rust.

Creating Wrapper for KLocalizedContext

We cannot directly use KLocalizedContext from Rust since it is not a relocatable struct. Thus we need to create a wrapper struct which contains a unique_ptr to our actual KLocalizedContext. At least that's how qmetaobject-rs seems to get around the problem. Here's how the wrapper looks like:

cpp! {{
    #include <KLocalizedContext>
    #include <QtCore/QObject>
    #include <QtQml/QQmlEngine>
    #include <QtQuick/QtQuick>

    struct KLocalizedContextHolder {
        std::unique_ptr<KLocalizedContext> klocalized;

        KLocalizedContextHolder(QObject *parent) : klocalized(new KLocalizedContext(parent)) {}
    };
}}
Enter fullscreen mode Exit fullscreen mode

We use the cpp! macro to include all the headers and define the struct.

Define Rust Struct

We now need to define a rust struct for KLocalizedContext, which refers to our Holder struct in C++. We use the cpp_class! macro for this:

cpp_class!(pub unsafe struct KLocalizedContext as "KLocalizedContextHolder");
Enter fullscreen mode Exit fullscreen mode

The as "datatype" part represents the C++ type our rust type/data refers to.

Implement members

Finally we can now implement the methods we want on this struct. Currently, I just want to register KLocalizedContext so that I can use the methods like i18n from QML. So the implementation is given below:

impl KLocalizedContext {
    pub fn init_from_engine(engine: &QmlEngine) {
        let engine_ptr = engine.cpp_ptr();
        cpp!(unsafe [engine_ptr as "QQmlEngine*"] {
            engine_ptr->rootContext()->setContextObject(new KLocalizedContext(engine_ptr));
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

We have to use a closure in the cpp! macro to execute the instructions we want to perform.

Example Usage

Using this is pretty straightforward. We need to initialize KLocalizedContext after creating the engine. Here is an example:

use cstr::cstr;
use qmetaobject::prelude::*;
use ki18n_rs::KLocalizedContext;

fn main() {
  let mut engine = QmlEngine::new();
  KLocalizedContext::init_from_engine(&engine);
  engine.load_data(r#"
    import QtQuick 2.6
    import QtQuick.Controls 2.0 as Controls
    import QtQuick.Layouts 1.2
    import org.kde.kirigami 2.13 as Kirigami

    // Base element, provides basic features needed for all kirigami applications
    Kirigami.ApplicationWindow {
        // ID provides unique identifier to reference this element
        id: root

        // Window title
        // i18nc is useful for adding context for translators, also lets strings be changed for different languages
        title: i18nc("@title:window", "Hello World")

        // Initial page to be loaded on app load
        pageStack.initialPage: Kirigami.Page {

            Controls.Label {
                // Center label horizontally and vertically within parent element
                anchors.centerIn: parent
                text: i18n("Hello World!")
            }
        }
    }
  "#.into());
  engine.exec();
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

I have publised this crate on crates.io as ki18n. I will be exposing more of the C++ API when I get the time. However, since I haven't used KI18n in the past, pull requests and issues on Github will be extremely valuable. If possible, I would like to make the usage of KI18n from Rust as painless as possible.

Discussion (0)