Pyside6でアナログ時計を作ってみる

# はじめに
　Qtアドベントカレンダーも４日目に突入。今の所毎日記事が入っているので嬉しいですね。
　昨日は、@task_jpさんによる[「Toradex Verdin AM62 Solo で meta-qt6 を動かしてみた」](https://qiita.com/task_jp/items/bfc8594bf6eb022f05d8)でした。ET+2024は僕も展示をお手伝いさせていただきました。meta-qt6は別件でも利用していますが、yoctoにしては、はまったり、困ったりすることが少なく、ビルド時間以外はとても重宝します。まぁ、下回りの実装が半端なママ、自分で頑張れって雰囲気になっていますが、そこらへんは組込みあるあるですしね。yoctoでQt6に挑まれるかたは見てみると良いかも。

## 本日のお題について
　先日、[Slintアドベントカレンダー](https://qiita.com/advent-calendar/2024/slint)の方で[Slint-Pythonを使ったアナログ時計の実装](https://qiita.com/hermit4/items/1a2173772e558212c3a4)を紹介しました。そこで、Pyside6とSlintで比較できるようにPyside6+QMLでも同じアナログ時計を実装したいと思います。Slintの記事と重複するけど、Slintには興味が無いやという人のためにこちらの記事だけでも読めるように記載しておきます。

　QtではQML用のdemoとして世界時計があります。ここからアナログ時計部分だけを抜き出して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 pyside6
```

# アナログ時計の仕様

- ウィンドウサイズは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)

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

# 実装してみよう
## QML側UI定義
```qml:~/work/qml/ui/clock.qml
import QtQuick
import QtQuick.Controls

ApplicationWindow {
    id : clock
    visible: true
    width: 320
    height: 240
    color: "#646464"
    property int hours
    property int minutes
    property int seconds

    function timeChanged() {
        var date = new Date;
        hours = date.getHours()
        minutes = date.getMinutes()
        seconds = date.getUTCSeconds();
    }

    Timer {
        interval: 100; running: true; repeat: true;
        onTriggered: clock.timeChanged()
    }

    Image {
        id: base
        anchors.centerIn: parent
        source : "https://raw.githubusercontent.com/qt/qtdoc/refs/heads/dev/examples/demos/clocks/images/clock-night.png"
        Image {
            id: hour
            x: base.width/2 - width/2
            y: base.height/2 - height + 18
            source: "https://raw.githubusercontent.com/qt/qtdoc/refs/heads/dev/examples/demos/clocks/images/hour.png"
            transform: Rotation {
                id: hourRotation
                origin.x: hour.width/2
                origin.y: hour.height - 18
                angle: (clock.hours * 30) + (clock.minutes * 0.5)
                Behavior on angle {
                    SpringAnimation { spring: 2; damping: 0.2; modulus: 360 }
                }
            }
        }
        Image {
            id: minute
            x: parent.width/2 - width/2
            y: parent.height/2 - height + 18
            source: "https://raw.githubusercontent.com/qt/qtdoc/refs/heads/dev/examples/demos/clocks/images/minute.png"
            transform: Rotation {
                id: minuteRotation
                origin.x: minute.width/2
                origin.y: minute.height - 18
                angle: clock.minutes * 6
                Behavior on angle {
                    SpringAnimation { spring: 2; damping: 0.2; modulus: 360 }
                }
            }
        }
        Image {
            id: second
            x: parent.width/2 - width/2
            y: parent.height/2 - height + 18
            source: "https://raw.githubusercontent.com/qt/qtdoc/refs/heads/dev/examples/demos/clocks/images/second.png"
            transform: Rotation {
                id: secondRotation
                origin.x: second.width/2
                origin.y: second.height - 18
                angle: clock.seconds * 6
                Behavior on angle {
                    SpringAnimation { spring: 2; damping: 0.2; modulus: 360 }
                }
            }
        }
        Image {
            anchors.centerIn: base; source: "https://raw.githubusercontent.com/qt/qtdoc/refs/heads/dev/examples/demos/clocks/images/center.png"
        }
    }
}
```

## Python側ロジック
```python: ~/work/qml/clock.py
from PySide6.QtWidgets import QApplication
from PySide6.QtQml import QQmlApplicationEngine
import sys

if __name__ == "__main__":
    app = QApplication(sys.argv)
    engine = QQmlApplicationEngine()
    engine.load("ui/clock.qml")

    if not engine.rootObjects():
        sys.exit(-1)

    sys.exit(app.exec())
```

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

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

　Qtデモそのままではなく、少し親子関係や配置について調整します。

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

　Qt Demoでは針が盤面の枠を超えて配置されていますが、長針と秒針が目盛りの上に乗るように、短針はその内側で目盛りを隠さないように配置するほうがアナログ時計としては一般的と思うので、針画像の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を使ってQML側で実装しています。QMLではEcmaScript6相当の機能が利用できるため時刻取得などもQML側で実装しています。もちろんPython側にロジックを移すことも可能ですが、Slintと違う部分の強調ということもあって今回はQML側での実装にしました。

　また100ms周期とはいえ、時刻の１秒で一気に６度移動するのは見た目上とんで見えるため回転にアニメーションをつけています。デモアプリの実装に合わせてSpringAnimationを実装しています。

## Pythonコードについて
　Qtでは、QQuickViewを使う方法と、QQmlApplicationEngineを使う方法がありますが、今回は後者で実装しています。アナログ時計程度だとロジックはすべてQML側で実装できるので、Python側は定番の読み出しコードだけになっています。
 　さっくりQMLをお試しするには、コンパイラやその他諸々を用意しなくてもお試しできるので、Pyside6は非常に便利ですね。もちろんWidgetsの機能も利用できるうえ、Qtが公式にサポートしているので、Pythonを使いつつ本格的なUIを必要とする場合、重要な選択肢になってきているかと思います。

# まとめ
　今回は、Slintアドベントカレンダーとの並行企画のため、Pyside6で簡単なアナログ時計を例としてQMLでの実装をご紹介しました。あまり身がない上にSlint側の記事との比較で間違い探しみたいになってしまいましたが、ま、まぁ、記事を書く人が減っている現状ですから、きっと怒られは発生しないはず…
　次回の記事は、きちんとQt6周りについて何か書きたいと思いますのでご容赦ください。

　明日は、@CharNoe さんの「Android対応したときの話」とのことです。楽しみに待ちましょう。