Slint-python (Alpha) を使ってみる

# はじめに
　ついに[Slintアドベントカレンダー](https://qiita.com/advent-calendar/2024/slint)もはじまり二日目を迎えました。昨日は@task_jpさんの[「Slint で値によって単数形と複数形を使い分ける方法」](https://qiita.com/task_jp/items/72c8d78523f82ca0984c)でした。英語だと単数形と複数形でメッセージを分けなくてはならないので面倒ですが、ちゃんとそのあたりを考えられた機能が用意されているのですね。

　本日ですが、実はこっそりPyside6とSlint-Pythonで同じアプリを作って比較する記事をクロスポストでもしようかと思っていたのですが、幸いにもQt側のカレンダーが埋まったので急遽Slintのみの記事に書き換えました。QtはQtで後で投稿して比較記事を後ほど書こうかなということで。どうせ埋まらないだろうとか思ってごめんよQtカレンダー。記事書いてくださる人がいて良かった。

　なお、Slintって何っていう方のために簡単に解説すると、SlintはRustで実装されたGUIフレームワークです。軽量コンパクトが売りで、QMLのようにUI記述用の独自の宣言型言語を提供しています。より詳しくは、@task_jpさんの[「GUI フレームワーク Slint の紹介」](https://qiita.com/task_jp/items/5e76f66366673d46afcd)を読んでみてください。

## 本日のお題
　Slint 1.8.0でAlphaとして公開されたPython APIについてお試し記事です。まだアルファ版ですので、今後色々変わったり、動作上に何か問題があるかもしれませんが、ちょっと触って見ようかなという人の参考になればということで。

今回は、シンプルにアナログ時計を作って見ようかと思います。QtではQML用のdemoとして世界時計があります。ここから時計部分だけを抜き出してQMLとSlintを比較しようかなと準備していたので、そのままアナログ時計のSlint版だけ紹介しておきましょう。

![clocks.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/58828/ce96da57-19a9-05ea-a43b-a6728fc2bd33.png)


## 検証環境

検証環境はKUbuntu 24.04ですが、Ubuntu24.04でも同じ手順で試せるかと思います。

```bash:/etc/os-release
PRETTY_NAME="Ubuntu 24.04.1 LTS"
NAME="Ubuntu"
VERSION_ID="24.04"
VERSION="24.04.1 LTS (Noble Numbat)"
VERSION_CODENAME=noble
ID=ubuntu
ID_LIKE=debian
HOME_URL="https://www.ubuntu.com/"
SUPPORT_URL="https://help.ubuntu.com/"
BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/"
PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy"
UBUNTU_CODENAME=noble
LOGO=ubuntu-logo
```
# 環境構築

Slint-Python用モジュールはpipで提供されています。ですのでpipでインストールできます。今回はvenvを使って仮想環境にインストールしています。

```bash:
sudo apt install python3-venv
python3 -m venv work/.venv
source work/.venv/bin/activate
pip install slint
```

# アナログ時計の仕様

- ウィンドウサイズは320x240サイズで中心に時計を配置します
- 必要な画像はQt demosから拝借します
    - 時計盤面（文字板＋ケース）
     https://raw.githubusercontent.com/qt/qtdoc/refs/heads/dev/examples/demos/clocks/images/clock-night.png
![clock-night.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/58828/fa32d624-a747-1ba8-f9f9-a93d3250ea64.png)     

    - 短針（時針）
https://raw.githubusercontent.com/qt/qtdoc/refs/heads/dev/examples/demos/clocks/images/hour.png
![hour.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/58828/f515c8fd-89a7-c98a-6737-eae139f2c584.png)

    - 長針（分針）
https://raw.githubusercontent.com/qt/qtdoc/refs/heads/dev/examples/demos/clocks/images/minute.png
![minute.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/58828/50f81136-7b78-1927-f388-7b3c8ce68384.png)

    - 秒針
https://raw.githubusercontent.com/qt/qtdoc/refs/heads/dev/examples/demos/clocks/images/second.png    
![second.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/58828/223d2684-70bd-f563-282f-03fe294c20d5.png)

    - キャップ（普通は秒針についているものですが）
https://raw.githubusercontent.com/qt/qtdoc/refs/heads/dev/examples/demos/clocks/images/center.png
![center.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/58828/d31486b1-55bf-7424-582b-3c5a0890bbbe.png)

- 一定周期毎に時刻を取得し、時刻に応じて針画像を回転させることで時刻を示します

## 実装準備
```bash:
mkdir -p ~/work/slint/ui ; cd $_
wget https://raw.githubusercontent.com/qt/qtdoc/refs/heads/dev/examples/demos/clocks/images/clock-night.png
wget https://raw.githubusercontent.com/qt/qtdoc/refs/heads/dev/examples/demos/clocks/images/hour.png
wget https://raw.githubusercontent.com/qt/qtdoc/refs/heads/dev/examples/demos/clocks/images/minute.png
wget https://raw.githubusercontent.com/qt/qtdoc/refs/heads/dev/examples/demos/clocks/images/second.png
wget https://raw.githubusercontent.com/qt/qtdoc/refs/heads/dev/examples/demos/clocks/images/center.png
```
# 実装してみよう
## slint側UI定義
```qml:~/work/slint/ui/clock.slint
export component Clock inherits Window {
    width:320px;
    height:240px;
    background: #646464;
    in property<int> hours;
    in property<int> minutes;
    in property<int> seconds;
    callback timeChanged();
    Timer {
        interval: 100ms;
        running: true;
        triggered() => { timeChanged(); }
    }
    Image {
        source: @image-url("clock-night.png");
        Image {
            x : parent.width /2 - self.width/2;
            y : parent.height/2 - self.height + 18px;
            source: @image-url("hour.png");
            rotation-origin-x: parent.width/2  - self.x;
            rotation-origin-y: parent.height/2 - self.y;
            rotation-angle: (root.hours * 30deg) + (root.minutes * 0.4deg);
            animate rotation-angle {
                duration: mod(root.hours * 30deg + root.minutes * 0.4deg, 360deg) > 0deg ? 250ms : 0ms;
                easing : ease-in-out;
            }
        }
        Image {
            x : parent.width /2 - self.width/2;
            y : parent.height/2 - self.height + 18px;
            source: @image-url("minute.png");
            rotation-origin-x: parent.width/2  - self.x;
            rotation-origin-y: parent.height/2 - self.y;
            rotation-angle: root.minutes * 6deg;
            animate rotation-angle {
                duration: root.minutes > 0?  250ms : 0ms;
                easing : ease-in-out;
            }
        }
        Image {
            x : parent.width /2 - self.width/2;
            y : parent.height/2 - self.height + 18px;
            source: @image-url("second.png");
            rotation-origin-x: parent.width/2  - self.x;
            rotation-origin-y: parent.height/2 - self.y;
            rotation-angle: root.seconds * 6deg;
            animate rotation-angle {
                duration: root.seconds > 0?  250ms : 0ms;
                easing : ease-in-out;
            }
        }
        Image {
            x : parent.width /2 - self.width/2;
            y : parent.height/2 - self.height/2;
            source: @image-url("center.png");
        }
    }
}
```
## Python側ロジック
```python:~/work/slint/clock.py
import slint
import datetime

class App(slint.load_file("ui/clock.slint").Clock):
    @slint.callback
    def timeChanged(self):
        dt = datetime.datetime.now()
        self.hours   = dt.hour
        self.minutes = dt.minute
        self.seconds = dt.second

app = App()
app.run()
```

## 起動
```bash:
cd ~/work/slint
python3 clock.py
```
![clock.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/58828/083f2a6e-1fa1-77cf-ca2f-9b25fcdae4aa.png)

# ざっくり解説 
## Slintコードについて
　Slintは一般的なGUIフレームワークと同じ用に左上(0,0)、右下(width,height)とする座標系になっています。宣言型言語で親の中に子を入れることができます。今回は画像を５つ使うだけのシンプルなものですので、利用しているのは外枠のWindowとImageエレメント、更新のためのTimerエレメントだけになります。

　文字版Imageを親としてその中に、短針、長針、秒針、キャップを兄弟として配置しています。Z軸を指定することもできますが、通常は定義順に重ねられるので省略しています。
　配置としては、初期状態は00:00:00で、回転はなしとなります。X方向では画像の中心となるように、Y方向は針の端が中心に来るように調整するのですが、画像サイズからそのままだと針が文字板を超えてフレーム枠に乗ってしまいます。

　QMLのデモでは針が盤面の枠を超えて配置されていますが、長針と秒針が目盛りの上に乗るように、短針はその内側で目盛りを隠さないように配置するほうがアナログ時計としては一般的と思うので、針画像のY座標はそれぞれ18pxほどずらして配置しています。
 
 アナログ時計は時刻を360度の円で表します。小学校の授業を思い出してみましょう。
 - 時針は12時間で360度、１時間で30度動きます。さらに60分で30度移動なので１分で0.5度回転することになります
 - 分針は60分で360度回転するので、1分で6度回転することになります
 - 秒針は60秒で360度回転するので、1秒で6度回転することになります

　ただし、短針の動作をそのまま実装すると、11時59分時点で短針が先に12時を指して見えてしまい格好がつかないので、あえて0.4を掛けるようにして、11:59に長針と短針が重なるように微調整しています。

　時刻更新は100ms周期として、最近導入されたTimer Elementを使ってslint側でタイマーを実装しています。ただしQMLと異なりEcmascriptが使えるわけではないため、時刻取得などをSlint側では扱えません。そのあたりのコードはコールバックを定義しPythonスクリプト側で行う事となります。

　また100ms周期とはいえ、時刻の１秒で一気に６度移動するのは見た目上とんで見えるため回転にアニメーションをつけています。ただし何も考えないと０へ戻る時に反転して359度から0度にむけて逆回転のアニメーションをしてしまいます。格好が悪いですが、回転が０度に至る瞬間はアニメーション時間を0msとしてアニメーションしないようにしています。

## Pythonコードについて
　Python側はslintのload_fileをして実装されたClockを継承する形で実装をしています。QMLとは異なりSlint側はほぼUI定義しかできません。そのため時刻取得などのロジックはPython側で用意することになりますが、callbackであると定義して簡単に連携できるようになっています。

　まぁ、アナログ時計程度だと、時刻を取得してSlint側のプロパティを更新する程度しかやることがありません。プロパティを更新すると勝手にバインドされている針画像の角度が変更され、角度が変更されると設定されたアニメーションに沿って画像が回転します。

# まとめ
　ちょっと当初の目論見と違ってSlint実装についてだけになったので中身の薄い記事となってしまいましたが、Slint-Pythonで簡単にアナログ時計を表示してみせる実装をご紹介しました。次回はPyside6と比較してSlintのメリット・デメリットを検討できたら良いなぁと思っています。
 
　明日は、@task_jpさんが何か書いてくださるそうです。