Slint で車載用のメーターを作ってみました

# はじめに

この記事は [Slint Advent Calendar 2024](https://qiita.com/advent-calendar/2024/slint) 16日目の記事になります。

昨日は @hermit4 さんによる [RustでUI - Slint開発環境構築](https://qiita.com/hermit4/items/14ccc819b3a9da677104) という記事でした。

Rust で GUI を作る人が増えるといいですね。私もあまり真面目にバックエンドを書いていないので、なにかちゃんとしたものを作ってみようとおもいます。

---

今日は、 Slint で自動車の速度メーターを作ったものを記事にしてみました。

https://x.com/task_jp/status/1850009819190866264/video/1

# AGL のメーターのデモ UI

AGL のソースコードの中には車載メーターのデモがいくつかあるのですが、一番シンプルな以下のデモを Slint に移植してみました。

https://gitlab.com/automotivegradelinux/apps/agl-cluster-demo-dashboard

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/13867/9e3d5f1c-fed6-0e74-30af-0dafeee0defc.png)

このソースコードは見たら直したくなるのであまり見ないようにするとして、このメーターは実は Qt 5 で動いていて、しかも今は亡き [Qt Quick Controls 1](https://doc.qt.io/qt-5/qtquickcontrols1-index.html) のメーターを描くための機能を利用して作られています。

https://doc.qt.io/qt-5/qml-qtquick-extras-circulargauge.html

https://doc.qt.io/qt-5/qml-qtquick-controls-styles-circulargaugestyle.html

このスタイルを用いて、パラメーターを上手に設定することで、左右のメーターと、右下の２つの半円のメーターが実装されています。


# Slint でメーターをどう書くのか

背景画像と針画像の組み合わせで実現するのが簡単な気がするのですが、Qt 版を見習って、共通の API で頑張って描画するような作りにしてみました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/13867/c2e4e68a-095e-10ce-d533-fcd3750c1498.png) ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/13867/4083ebea-890d-dc88-fcf8-a1b4bfd44f8b.png)

上記の画像をじっくり見ながら、必要な要素を構造化していきます。

## 目盛

角度とラベル（数値）、それから色が赤くなっているところがあるので、色の情報をもつ構造体にします。

```qml:CircularGauge.slint
export struct TickMark {
    angle: angle,
    label: string,
    color: color,
}
```

## 補助目盛

角度だけでなんとかなりそうです。

```qml:CircularGauge.slint
export struct SubTickMark {
    angle: angle,
}
```

## 警告エリア

目盛と目盛の間が赤い帯のように塗りつぶされている部分です。
最初と最後の角度と、色をもつ構造体を定義します。

```qml:CircularGauge.slint
export struct WarningCircumference {
    from: float,
    to: float,
    color: color,
}
```

## 全体的な設定

目盛の角度の範囲と、最小値、最大値、目盛の間隔、補助目盛の間隔、ラベルを何目盛ごとに描くか、ラベルを円の内側に描くか外側に描くか、目盛の配列、補助目盛の配列、警告範囲、それから、メーターに表示する単位などを構造体にしました。

これらを適切に設定することで、画像の４つのパターンにすべて対応することができます。

```qml:CircularGauge.slint
export struct CircularGaugeStyle {
    min-angle: angle,
    max-angle: angle,
    min-value: int,
    max-value: int,
    tick-interval: int,
    sub-tick-interval: int,
    label-interval: int,
    label-position-outside: bool,
    tick-marks: [TickMark],
    sub-tick-marks: [SubTickMark],
    warning-circumferences: WarningCircumference,
    unit: string,
}
```

## メーターの描画部分

様々なパラメーターに対応するために結構面倒くさいコードになってしまいました。とても長いので、少しずつ紹介していきます。

```qml:CircularGauge.slint
export component CircularGauge {
    in property <length> r: self.width > self.height ? self.width / 2 : self.height / 2;
    width: 300px;
    height: 300px;
```

上記で、メーターのコンポーネントの宣言と、縦横の大きさ、それから求められる半径を計算しています。

```qml:CircularGauge.slint
    in-out property <CircularGaugeStyle> style;
    in-out property <float> value: 0;
```

スタイルと現在の値のプロパティを定義しています。

```qml:CircularGauge.slint
    Rectangle {
        width: 100%;
        height: 100%;
        clip: self.width != self.height;
```

次に、半円の場合に子要素の描画がはみ出さないようにクリッピングする機能がきます。

```qml:CircularGauge.slint
        border := Rectangle {
            y: 0;
            width: 100%;
            height: self.width;
            background: transparent;
            border-radius: self.width / 2;
            border-width: self.width * 1%;
            border-color: #222;
        }
```

その次は、メーターの縁の部分を Rectangle で円形に表示します。

```qml:CircularGauge.slint
        Rectangle {
            y: (root.width - self.width) / 2;
            width: 96%;
            height: self.width;
            background: #222;
            border-radius: self.width / 2;
        }

        Rectangle {
            y: (root.width - self.width) / 2;
            width: 96%;
            height: self.width;
            background: @radial-gradient(circle, #FFFFFF00 0%, #FFFFFF02 70%, #FFFFFF 100%);
            border-radius: self.width / 2;
            opacity: 0.1;
        }
```

円全体を黒っぽい色で塗りつぶした後で、うっすら白いグラデーションをかけています。

```qml:CircularGauge.slint
        VerticalLayout {
            y: root.width * 3 / 5;
            spacing: root.width / 30;
            Text {
                text: floor(root.value);
                height: self.font-size;
                horizontal-alignment: TextHorizontalAlignment.center;
                vertical-alignment: TextVerticalAlignment.center;
                color: white;
                font-size: root.r / 3;
            }
            Text {
                text: root.style.unit;
                height: self.font-size;
                horizontal-alignment: TextHorizontalAlignment.center;
                vertical-alignment: TextVerticalAlignment.center;
                color: white;
                font-size: root.r / 8;
            }
        }
    }
```

メーターの値と単位を表示するための処理になります。

ここまでが、背景と現在の値の表示です。


```qml:CircularGauge.slint
    if root.style.warning-circumferences.color != Colors.transparent : Path {
        y: (root.width - self.width) / 2;
        width: 96%;
        height: self.width;
        MoveTo {
            x: -100;
            y: -100;
        }

        MoveTo {
            x: +100;
            y: +100;
        }

        MoveTo {
            x: 100 * cos((root.style.warning-circumferences.to - 0.1) * (root.style.max-angle - root.style.min-angle) / (root.style.max-value - root.style.min-value) + root.style.min-angle - 90deg);
            y: 100 * sin((root.style.warning-circumferences.to - 0.1) * (root.style.max-angle - root.style.min-angle) / (root.style.max-value - root.style.min-value) + root.style.min-angle - 90deg);
        }

        ArcTo {
            x: 100 * cos((root.style.warning-circumferences.from + 0.15) * (root.style.max-angle - root.style.min-angle) / (root.style.max-value - root.style.min-value) + root.style.min-angle - 90deg);
            y: 100 * sin((root.style.warning-circumferences.from + 0.15) * (root.style.max-angle - root.style.min-angle) / (root.style.max-value - root.style.min-value) + root.style.min-angle - 90deg);
            radius-x: 100;
            radius-y: 100;
        }
        stroke: root.style.warning-circumferences.color;
        stroke-width: self.width / 30;
        opacity: 0.5;
    }
```

警告範囲がある場合には [Path](https://docs.slint.dev/latest/docs/slint/src/language/builtins/elements#path) を利用して弧を描いています。

```qml:CircularGauge.slint
    for data in root.style.tick-marks: Path {
        y: (root.width - self.width) / 2;
        width: 96%;
        height: self.width;
        function calc-x(x: float) -> float {
            return (100 + x) * cos(data.angle - 90deg);
        }
        function calc-y(y: float) -> float {
            return (100 + y) * sin(data.angle - 90deg);
        }
        MoveTo {
            x: -100;
            y: -100;
        }

        MoveTo {
            x: +100;
            y: +100;
        }

        MoveTo {
            x: calc-x(  0);
            y: calc-y(  0);
        }

        LineTo {
            x: calc-x(-5);
            y: calc-y(-5);
        }

        stroke: data.color == transparent ? white : data.color;
        stroke-width: root.width / 100;
    }
```

次に目盛の実装です。目盛１つを Path １つで描画しているため、とても無駄が多い実装になっています。Path の中で１目盛を描くための複数の描画命令をループさせられるといいのですが、いい方法が見つかりませんでした。

```qml:CircularGauge.slint
    for data in root.style.tick-marks: Text {
        text: data.label;
        property <float> distance: root.style.label-position-outside ? 1.2 : 0.77;
        x: root.r * self.distance * cos(data.angle - 90deg) + root.r - self.font-size * 2;
        y: root.r * self.distance * sin(data.angle - 90deg) + root.r - self.font-size;
        width: self.font-size * 4;
        height: self.font-size * 2;
        horizontal-alignment: TextHorizontalAlignment.center;
        vertical-alignment: TextVerticalAlignment.center;
        color: data.color == transparent ? white : data.color;
        font-size: root.r / (root.style.label-position-outside ? 5 : 10);
    }
```

目盛のラベルを実装しています。

```qml:CircularGauge.slint
    for data in root.style.sub-tick-marks: Path {
        y: (root.width - self.width) / 2;
        width: 96%;
        height: self.width;
        function calc-x(x: float) -> float {
            return (100 + x) * cos(data.angle - 90deg);
        }
        function calc-y(y: float) -> float {
            return (100 + y) * sin(data.angle - 90deg);
        }
        MoveTo {
            x: -100;
            y: -100;
        }

        MoveTo {
            x: +100;
            y: +100;
        }

        MoveTo {
            x: calc-x(  0);
            y: calc-y(  0);
        }

        LineTo {
            x: calc-x(-3);
            y: calc-y(-3);
        }

        stroke: white;
        stroke-width: root.width / 200;
    }
```

目盛と同様に補助目盛を描画しています。

```qml:CircularGauge.slint
    @children
```

[Container Components](https://docs.slint.dev/latest/docs/slint/src/language/concepts/container) に説明がありますが、このコンポーネントを利用する側で子要素を追加した時にはここの位置に追加されるようにしています。

```qml:CircularGauge.slint
    needle := Rectangle {
        y: (root.width - self.width) / 2;
        width: 96%;
        height: self.width;

        property <angle> angle: (root.style.max-angle - root.style.min-angle) * (root.value - root.style.min-value) / (root.style.max-value - root.style.min-value) + root.style.min-angle;

        Path {
            MoveTo {
                x: -100;
                y: -100;
            }

            MoveTo {
                x: +100;
                y: +100;
            }

            // draw needle in diamond shape
            MoveTo {
                x: -10 * cos(needle.angle - 90deg);
                y: -10 * sin(needle.angle - 90deg);
            }

            LineTo {
                x: 100 * cos(needle.angle - 90deg);
                y: 100 * sin(needle.angle - 90deg);
            }

            LineTo {
                x: 5 * sin(needle.angle - 90deg);
                y: -5 * cos(needle.angle - 90deg);
            }

            LineTo {
                x: -10 * cos(needle.angle - 90deg);
                y: -10 * sin(needle.angle - 90deg);
            }

            fill: #FF000066;
        }

        Path {
            MoveTo {
                x: -100;
                y: -100;
            }

            MoveTo {
                x: +100;
                y: +100;
            }

            // draw needle in diamond shape
            MoveTo {
                x: -10 * cos(needle.angle - 90deg);
                y: -10 * sin(needle.angle - 90deg);
            }

            LineTo {
                x: 100 * cos(needle.angle - 90deg);
                y: 100 * sin(needle.angle - 90deg);
            }

            LineTo {
                x: -5 * sin(needle.angle - 90deg);
                y: 5 * cos(needle.angle - 90deg);
            }

            LineTo {
                x: -10 * cos(needle.angle - 90deg);
                y: -10 * sin(needle.angle - 90deg);
            }

            fill: #FF0000CC;
        }
    }
```

針用のエリアを定義し、その中心から現在の角度に向けて Path で針を描画しています。

```qml:CircularGauge.slint
    ta := TouchArea {}
    states [
        active when ta.pressed: {
            root.value: root.style.max-value;
            in {
                animate root.value { duration: 1000ms; }
            }
            out {
                animate root.value { duration: 800ms; }
            }
        }
    ]
```

タッチされた際に、値を最大値に設定し、そこまで１秒間でアニメーションするようにしています。


# 個々のスタイルの設定

では、上記で作成したメーターに、実際のパラメーターをどう設定しているのかも見てみましょう。

## 速度計

```qml:app-window.slint
accelerometer := CircularGauge {
    width: 25%;
    height: self.width;
    style: {
        min-angle: -145deg,
        max-angle: +145deg,
        min-value: 0,
        max-value: 220,
        tick-interval: 10,
        sub-tick-interval: 2,
        label-interval: 20,
        label-position-outside: false,
        unit: "km/h",
    };
}
```

- 中心から真上を境に±145度の角度になるように設定
- 速度の最小値は0、最大値は220
- 目盛は10ごとに描く
- サブ目盛は2ごとに描く
- 目盛の数値は20ごとに描く
- ラベルは円の内側に描く
- 単位は "km/h" とする

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/13867/c2e4e68a-095e-10ce-d533-fcd3750c1498.png)

これにより、上記のメーターが描画されます。

## タコメーター

```qml:app-window.slint
tachometer := CircularGauge {
    width: 25%;
    height: self.width;
    style: {
        min-angle: -145deg,
        max-angle: +145deg,
        min-value: 0,
        max-value: 8,
        tick-interval: 1,
        label-interval: 1,
        label-position-outside: false,
        warning-circumferences: {
            from: 7,
            to: 8,
            color: Colors.red,
        },
        unit: "RPM",
    };
    
    Text {
        y: parent.height * 1 / 5;
        text: "x1000";
        height: self.font-size;
        horizontal-alignment: TextHorizontalAlignment.center;
        vertical-alignment: TextVerticalAlignment.center;
        color: white;
        font-size: parent.r / 10;
    }
    ...
}
```

速度計と大きく異なるのは、補助目盛がないことと、警告エリアがあることですね。
上部の `x1000` は別途 `Text` で表示しています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/13867/4083ebea-890d-dc88-fcf8-a1b4bfd44f8b.png)

## 燃料計

```qml:app-window.slint
fuel-gauge := CircularGauge {
    x: -self.width * 0.3;
    y: parent.width * 0.85;
    z: 1;
    width: 30%;
    height: 15%;
    style: {
        min-angle: -60deg,
        max-angle: +60deg,
        min-value: 0,
        max-value: 4,
        tick-interval: 4,
        sub-tick-interval: 1,
        label-interval: 0,
        label-position-outside: true,
        warning-circumferences: {
            from: 0,
            to: 1,
            color: Colors.red,
        },
        unit: "L",
    };
    Image {
        y: parent.height * 2 / 5;
        width: 15%;
        height: self.width;
        image-fit: contain;
        source: @image-url("./images/fuel-icon.png");
    }
}
```

高さを幅の半分にすることで半円にしています。それに伴い角度も -60度から+60度になっています。
ラベルは円の外側に描くようにしています。燃料を表す画像は別途 `Image` で描いています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/13867/6378658b-7f4e-a5ac-b83c-ac0c7c23eade.png)

## 温度計

```qml:app-window.slint
temp-gauge := CircularGauge {
    x: parent.width - self.width * 0.7;
    y: parent.width * 0.85;
    z: 1;
    width: 30%;
    height: 15%;
    style: {
        min-angle: -60deg,
        max-angle: +60deg,
        min-value: 0,
        max-value: 4,
        tick-interval: 4,
        sub-tick-interval: 1,
        label-interval: 0,
        label-position-outside: true,
        warning-circumferences: {
            from: 3,
            to: 4,
            color: Colors.red,
        },
        unit: "°C",
    };
    Image {
        y: parent.height * 2 / 5;
        width: 15%;
        height: self.width;
        image-fit: contain;
        source: @image-url("./images/temperature-icon.png");
    }
}
```

燃料計と似た設定で以下の表示を実現しています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/13867/a73d85af-d9e2-8dce-25e5-de3ec7263114.png)


# 目盛と補助目盛の配列

上記の設定を元に、目盛と補助目盛の配列を作るのですが、.slint 言語では関数は定義できるのですが高度なロジックを書くことはできません。

https://docs.slint.dev/latest/docs/slint/src/language/syntax/expressions

このため、その処理はバックエンド側でやってあげる必要があります。

Rust の場合は以下のような感じになります。

```rust:main.rs
fn setup_gauge(style: &mut CircularGaugeStyle, warning_min: Option<i32>, labels: Option<Vec<&str>>) {
    let mut tick_marks = Vec::new();
    for i in (style.min_value..=style.max_value).step_by(style.tick_interval as usize) {
        let angle = (i - style.min_value) as f32 / (style.max_value as f32 - style.min_value as f32) * (style.max_angle - style.min_angle) + style.min_angle;
        let label = match labels {
            Some(ref l) => if i < l.len() as i32 { SharedString::from(l[i as usize]) } else { SharedString::new() },
            None => if style.label_interval == 0 {
                 SharedString::new()
                 } else {
                     match i % style.label_interval {
                        0 => SharedString::from(format!("{}", i)),
                        _ => SharedString::new(),
                    }
                }
        };
        let color = match warning_min {
            Some(warning_min) => if i >= warning_min { Color::from_argb_f32(1.0, 1.0, 0.0, 0.0) } else { Color::from_argb_f32(1.0, 1.0, 1.0, 1.0) },
            None => Color::from_argb_f32(1.0, 1.0, 1.0, 1.0),
        };
        tick_marks.push(TickMark {
            angle,
            label,
            color,
        });
    }
    style.tick_marks = ModelRc::from(Rc::new(VecModel::from(tick_marks)).clone());

    let mut sub_tick_marks = Vec::new();
    if style.sub_tick_interval > 0 {
        for i in (style.min_value..=style.max_value).step_by(style.sub_tick_interval as usize) {
            let angle = (i - style.min_value) as f32 / (style.max_value as f32 - style.min_value as f32) * (style.max_angle - style.min_angle) + style.min_angle;
            sub_tick_marks.push(SubTickMark { angle: angle });
        }
    }
    style.sub_tick_marks = ModelRc::from(Rc::new(VecModel::from(sub_tick_marks)).clone());
}
```

定義されたスタイルの情報を元に、計算を行い、`TickMark` や `SubTickMark` を生成して、スタイルのプロパティに設定しています。

目盛の色を変えるための `warning_min` があったりします。

目盛のラベルは、基本的には数値なのですが、E(mpty)、F(ull)、C(ool)、H(ot) や、表示なしのケースもあるので、`labels` 引数でカスタマイズ可能にしています。

実際には以下のように呼び出しています。

```rust:main.rs
let mut accelerometer_style = ui.get_accelerometer_style();
setup_gauge(&mut accelerometer_style, None, None);
ui.set_accelerometer_style(accelerometer_style);

let mut tachometer_style = ui.get_tachometer_style();
setup_gauge(&mut tachometer_style, Some(7), None);
ui.set_tachometer_style(tachometer_style);

let mut fuel_gauge_style = ui.get_fuel_gauge_style();
setup_gauge(&mut fuel_gauge_style, None, Some(vec!["E", "", "", "", "F"]));
ui.set_fuel_gauge_style(fuel_gauge_style);

let mut temp_gauge_style = ui.get_temp_gauge_style();
setup_gauge(&mut temp_gauge_style, None, Some(vec!["C", "", "", "", "H"]));
ui.set_temp_gauge_style(temp_gauge_style);
```

スタイル定義とロジックだけでは完全には実現できずに、ロジックを呼び出す側で多少ごまかしている感じです。時間があったら改善したいです。

# おわりに

Qt 5 の Qt Quick Controls 1 の、[CircularGuage](https://doc.qt.io/qt-5/qml-qtquick-extras-circulargauge.html) 相当のエレメントを .slint で自作して、AGL のシンプルなメーターを Slint に移植してみました。

- .slint では高度なロジックは書けないので、バックエンドでも処理をする必要があった
- １つの `Path` の中に、複数の要素をループして描く方法がわからず（ない？）大量の `Path` を生成して頑張って描いた
- そもそも、Qt でも Qt Quick Controls 2 では CircularGuage はなくなっているので、標準の汎用の部品として実装するのは無理があったかもしれない

みたいなことが分かりましたが、見た目や動きはとてもいいものが作れました。

14日目に書いた [Linux で Slint にカメラの映像を表示する](https://qiita.com/task_jp/items/685beaa68de5ba8b9b16) と今回のメーターを組み合わせたデモのソースコードを github 上で公開しましたので、試したい方がいましたら是非試してみてください。

フィードバックも歓迎です。

https://github.com/tasuku-suzuki-signalslot/slint-cluster-demo

これ以外にも、いろいろなデモのUIを Slint に移植して遊んでみたいと思っています。

明日は @hermit4 さんによる [Slint言語入門ービルトイン機能](https://qiita.com/hermit4/items/943216af708f9438e532) です。お楽しみに！