はじめに
この記事は Slint Advent Calendar 2024 16日目の記事になります。
昨日は @hermit4 さんによる RustでUI - Slint開発環境構築 という記事でした。
Rust で GUI を作る人が増えるといいですね。私もあまり真面目にバックエンドを書いていないので、なにかちゃんとしたものを作ってみようとおもいます。
今日は、 Slint で自動車の速度メーターを作ったものを記事にしてみました。
AGL のメーターのデモ UI
AGL のソースコードの中には車載メーターのデモがいくつかあるのですが、一番シンプルな以下のデモを Slint に移植してみました。
このソースコードは見たら直したくなるのであまり見ないようにするとして、このメーターは実は Qt 5 で動いていて、しかも今は亡き Qt Quick Controls 1 のメーターを描くための機能を利用して作られています。
このスタイルを用いて、パラメーターを上手に設定することで、左右のメーターと、右下の2つの半円のメーターが実装されています。
Slint でメーターをどう書くのか
背景画像と針画像の組み合わせで実現するのが簡単な気がするのですが、Qt 版を見習って、共通の API で頑張って描画するような作りにしてみました。
上記の画像をじっくり見ながら、必要な要素を構造化していきます。
目盛
角度とラベル(数値)、それから色が赤くなっているところがあるので、色の情報をもつ構造体にします。
export struct TickMark {
angle: angle,
label: string,
color: color,
}
補助目盛
角度だけでなんとかなりそうです。
export struct SubTickMark {
angle: angle,
}
警告エリア
目盛と目盛の間が赤い帯のように塗りつぶされている部分です。
最初と最後の角度と、色をもつ構造体を定義します。
export struct WarningCircumference {
from: float,
to: float,
color: color,
}
全体的な設定
目盛の角度の範囲と、最小値、最大値、目盛の間隔、補助目盛の間隔、ラベルを何目盛ごとに描くか、ラベルを円の内側に描くか外側に描くか、目盛の配列、補助目盛の配列、警告範囲、それから、メーターに表示する単位などを構造体にしました。
これらを適切に設定することで、画像の4つのパターンにすべて対応することができます。
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,
}
メーターの描画部分
様々なパラメーターに対応するために結構面倒くさいコードになってしまいました。とても長いので、少しずつ紹介していきます。
export component CircularGauge {
in property <length> r: self.width > self.height ? self.width / 2 : self.height / 2;
width: 300px;
height: 300px;
上記で、メーターのコンポーネントの宣言と、縦横の大きさ、それから求められる半径を計算しています。
in-out property <CircularGaugeStyle> style;
in-out property <float> value: 0;
スタイルと現在の値のプロパティを定義しています。
Rectangle {
width: 100%;
height: 100%;
clip: self.width != self.height;
次に、半円の場合に子要素の描画がはみ出さないようにクリッピングする機能がきます。
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 で円形に表示します。
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;
}
円全体を黒っぽい色で塗りつぶした後で、うっすら白いグラデーションをかけています。
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;
}
}
}
メーターの値と単位を表示するための処理になります。
ここまでが、背景と現在の値の表示です。
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 を利用して弧を描いています。
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;
}
次に目盛の実装です。目盛1つを Path 1つで描画しているため、とても無駄が多い実装になっています。Path の中で1目盛を描くための複数の描画命令をループさせられるといいのですが、いい方法が見つかりませんでした。
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);
}
目盛のラベルを実装しています。
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;
}
目盛と同様に補助目盛を描画しています。
@children
Container Components に説明がありますが、このコンポーネントを利用する側で子要素を追加した時にはここの位置に追加されるようにしています。
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 で針を描画しています。
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; }
}
}
]
タッチされた際に、値を最大値に設定し、そこまで1秒間でアニメーションするようにしています。
個々のスタイルの設定
では、上記で作成したメーターに、実際のパラメーターをどう設定しているのかも見てみましょう。
速度計
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" とする
これにより、上記のメーターが描画されます。
タコメーター
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
で表示しています。
燃料計
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
で描いています。
温度計
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");
}
}
燃料計と似た設定で以下の表示を実現しています。
目盛と補助目盛の配列
上記の設定を元に、目盛と補助目盛の配列を作るのですが、.slint 言語では関数は定義できるのですが高度なロジックを書くことはできません。
このため、その処理はバックエンド側でやってあげる必要があります。
Rust の場合は以下のような感じになります。
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
引数でカスタマイズ可能にしています。
実際には以下のように呼び出しています。
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 相当のエレメントを .slint で自作して、AGL のシンプルなメーターを Slint に移植してみました。
- .slint では高度なロジックは書けないので、バックエンドでも処理をする必要があった
- 1つの
Path
の中に、複数の要素をループして描く方法がわからず(ない?)大量のPath
を生成して頑張って描いた - そもそも、Qt でも Qt Quick Controls 2 では CircularGuage はなくなっているので、標準の汎用の部品として実装するのは無理があったかもしれない
みたいなことが分かりましたが、見た目や動きはとてもいいものが作れました。
14日目に書いた Linux で Slint にカメラの映像を表示する と今回のメーターを組み合わせたデモのソースコードを github 上で公開しましたので、試したい方がいましたら是非試してみてください。
フィードバックも歓迎です。
これ以外にも、いろいろなデモのUIを Slint に移植して遊んでみたいと思っています。
明日は @hermit4 さんです。お楽しみに!