前回、次のような機能目標を立てました。

  • 温度画像を擬似カラーで表示する機能、特定ポイントの温度を表示する機能
  • 測定の温度範囲や表示の解像度など、各種設定を変更し保存するメニュー機能
  • 温度画像や擬似カラー画像を SD カードに ファイルとして保存し表示する機能

現段階では「表示ができるようになった」程度で、ソフトの作り込みが全然なので、改めて課題と欲しい機能を整理してみました。

項目 課題 希望(機能要件)/願望(非機能要件)
レンダリング - 最大解像度だとフレームレートが遅い
- 解像度下げるには、再コンパイルが必要
- メニューで変更可能にしたい
- クールな GUI が欲しい!
温度測定範囲 - 20℃〜35℃固定 → 変更は再コンパイル
- 温度画像のカラー分解能が荒い
- 特定ポイントの温度が分からない
- メニューで変更可能にしたい
- タッチした点の温度を表示したい
- カラーパレットを無段階化したい
温度画像 - キャプチャするインターフェースがない
- 本体で保存データの閲覧/削除が出来ない
- 動画を保存したい!
- タッチで温度画像を表示したい
- ファイル管理やサムネール表示が欲しい
- 生データを連続保存する機能が欲しい
タッチパネル - キャリブレーションは別プログラム
- キャリブレーション結果がプログラム埋め込み
- キャリブレーション出来る様にしたい
- 結果を Flash に保存したい

ということで、今回は「メニュー機能」の作り込みについて書きたいと思います!

Arduino 用ライブラリの調査

例によって、まずは Arduino 環境で使えるライブラリの調査からです。

GUI ライブラリ

Adafruit_ILI9341_Menu
Adafruit_ILI9341_Menu

タイトルバーとリストボックスで構成され、シンプルな操作で設定項目の編集が可能なライブラリです。Adafruit-GFX-Library 用なので、この プルリク の様に TFT_eSPI や LovyanGFX への移植が必要ですが、それほど大変ではなさそうです。

リストボックスだけで完結するなら選択肢としてアリですが、今回の件では+αのプログラミングが必要になりそうです。

GUIslice Builder
GUIslice Builder

軽量でオープンソースな GUI ライブラリです。豊富に用意されたウィジェットは、そのほとんどが Flash 上に配置されるよう構成されていて、RAM を圧迫しない作りとのことです。また Windows や MacOS、Linux で動作する、ドラッグ&ドロップで GUI が作れる WYSIWYG なツール GUIslice Builder が提供されていて、レイアウトの手間を大幅に削減してくれます。

ドキュメントも オンライン だけじゃなく、合わせると 800 ページ近くになるリファレンスやユーザーガイドの PDF もあり、しっかりしていています。ウィジェットのデザインはクールさに欠けますが、かなりの力作です!

LVGL
LVGL - Light and Versatile Graphics Library

フリーで商用利用も可能なオープンソースなライブラリです。Arduino 用は ESP32 と TFT_eSPI が推奨 されてますが、LovyanGFX でも動きそう です。

サンプルを見る限り、スタイルシートの代わりに関数群を組み合わせてデザインするイメージでしょうか。ただ日本語の情報がほとんどなく、学習コストはかなり高そうで、僕では簡単な Arduino 用スケッチ例 さえ動かせず、今回は断念です 😭

悔しさのあまり勢いで CYDAliExpress で購入 しちゃいました。この辺のチュートリアル を参考に、そのうちリベンジしたいと思ってます :fish_cake:

SquareLine Studio
SquareLine Studio

グラフィックライブラリに TFT_eSPILovyanGFX を、レンダリングフレームワークに LVGL を採用している、かなりクールなツールですが、オープンソースでガッツリとビジネス利用しています。ライセンスのページ には、個人利用は生涯無料のように書いてありますが、30日でライセンス切れになる可能性大ですね。LVGL が動かないので今回は軽くパスです。

SquareLine Vision
SquareLine Vision

前述の SquareLine Studio の新バージョンです。組み込み系に加え、Vue.jsREACT など Web 系の UI フレームワークも取り込もうとしている意欲的なツールです。

SquareLine はこれまで LVGL と協力関係にありましたが、2024年2月、「LVGL と縁を切り独自路線を歩む事になった」ようです。LVGL 側も負けじと UI ツールのリリース計画を発表 しているので、そちらに期待ですネ。

タッチイベント ライブラリ

ココまで GUI ライブラリを調べてきて、「自作するっきゃない?」と思い始めました。そうなるとタップやスワイプなど、タッチスクリーン上のイベントを識別するライブラリも必要になりそうです。という事で、この件も調べてみました。

  • TouchEvent
    ダブルタップなども識別できるのですが、残念ながら XPT2046_Touchscreen 専用です。タッチが検出可能な TFT_eSPI や LovyanGFX との重複利用も考えられますが、肝心のキャリブレーションがサポートされていないので、別途 XPT2046_Calibrated などのライブラリが必要です。

という事で、本件も含めて自作を決意するに至った次第です。こりゃ大変だ〜 😮‍💨

今回製作した UI モックアップのご紹介

文章で説明するより動画の方が分かり易いと思い、初めて音声付きの動画を作成しました。12分と少々長く、滑舌が悪く聴きづらいところがありますが、再録は面倒なのでご勘弁を :bow:

各コンポーネントのご紹介

コンポーネントの全体概要
コンポーネントの全体概要

さて、作成した各コンポーネントをチョットだけ詳しく、一部コードを交えて紹介しますが、コードそのものよりもコメントでイメージが伝わればと思います。

また動画で使った「コンポーネントの全体概要」を再掲するので、合わせてご覧ください。

イベント マネージャー

TFT_eSPI または LovyanGFX が提供するメソッド getTouch() で、タップやダブルタップ、スワイプといったイベントを識別するコンポーネントです。識別するイベントを次のように定義しています。

typedef enum {
  EVENT_NONE    = (0x00), // 非タッチ状態を 'HIGH' とみなす
  EVENT_RISING  = (0x01), // タッチ状態   --> 非タッチ状態
  EVENT_FALLING = (0x02), // 非タッチ状態 --> タッチ状態
  EVENT_TOUCHED = (0x04), // タッチ状態   --> タッチ状態
  EVENT_TAP2    = (0x08), // ダブルタップ

  // エイリアス
  EVENT_INIT    = (EVENT_NONE),
  EVENT_UP      = (EVENT_RISING),
  EVENT_DOWN    = (EVENT_FALLING),
  EVENT_DRAG    = (EVENT_FALLING | EVENT_TOUCHED),
  EVENT_TAP     = (EVENT_FALLING | EVENT_RISING),
  EVENT_CLICK   = (EVENT_FALLING | EVENT_RISING),
  EVENT_CHANGE  = (EVENT_FALLING | EVENT_RISING),
  EVENT_SELECT  = (EVENT_FALLING | EVENT_TAP2),
  EVENT_ALL     = (EVENT_FALLING | EVENT_RISING | EVENT_TOUCHED),
} Event_t;

基本は「立ち下がり(FALLING)」、「立ち上がり(RISING)」、「タッチ(TOUCHED)」で、アプリケーションの用途に合わせこれらを組みわせたエイリアスを定義しています。またダブルタップ(TAP2)は、一定時間内の FALLINGRISING をカウントして識別します。

このコンポーネントが返すのは、次のような識別したイベントと座標の組みです。

typedef struct Touch {
  Event_t     event;  // Detected event
  uint16_t    x, y;   // The coordinates where the event fired
} Touch_t;

また必要かどうか分かりませんが、メカスイッチ同様、デバウンス処理を入れています。今回はタッチデバイスからの割り込み信号 T-IRQ を有効にしているので、ちゃんとオシロスコープで観測すべきですが、今後の課題です。

余談ですが、LovyanGFX の場合、getTouch() の第3引数で「読み込み回数」を指定すると、観測した座標の中央値を返してくれるようなのですが、試してはいません。細かいところまで作り込んであるのはサスガです。

ウィジェット

まずはスクリーンとウィジェットの構成です。メインスクリーンを例にとれば、最終イメージに対し背景となる画像を1枚用意し、その上に「ウィジェット領域」とその中で「受け付けるイベントの種類」を定義しています。

メインスクリーンの構成
メインスクリーンの構成

上記の考え方を実現するため、各ウィジェットを次のような構造体で定義します。

// ウィジェット画像の定義
typedef struct {
  const uint8_t   *data;  // ウィジェット画像配列へのポインタ
  const size_t    size;   // ウィジェット画像のデータサイズ
} Image_t;

// ウィジェットの定義
typedef struct Widget {
  const uint16_t  x, y;   // ウィジェット領域の開始座標
  const uint16_t  w, h;   // ウィジェット領域の縦横幅
  const Image_t   *image; // ウィジェット画像へのポインタ
  const Event_t   target_event; // 受け付けるイベントの種類
  void            (*callback)(const struct Widget *widget, const Touch_t &touch);  // コールバック関数
} Widget_t;

callback() の第1引数は自分自身へのポインタで、後述するウィジェットマネージャーから渡されます(クラス化しウィジェットをオブジェクトとして生成する段取りを踏めば不要ですが、今回はそんな面倒なことをしていないため、必要となっています)。

さて実際のメインスクリーンは、この構造体を配列にして、次のように定義しています。

static constexpr Widget_t widget_main[] = {
  {   0,   0, 320, 240, image_main,        EVENT_NONE, onMainScreen        },
  {   0,   0, 256, 192, NULL,              EVENT_ALL,  onMainInside        },
  { 258,   0,  62, 134, NULL,              EVENT_ALL,  onMainOutside       },
  {   0, 195, 256,  45, NULL,              EVENT_ALL,  onMainThermograph   },
  { 265, 135,  50,  50, image_icon_camera, EVENT_UP,   onMainCapture       },
  { 265, 185,  50,  50, NULL,              EVENT_ALL,  onMainConfiguration },
};

背景のスクリーン画像も1つのウィジェットで、image_main が示す Image_t 配列へのポインタで紐づけます。またカメラのアイコン画像 image_icon_camera は、状況や設定に応じて4種類を切り替えるため、4つの画像を紐付けます。

// メインスクリーンの背景画像
static constexpr Image_t image_main[] = { { screen_main, sizeof(screen_main) }, }; // 320 x 240

// カメラアイコンの画像
static constexpr Image_t image_icon_camera[] = {
  { icon_camera1, sizeof(icon_camera1) }, // カメラアイコン(50 x 50)
  { icon_camera2, sizeof(icon_camera2) }, // スクリーンキャプチャ中のアイコン(50 x 50)
  { icon_video,   sizeof(icon_video  ) }, // ビデオアイコン(50 x 50)
  { icon_stop,    sizeof(icon_stop   ) }, // 録画中アイコン(50 x 50)
};

ウィジェット画像へのポインタに NULL が指定されているのは、画像が不要あるいは既に背景画像に描かれているウィジェットで、「ウィジェット領域」と「受け付けるイベントの種類」、および「コールバック関数」だけを持ちます。

そしてこれらの全データは、constexpr 宣言により、Flash 上に配置 されます。

ウィジェット画像について

フォーマットは全て PNG としています。TFT_eSPI の場合は別途 PNGdec ライブラリ が必要ですが、RGB565 フォーマットに比べ画像サイズを小さく出来、Flash の占有率を減らせます。

また画像のデータ変換は、Lang-ship さんの「LovyanGFX入門 その5 画像描画」を参考に、提供して下さっているツールを使わせて頂きました。色々なデータ変換に対応していて、出力データをそのままヘッダーファイルに埋め込める、大変便利なツールです。感謝です :1st_place_medal:

画像データ変換ツール
画像データ変換ツール
カメラ、歯車アイコン右横のゴミ
カメラ、歯車アイコン右横のゴミ

さらにちょっとした Tips ですが、フリーのツール(僕の場合はサブスク化される前の ImageOptim)で PNG 画像を軽量化 しています。ただし TFT_eSPI で使う PNGdec では、軽量化した画像を表示するとゴミが現れることがあるので要注意です。軽量化しなければ正しく表示されます。

各ウィジェットの概要

詳細は割愛しますが、イメージをお伝えすべく、主要なウィジェットの概要と設計時のメモを画像で示します。

  • スクロールバー付きリストボックス
スクロールバー付きリストボックス
スクロールバー付きリストボックス

SD カードのファイル管理に使うウィジェットで、カード内のファイルを C++11 の std:vector でリスト化し、全ファイルを想定した仮想キャンバスと、表示用のスプライト画像との関係からスクロールバーの長さと位置を決めています。図中、大文字のシンボルは、このウィジェットのプロパティです。

タップ時のサムネイル表示は EVENT_FALLING で、またダブルタップ時のディレクトリ移動は EVENT_TAP2 で、それぞれのイベントをコールバック関数内で判定し処理しています。

  • スライダー
スライダーの構成
スライダーの構成

スライダーは「バー」と「ノブ」の2種類の画像で構成します。さらに「ノブ」は有効化/無効化の2つを用意します。

今回「バー」は「ノブ」より細くしていますが、画像の縦幅を合わせ、「バー」の背景をスクリーンの背景と同色で塗りつぶしています。こうする事で「ノブ」を重ねた時のアンチエイリアシングを画像編集ソフトに任せられます。グラフィックライブラリ側のアンチエイリアシング機能を使うよりも、この方式の方が表示時の速度と綺麗さの面で有利かと思います。

イケてない点としては、長さの異なる「バー」は、それぞれ専用画像の作成が必要という仕様で作っちゃった事です :persevere: クラス化する場合には、要改善ですね。

  • チェックボックス、ラジオボタン、トグルボタン
ボタン群
ボタン群

これらのウィジェットは、見た目以外にそれほど大きな違いはありません。ただラジオボタンだけは、一つが選択された時にグループ内の他のボタンにメッセージを送る処理をコールバック関数に記述しています。

  • アニメーション
アニメーションの仕組み
アニメーションの仕組み

ユーザー操作への反応として単純なアニメーションを実装しました。省メモリのため、アイコン画像より少し大きな領域に対し横1ライン分のバッファを用意し、 EVENT_FALLING の場合は下から順に、EVENT_RISING では上から順に、それぞれ readRect() でコピーし pushImage() でズラしています。これでも十分に高速に実行可能です。

ウィジェット マネージャー

主な役割は以下の2つです。

  • イベントマネージャが識別したイベントと座標を元に該当するウィジェットを特定し、コールバック関数を呼び出す役割です。以下にコードの抜粋を示します。
// フォーカスされたウィジェット
static Widget_t const* focus = NULL;

// イベントマネージャーが識別したイベントを元に該当するウィジェットを特定し、コールバックを実行する
static bool widget_event(const Widget_t *widgets, const size_t n_widgets, Touch_t &touch) {
  Event_t event = touch.event;

  // フォーカスされたウィジェットが空の場合
  if (focus == NULL) {
    for (int i = 0; i < n_widgets; i++) {

      // 発生したイベントが、ウィジェットの「受け付けるイベントの種類」と合致したら、
      if ((touch.event & widgets[i].target_event) && widgets[i].callback) {

        // 「ウィジェット領域」内のイベントかどうかをチェックし、
        if (widgets[i].x <= touch.x && touch.x <= widgets[i].x + widgets[i].w &&
            widgets[i].y <= touch.y && touch.y <= widgets[i].y + widgets[i].h) {

          // 該当するウィジェットとしてフォーカスを当てる
          focus = &widgets[i];
          break;
        }
      }
    }
  }

  // 発生したイベントが、フォーカスされたウィジェットの「受け付けるイベントの種類」と合致したら、
  if (focus && (touch.event & focus->target_event)) {

    // 余計なイベントはマスクし、
    touch.event = (Event_t)(touch.event & focus->target_event);

    // コールバック関数を実行する
    focus->callback(focus, touch);
  }

  // 発生したイベントが「立ち上がり」の場合は、フォーカスをリセットする
  focus = (event & EVENT_RISING ? NULL : focus);

  return (focus != NULL); // フォーカスが当たっている場合は true、外れれば false
}

focusconst Widget_t* じゃなくて Widget_t const* です!)は、スクロールバーやスライダーの操作中にウィジェット領域から外れてもフォーカスし続けるための変数です。またこの変数は、スクリーンが遷移した時にリセットの必要があるため、ファイル内でグローバルとしています。

  • もう一つはスクリーンの状態遷移を管理する役割です。例えばスクリーンが遷移した場合、フォーカスをリセットし、各ウィジェットに初期化のメッセージを送ります。メッセージを受け取ったウィジェットでは主に、設定値に応じた描画を行います。
// ウィジェットを初期化するためのイベントメッセージ
static constexpr Touch_t doInit = { EVENT_INIT, 0, 0 };

// 指定されたスクリーンに属するウィジェットを初期化する
void widget_setup(State_t screen) {
  int n;
  Widget_t const *widget;

  // フォーカスされたウィジェットをリセットする
  focus = NULL;

  // 指定されたスクリーンのウィジェット配列を取得し、
  if (widget_get(screen, &widget, &n)) {

    // 各ウィジェットに初期化のイベントメッセージを送る
    for (int i = 0; i < n; i++, widget++) {
      if (widget->callback) {
        widget->callback(widget, doInit);
      }
    }
  }
}

さて…

普通は GUI のウィジェットをプログラミングする時、クラス化するのが当然なんでしょうが、慣れ親しんだ C 言語スタイルで作ってしまいました。結果、構造の単純さだけが取り柄で、再利用性は最低です :sweat_drops:

最初は、スキル向上のために C++ でクラス化にチャレンジするか迷ったんですが、ポンコツな車輪の再発明に時間を費やすより、とにかく一品モノとしてとっとと仕上げ、LVGL などの優れた車輪に習熟する方が勉強になるかと考えた次第です(見苦しいイイワケです)。

とは言えかなりの時間を費やしてしまいました。年内に仕上げられるか危ういところですが、年明けには 1550 円の CYD で LVGL のプログラミングが楽しめるよう頑張ります :sunrise: