GUI フレームワーク Slint の紹介

[Rust Advent Calendar 2023](https://qiita.com/advent-calendar/2023/rust) の12日目の記事です．

Rust で動くクロスプラットフォームの GUI フレームワーク Slint の紹介と、簡単なチュートリアルを試した結果を共有します。

# Slint とは

[Slint](https://slint.dev/) は、ドイツのブランデンブルク州ホーエン・ノイエンドルフにある [SixtyFPS GmbH](https://slint.dev/imprint) 社が開発している、Rust 向けの GUI フレームワークです。

https://github.com/slint-ui/slint

[Qt](https://www.qt.io/) の [QML](https://www.qt.io/product/qt6/qml-book) に似た、宣言的な方法で GUI が記述でき、Rust だけではなく、C++ や JavaScript からも利用が可能になっています。

`Slint` という名前は、Slint が目指す以下の言葉の頭文字をとったものとなっています。

- Scalable
- Lightweight
- Intuitive
- Native

(最後の `t` は Xt や Qt と同様にツールキットを表すのかもしれない）

## プラットフォームの対応状況

2023年12月時点の [対応状況](https://github.com/slint-ui/slint?tab=readme-ov-file#current-status) は以下のとおりです。

- **組み込み機器**: *対応済*. Slint は Linux 及び Windows を利用した組み込み機器での製品開発に利用されています。Slint は 300KiB 以下の RAM 容量でも動作し、ARM Cortex M、 ESP32、STM32 といった MCU や、ARM Cortex A、Intel x86 といった MPU での動作をサポートしています。
- **デスクトップ**: *対応中*. Slint は Windows や Linux、Mac で動作していて、プラットフォームの対応を順次改善しているところです。
- **ウェブブラウザ**: *対応中*. Slint のコードは WebAssembly へのコンパイルが可能で、ウェブブラウザ上で動作します。ウェブ向けののフレームワークは多数存在するため、ウェブブラウザは Slint のメインのターゲットではありません。現在の対応は主にデモ向けとなります。
- **モバイル**
  - Android: *対応中*. https://github.com/slint-ui/slint/issues/46 で対応中です。
  - iOS: *未対応*. Android の初期対応が完了した後に、iOS の対応を行う予定です。

組み込み向けのボードの対応状況は以下を参照してください。

https://slint.dev/supported-boards

## 動作デモ

組み込み向けのデモは Youtube で見ることができます。

https://www.youtube.com/watch?v=_BDbNHrjK7g

https://www.youtube.com/watch?v=NNNOJJsOAis

https://www.youtube.com/watch?v=dkBwNocItGs

また、WebAssembly でも Slint のデモを見ることができます。

[![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/13867/ae71a300-c49a-0989-bc36-cfbb768434f2.png)](https://slint.dev/releases/1.3.2/demos/gallery/)

[![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/13867/5fe6bec9-e43a-0cb6-2ebd-183341d1e741.png)](https://slint.dev/demos/energy-monitor/)

さらに見たい方は以下のサイトを参照してください。

https://slint.dev/demos

## ライセンス体系

以下の３つの中から好きなものを選択することが可能です

1. [Royalty-free license](LICENSES/LicenseRef-Slint-Royalty-free-1.1.md),
2. [GNU GPLv3](LICENSES/GPL-3.0-only.txt),
3. [有料のライセンス](https://slint.dev/pricing.html).

※選択の指針は [FAQ](https://github.com/slint-ui/slint/blob/master/FAQ.md#licensing) をご覧ください。

# Slint をはじめよう

Slint は、GUI を `.slint` と呼ばれる言語で記述し、Slint の実行 API(Rust, C++, JavaScript) を利用して実行します。

## ドキュメント

https://releases.slint.dev/

## .slint で遊んでみよう

最終的にはローカルのプロジェクトに置くべき .slint ファイルですが、
ウェブブラウザ上で .slint ファイルの記述やプレビューを行うことができるようになっています。

https://slintpad.com/

まずは、この SlintPad 上で .slint での GUI の記述に慣れてみましょう。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/13867/88303f61-45a3-3e21-b085-8c6c729eac4c.png)

左側が .slint のコードを記述するエディタです。

右上が記述した .slint の GUI のプレビューです。

右下に、選択中のエレメントのプロパティの一覧や、アウトライン表示用のタブがあります。

### Hello World

```qml
export component App { 
    Text { text: "Hello World"; }
}
```

[![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/13867/7bb45167-c238-e664-b750-78bcf10cb8b6.png)](https://slint.dev/releases/1.3.2/editor/?snippet=export+component+App+%7B+%0A++++Text+%7B+text%3A+%22Hello+World%22%3B+%7D%0A%7D&style=native)

`App` というコンポーネントを作成し、内部に Slint にビルトインされている（= 無条件で利用可能な) [Text](https://slint.dev/releases/1.3.2/docs/slint/src/language/builtins/elements#text) エレメントを１つ生成します。

### ボタンの追加と配置

次に、ボタンを追加してみます。

Slint では、`Button` エレメントはビルトインではなく、[*標準ウィジェット*](https://slint.dev/releases/1.3.2/docs/slint/src/language/widgets/) という位置づけで提供されます。
この標準ウィジェットには、[Button](https://slint.dev/releases/1.3.2/docs/slint/src/language/widgets/button) や [ComboBox](https://slint.dev/releases/1.3.2/docs/slint/src/language/widgets/combobox) などが含まれ、プラットフォームやテーマに応じた [スタイル](https://slint.dev/releases/1.3.2/docs/slint/src/advanced/style#selecting-a-widget-style) で表示することが可能です。

標準ウィジェットが提供するエレメントを利用するには、以下のように `import` 文を記述する必要があります。

```qml
import { Button } from "std-widgets.slint";

export component App { 
    Text { text: "Hello World"; }
    Button { text: 'yay'; }
}
```

また、`Text` と `Button` ２つのエレメントを上下に配置するために、[VerticalBox](https://slint.dev/releases/1.3.2/docs/slint/src/language/widgets/verticalbox) を利用します。

```qml
import { Button, VerticalBox } from "std-widgets.slint";

export component App {
    VerticalBox {
        Text { text: "Hello World"; }
        Button { text: "yay"; }
    }
}
```

[![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/13867/6c8800e4-477c-abc3-86f2-531f8629aff6.png)](https://slint.dev/releases/1.3.2/editor/?snippet=import+%7B+Button%2C+VerticalBox+%7D+from+%22std-widgets.slint%22%3B%0A%0Aexport+component+App+%7B%0A++++VerticalBox+%7B%0A++++++++Text+%7B+text%3A+%22Hello+World%22%3B+%7D%0A++++++++Button+%7B+text%3A+%22yay%22%3B+%7D%0A++++%7D%0A%7D%0A&style=native)

### 独自プロパティの作成と、ボタンの処理

`App` に、`int` 型の `counter` プロパティを作成します。

```qml
import { Button, VerticalBox } from "std-widgets.slint";

export component App {
    property <int> counter: 1; // ここを追加
    VerticalBox {
        Text { text: "Hello World"; }
        Button { text: "yay"; }
    }
}
```

次に、`Text` に、その値を表示します。

```qml
import { Button, VerticalBox } from "std-widgets.slint";

export component App {
    property <int> counter: 1;
    VerticalBox {
        Text { text: "Hello World" + counter; } // ここを変更
        Button { text: "yay"; }
    }
}
```

ボタンが押された時の処理は、以下のように記述することが可能です。

```qml
import { Button, VerticalBox } from "std-widgets.slint";

export component App {
    property <int> counter: 1;
    VerticalBox {
        Text { text: "Hello World" + counter; }
        Button { text: "yay"; clicked => { counter+=1; } } // ここを変更
    }
}
```

プレビューで `yay` ボタンをクリックするごとに、`counter` プロパティの値が1づつ増加し、Hello World の横に表示される数値の表示も**自動で更新されます**。

[![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/13867/bfc6aeb1-d966-6560-86e5-0cda1b1b6678.png)](https://slint.dev/releases/1.3.2/editor/?snippet=import+%7B+Button%2C+VerticalBox+%7D+from+%22std-widgets.slint%22%3B%0A%0Aexport+component+App+%7B%0A++++property+%3Cint%3E+counter%3A+1%3B%0A++++VerticalBox+%7B%0A++++++++Text+%7B+text%3A+%22Hello+World%22+%2B+counter%3B+%7D%0A++++++++Button+%7B+text%3A+%22yay%22%3B+clicked+%3D%3E+%7B+counter%2B%3D1%3B+%7D+%7D%0A++++%7D%0A%7D%0A&style=native)

## Rust から .slint を利用してみよう

### プロジェクトの作成

```sh
cargo new calc
cd calc
```

slint のクレートを追加します
```sh
cargo add slint
```

Visual Studio Code をお使いの方は、以下の拡張を入れておくと作業が捗ります。

https://marketplace.visualstudio.com/items?itemName=Slint.slint

### .slint の組み込み

`slint::slint!` マクロを利用して、`.rs` ファイルに .slint のコードを埋め込むことが可能です。

```rust:src
slint::slint! {
    import { Button, VerticalBox } from "std-widgets.slint";

    export component App {
        property <int> counter: 1;
        VerticalBox {
            Text { text: "Hello World" + counter; }
            Button { text: "yay"; clicked => { counter+=1; } }
        }
    }
}

fn main() {
    println!("Hello, world!");
}
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/13867/36bf0642-9112-e485-0924-11401a1abe22.png)

Visual Studio Code では、 `.slint` のコード内に表示される「▶ Show Preview」をクリックすることで、プレビューが表示されるようになります。
このプレビューでは、.slint のコードが実行されているため、ボタンなどの動作を確認することができます。
また、.slint のコードを変更すると、即時プレビューにも反映されるため、プレビューを見ながら効率よく GUI のコードの開発を行うことができるようになっています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/13867/bbe4e98b-a408-38f5-ad39-1494a80d45b0.png)

### Rust から実行する

.slint のコードは、コンパイル時に Rust のコードに変換され、Rust のコードから利用可能になります。

```rust:src
slint::slint! {
    import { Button, VerticalBox } from "std-widgets.slint";

    export component App {
        property <int> counter: 1;
        VerticalBox {
            Text { text: "Hello World" + counter; }
            Button { text: "yay"; clicked => { counter+=1; } }
        }
    }
}

fn main() {
    App::new().unwrap().run().unwrap(); // ここを変更
}
```

実行すると、以下のような画面が現れます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/13867/cbf35582-4858-9ee1-01c6-abddc4a5067e.png)

### ボタンが押されたときの処理を Rust 側で行う

まず、.slint 側で、以下の変更を行います。

1. `counter` プロパティを外部からアクセスできるようにする
1. `clicked` という *コールバック* を生成し、外部からイベントの処理をできるようにする
1. `Button` に `btn` という名前（id, 変数名）をつける
1. `btn` の `clicked` を、`App` の `clicked` に結びつける

```qml:src/main.rs
slint::slint! {
    import { Button, VerticalBox } from "std-widgets.slint";

    export component App {
        in property <int> counter: 1; // 1
        callback clicked <=> btn.clicked; // 2, 4
        VerticalBox {
            Text { text: "Hello World" + counter; }
            btn := Button { text: "yay"; } // 3
        }
    }
}
```

.slint は GUI を記述するための独自の DSL のため、最初は見慣れないと感じるかもしれません。

Rust 側のコードを以下のように変更することで、.slint 側のコールバックやプロパティを利用したアプリケーションの開発が可能になります。

```rust:src/main.rs
fn main() {
    let app = App::new().unwrap();
    let weak = app.as_weak();
    app.on_clicked(move || {
        let app = weak.upgrade().unwrap();
        app.set_counter(app.get_counter() + 2);
    });
    app.run().unwrap();
}
```

## 独自エレメントの作成と高度な連携

電卓を開発しながら、さらに高度な Slint の使い方を学びましょう。

### 電卓の UI を作る

```qml:src/main.rs
slint::slint! {
    import { Button } from "std-widgets.slint";

    export component App {
        in property <int> value: 0;
        GridLayout {
            padding: 10px;
            spacing: 5px;
            Text { text: value; colspan: 3; }
            Row {
                Button { text: "1"; }
                Button { text: "2"; }
                Button { text: "3"; }
            }
            Row {
                Button { text: "4"; }
                Button { text: "5"; }
                Button { text: "6"; }
            }
            Row {
                Button { text: "7"; }
                Button { text: "8"; }
                Button { text: "9"; }
            }
            Row {
                Button { text: "0"; col: 1; }
            }
        }
    }
}
```

[![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/13867/4460720c-edb2-6bc8-65d9-0841cbe257fa.png)
](https://slint.dev/releases/1.3.2/editor/?snippet=%0A++++import+%7B+Button+%7D+from+%22std-widgets.slint%22%3B%0A%0A++++export+component+App+%7B%0A++++++++in+property+%3Cint%3E+value%3A+0%3B%0A++++++++GridLayout+%7B%0A++++++++++++padding%3A+10px%3B%0A++++++++++++spacing%3A+5px%3B%0A++++++++++++Text+%7B+text%3A+value%3B+colspan%3A+3%3B+%7D%0A++++++++++++Row+%7B%0A++++++++++++++++Button+%7B+text%3A+%221%22%3B+%7D%0A++++++++++++++++Button+%7B+text%3A+%222%22%3B+%7D%0A++++++++++++++++Button+%7B+text%3A+%223%22%3B+%7D%0A++++++++++++%7D%0A++++++++++++Row+%7B%0A++++++++++++++++Button+%7B+text%3A+%224%22%3B+%7D%0A++++++++++++++++Button+%7B+text%3A+%225%22%3B+%7D%0A++++++++++++++++Button+%7B+text%3A+%226%22%3B+%7D%0A++++++++++++%7D%0A++++++++++++Row+%7B%0A++++++++++++++++Button+%7B+text%3A+%227%22%3B+%7D%0A++++++++++++++++Button+%7B+text%3A+%228%22%3B+%7D%0A++++++++++++++++Button+%7B+text%3A+%229%22%3B+%7D%0A++++++++++++%7D%0A++++++++++++Row+%7B%0A++++++++++++++++Button+%7B+text%3A+%220%22%3B+col%3A+1%3B+%7D%0A++++++++++++%7D%0A++++++++%7D%0A++++%7D&style=native)

ビルトインエレメントの [GridLayout](https://slint.dev/releases/1.3.2/docs/slint/src/language/builtins/elements#gridlayout) を利用してボタンを配置しました。

### 独自エレメント（ボタン）の作成

標準で提供されている [Button](https://slint.dev/releases/1.3.2/docs/slint/src/language/widgets/button) の代わりに、自前で `Button` というエレメントを作成してみましょう。

```qml:src/main.rs
slint::slint! {
    component Button inherits Rectangle {
        min-width: 30px;
        min-height: 30px;
        in property <string> text;
        background: ta.pressed ? red : ta.has-hover ? #2b6fb3 : #1d78d3;
        animate background { duration: 100ms; }
        border-radius: 4px;
        border-width: 2px;
        border-color: self.background.darker(20%);
        Text { text: root.text; }
        ta := TouchArea {}
    }

    export component App {
        ...
    }
}
```

標準ウィジェットは利用しないので、先頭の `import` 文は削除しました。

[Rectangle](https://slint.dev/releases/1.3.2/docs/slint/src/language/builtins/elements#rectangle) をベースに新たな `Button` エレメントを作成し、数字を表示するための `Text` と、マウス操作に反応する `TouchArea` を配置しています。

マウスの操作の状態に応じて、背景色（`background`）と境界色（`border-color`）を変えています。

[![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/13867/9fb023f2-a62f-7e8d-ff33-58e66a7b5ae8.png)](https://slint.dev/releases/1.3.2/editor/?snippet=%0A++++component+Button+inherits+Rectangle+%7B%0A++++++++min-width%3A+30px%3B%0A++++++++min-height%3A+30px%3B%0A++++++++in+property+%3Cstring%3E+text%3B%0A++++++++background%3A+ta.pressed+%3F+red+%3A+ta.has-hover+%3F+%232b6fb3+%3A+%231d78d3%3B%0A++++++++animate+background+%7B+duration%3A+100ms%3B+%7D%0A++++++++border-radius%3A+4px%3B%0A++++++++border-width%3A+2px%3B%0A++++++++border-color%3A+self.background.darker%2820%25%29%3B%0A++++++++Text+%7B+text%3A+root.text%3B+%7D%0A++++++++ta+%3A%3D+TouchArea+%7B%7D%0A++++%7D%0A%0A++++export+component+App+%7B%0A++++++++in+property+%3Cint%3E+value%3A+0%3B%0A++++++++GridLayout+%7B%0A++++++++++++padding%3A+10px%3B%0A++++++++++++spacing%3A+5px%3B%0A++++++++++++Text+%7B+text%3A+value%3B+colspan%3A+3%3B+%7D%0A++++++++++++Row+%7B%0A++++++++++++++++Button+%7B+text%3A+%221%22%3B+%7D%0A++++++++++++++++Button+%7B+text%3A+%222%22%3B+%7D%0A++++++++++++++++Button+%7B+text%3A+%223%22%3B+%7D%0A++++++++++++%7D%0A++++++++++++Row+%7B%0A++++++++++++++++Button+%7B+text%3A+%224%22%3B+%7D%0A++++++++++++++++Button+%7B+text%3A+%225%22%3B+%7D%0A++++++++++++++++Button+%7B+text%3A+%226%22%3B+%7D%0A++++++++++++%7D%0A++++++++++++Row+%7B%0A++++++++++++++++Button+%7B+text%3A+%227%22%3B+%7D%0A++++++++++++++++Button+%7B+text%3A+%228%22%3B+%7D%0A++++++++++++++++Button+%7B+text%3A+%229%22%3B+%7D%0A++++++++++++%7D%0A++++++++++++Row+%7B%0A++++++++++++++++Button+%7B+text%3A+%220%22%3B+col%3A+1%3B+%7D%0A++++++++++++%7D%0A++++++++%7D%0A++++%7D&style=native)

### Rust と .slint 間で共有するグローバルオブジェクトの作成

`App` を直接利用するのではなく、グローバルなオブジェクトを通して連携をしてみます。

```qml:src/main.rs
slint::slint! {
    export global CalcLogic {
        callback button-pressed(string);
    }

    component Button inherits Rectangle {
        ...
        ta := TouchArea {
            clicked => { CalcLogic.button-pressed(root.text); }
        }
    }
...
}
```

グローバルな `CalcLogic` オブジェクトをエクスポートし、ボタンが押された際に、ボタン自体がそのコールバックを呼び出すようにしています。

```rust:src/main.rs
fn main() {
    let app = App::new().unwrap();
    let weak = app.as_weak();
    app.global::<CalcLogic>().on_button_pressed(move |value| {
        let app = weak.upgrade().unwrap();
        let current = app.get_value();
        app.set_value(current * 10 + value.parse::<i32>().unwrap());
    });
    app.run().unwrap();
}
```

main 関数ですが、今度はグローバルな `CalcLogic` に定義されている `button-pressed` コールバックに対応するハンドラ `on_button_pressed` の中で数字を増やす処理をするようにしました。

### 四則演算などの簡易実装

```rust:src/main.rs
use std::{cell::RefCell, rc::Rc};

slint::slint! {
    export global CalcLogic {
        callback button-pressed(string);
    }

    component Button inherits Rectangle {
        min-width: 30px;
        min-height: 30px;
        in property <string> text;
        background: ta.pressed ? red : ta.has-hover ? #2b6fb3 : #1d78d3;
        animate background { duration: 100ms; }
        border-radius: 4px;
        border-width: 2px;
        border-color: self.background.darker(20%);
        Text { text: root.text; }
        ta := TouchArea {
            clicked => { CalcLogic.button-pressed(root.text); }
        }
    }

    export component App {
        in property <int> value: 0;
        GridLayout {
            padding: 10px;
            spacing: 5px;
            Text { text: value; colspan: 4; }
            Row {
                Button { text: "1"; }
                Button { text: "2"; }
                Button { text: "3"; }
                Button { text: "+"; }
            }
            Row {
                Button { text: "4"; }
                Button { text: "5"; }
                Button { text: "6"; }
                Button { text: "-"; }
            }
            Row {
                Button { text: "7"; }
                Button { text: "8"; }
                Button { text: "9"; }
                Button { text: "*"; }
            }
            Row {
                Button { text: "C"; }
                Button { text: "0"; }
                Button { text: "="; }
                Button { text: "/"; }
            }
        }
    }
}

#[derive(Default)]
struct CalcState {
    prev_value: i32,
    current_value: i32,
    operator: slint::SharedString,
}

fn main() {
    let app = App::new().unwrap();
    let weak = app.as_weak();
    let state: Rc<RefCell<CalcState>> = Rc::new(RefCell::new(CalcState::default()));
    app.global::<CalcLogic>().on_button_pressed(move |value| {
        let app = weak.upgrade().unwrap();
        let mut state = state.borrow_mut();
        if let Ok(val) = value.parse::<i32>() {
            state.current_value *= 10;
            state.current_value += val;
            app.set_value(state.current_value);
            return;
        }
        match value.as_str() {
            "C" => {
                state.prev_value = 0;
                state.current_value = 0;
                state.operator = "".into();
                app.set_value(state.current_value);
            }
            "+" => {
                state.prev_value = state.current_value;
                state.current_value = 0;
                state.operator = "+".into();
            }
            "-" => {
                state.prev_value = state.current_value;
                state.current_value = 0;
                state.operator = "-".into();
            }
            "*" => {
                state.prev_value = state.current_value;
                state.current_value = 0;
                state.operator = "*".into();
            }
            "/" => {
                state.prev_value = state.current_value;
                state.current_value = 0;
                state.operator = "/".into();
            }
            "=" => {
                match state.operator.as_str() {
                    "+" => state.current_value = state.prev_value + state.current_value,
                    "-" => state.current_value = state.prev_value - state.current_value,
                    "*" => state.current_value = state.prev_value * state.current_value,
                    "/" => state.current_value = state.prev_value / state.current_value,
                    _ => {}
                }
                app.set_value(state.current_value);
            }
            _ => {}
        }
    });
    app.run().unwrap();
}
```

四則演算とクリア用のボタンを UI に追加し、雑で不完全ですが、大まかな実装を行いました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/13867/5145b2e9-4364-ee51-4aab-f1d96bf926e7.png)

## 終わりに

今回の記事では、クロスプラットフォームの GUI フレームワーク Slint の紹介と、Rust から使う方法を公式の Youtube のチュートリアルをベースに紹介しました。

興味がある方は、ぜひご覧ください。

https://www.youtube.com/watch?v=WBcv4V-whHk

## おまけ

今回作成した電卓のアプリを、Raspberry Pi Pico で、ベアメタルで動かしてみました。

液晶は [Raspberry Pi Pico用 2.8インチ タッチディスプレイ 320×240](https://ssci.to/7331) を利用しています。

一部コードの変更を行っていますが、こんなに簡単に Raspberry Pi Pico でも GUI の開発ができ、しかもサクサク動くんですね。素晴らしい！

ソースコードは以下にあげましたので、眺めてみてください。

https://github.com/task-jp/slint/tree/calc_mcu/examples/calc_mcu

![calc_mcu.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/13867/042f8aa9-b8ba-3958-69f0-721d5648b31e.gif)