はじめに
以前、4行のキャラクタLCDと複数の物理ボタンを持つ組み込み機器のUIを設計したことがあります。
4行しか表示できない機器では、画面だけを先に設計するとうまくいきません。上下左右、Enter、Cancelに加えて、Home、ファンクションキー、専用ショートカットがあるかどうかで、自然な画面遷移が変わるからです。
この記事では、物理ボタンを単なる入力デバイスではなく、画面の情報設計を決める条件として扱います。
公開用に作り直した例です
本文の画面名、キー構成、外字、コードは、設計経験を説明するために新しく作成しました。実製品の画面仕様や操作仕様を転載したものではありません。
この記事は6本シリーズの4本目です。第2回でGuiWidget/GuiVisual/GuiItemという型階層を、第3回でイベント配送を扱いました。ここでは、それらを物理ボタンと画面設計へつなぎます。
この記事でわかること
- 物理キー構成と画面遷移を一緒に設計する理由
- 第2回で定義した
GuiItemの方向別リンクを、画面ごとにどう組むか - Home、ファンクションキー、ショートカットを分けて考える方法
- ComboやSpinの通常モードと編集モード
- 4行LCDで外字をUI部品として使う方法
先に結論
物理ボタンを画面完成後に割り当てるのではなく、操作面と画面を1つのUIとして設計すると、フォーカス移動やショートカットの例外処理を減らせます。
方向キーがあるなら、Widgetの移動先も単一のリストではなく、上下左右を持つ小さなグラフとして表した方が自然です。第2回のGuiItemが方向別リンクを持っているのは、そのためです。
画面だけを先に設計すると起きること
4行LCDへ設定項目を並べた後で、物理キーへ操作を割り当てると、次のような例外が増えていきます。
- 左右キーが、ある画面ではフォーカス移動、別の画面では値変更になる
- ファンクションキーの表示と、実際のキー位置が一致しない
- Homeキーで戻ったとき、編集途中の値をどうするか決まっていない
- ショートカットで開いた画面の戻り先が一定しない
- フォーカスできない表示項目まで順番にたどってしまう
問題はキーが多いことではなく、キーごとの役割と画面状態の関係が曖昧なことです。
キーを役割で分類する
公開用の例では、キーを次の5種類へ分けます。
| 種類 | 例 | 主な役割 |
|---|---|---|
| ナビゲーション | 上下左右 | フォーカス移動、候補移動 |
| 決定 | Enter、Cancel | 編集開始、確定、取消 |
| 大域移動 | Home | 現在位置に依存しない画面移動 |
| 文脈依存 | F1〜F4 | 画面下部に表示した機能を実行 |
| 専用操作 | 任意のショートカット | 特定機能へ直接移動または直接操作 |
大域移動と文脈依存キーを、通常のフォーカス移動と同じ仕組みへ押し込まないことが重要です。
なお、ファンクションキーの本数は機器依存です。 公開用のサンプルでは4本(F1〜F4)としていますが、これは説明のための固定値で、機種によって0本のことも8本のこともあります。本数を変えたいときは、第2回で定義したGUI_FUNCTION_KEY_COUNTを変えるか、キーを持たない機種では#ifで配列ごと落とします。
フォーカス順ではなく、フォーカスグラフを作る
一般的な小型UIでは、フォーカス移動を「兄弟の並び順」に沿ったnext/prevの一列リストで表します。表示順とフォーカス順が一致している間はこれで十分で、リンクを増やさない分だけRAMを節約できます。
しかし、この一列モデルには制約があります。表示順とフォーカス順を別々に設定できず、移動は同じ親の中だけです。単純な縦1列のメニューなら問題になりませんが、2列配置やファンクションキーが加わると、「次」がどちらなのか一意に決まりません。
そこで第2回では、フォーカスを取る部品GuiItemに、方向別のリンクを持たせてあります。再掲します。
/* 第2回で定義済み。GuiItemの先頭はGuiVisual。 */
typedef struct {
GuiItem *left;
GuiItem *right;
GuiItem *up;
GuiItem *down;
GuiItem *function[GUI_FUNCTION_KEY_COUNT];
} GuiItemLinks;
struct GuiItem {
GuiVisual visual; /* 必ず先頭。GuiVisualの先頭はGuiWidget。 */
GuiItemLinks links;
};
リンクは「左右」「上下」「ファンクションキー」の3つのまとまりで考えます。
-
左右(
left/right)は、同じ行に並んだ部品の間の移動 -
上下(
up/down)は、行をまたぐ移動、または編集中の値の増減 -
ファンクションキー(
function[])は、位置に依存しない機能への移動
GuiItemの先頭がGuiVisualで、その先頭がGuiWidgetなので、GuiItem *はそのままGuiVisual *にもGuiWidget *にもキャストできます。ディスパッチャはGuiWidget *として扱い、フォーカス移動のときだけGuiItem *として見ます。
増えたRAMをどう考えるか
第2回では、イベント関数5本をWidgetごとに持たせると32bit環境で20byteになるため、共有クラス関数表へ追い出しました。それに対してGuiItemLinksは、ポインタを8本持ちます。32bit環境なら1個あたり32byteです。節約したはずのRAMを、その場で使い切っている計算になります。
ただしこれは矛盾ではありません。第2回の方針は「使わない機能へRAMを払わない」であって、「常に節約する」ではないからです。クラス関数表を共有できたのは、同じ種類のWidgetがすべて同じイベント関数を使うという理由があったからで、方向別リンクは画面ごとに値が違うため共有できません。
判断の順序は次のようになります。
- 表示するだけの部品は
GuiItemにせず、GuiLabel(=GuiVisual止まり)で置く - 方向移動が必要な部品だけを
GuiItemの派生にする - ファンクションキーを持たない機種では、
function[]を#ifで落とす
フォーカス可能な部品は画面あたり数個から十数個です。全Widgetではなく、フォーカス可能なものだけがリンクを持つ点も見積もりに効きます。
画面ごとに移動規則を組み立てる
リンクはWidgetの型が持つ固定規則ではなく、画面を組み立てる側が設定します。
まず、画面そのものの定義です。GuiPageを先頭へ埋め込み、その画面が使うWidgetを実体としてぶら下げます。動的確保は行いません。
typedef struct {
GuiPage page; /* 必ず先頭。GuiPageの先頭はGuiWidget。 */
GuiLabel title;
GuiCombo rate;
GuiSpin level;
GuiButton apply;
GuiButton help;
} SettingsPage;
/* 子Widget配列。GuiPageはこの配列へのポインタだけを持つ。 */
static SettingsPage g_settings;
static GuiWidget *g_settings_children[5];
GuiPageは子の実体を持たず、GuiWidget *の配列を指すだけです。実体の所有者はSettingsPage側にあります。
リンクの設定は、Pageの構築時に行います。画面表示のたびに変わる必要がなければ、構築時に一度だけで十分です。
static void settings_build_links(SettingsPage *sp)
{
/* 上下: 行をまたぐ移動 */
sp->rate.named.item.links.down = &sp->level.named.item;
sp->level.named.item.links.up = &sp->rate.named.item;
/* 左右: 同じ行の中の移動 */
sp->rate.named.item.links.right = &sp->apply.item;
sp->apply.item.links.left = &sp->rate.named.item;
/* ファンクションキー: 位置に依存しない移動 */
sp->rate.named.item.links.function[0] = &sp->help.item;
sp->level.named.item.links.function[0] = &sp->help.item;
}
sp->rate.named.item.linksという綴りが、そのまま型の入れ子を表しています。GuiComboの先頭がGuiNamedItem、その先頭がGuiItem、その中にGuiItemLinksがある、という関係が、コードだけで追えるようになっています。
機種や設定値によってリンクが変わる場合は、GuiPageClassのbefore_showで組み直します。
static void settings_before_show(GuiPage *page)
{
SettingsPage *sp = (SettingsPage *)page;
settings_build_links(sp);
/* 上位機種だけHelpを見せる。非表示のItemは読み飛ばされる。 */
gui_widget_set_visible(&sp->help.item.visual.widget, HAS_HELP_KEY);
}
同じComboであっても、画面上の位置や隣接部品は変わります。Widgetのクラス側へ画面固有の移動先を埋め込まず、Page側で設定するのが要点です。
キー走査とUIイベントを分ける
WidgetがGPIOを直接読む設計にはしません。
キー入力層は、物理キーの走査、チャタリング除去、長押し判定、リピートを受け持ちます。UIライブラリは確定済みのイベントだけを受け取ります。
第2回のクラス関数表がkey_down/key_press/key_upの3本を持っているのは、この境界を型で表すためです。
/* 第2回で定義済み */
struct GuiWidgetClass {
void (*show)(GuiWidget *widget);
void (*hide)(GuiWidget *widget);
bool (*key_down)(GuiWidget *widget, GuiKey key); /* 押した瞬間 */
bool (*key_press)(GuiWidget *widget, GuiKey key); /* 長押しリピート */
bool (*key_up)(GuiWidget *widget, GuiKey key); /* 離した瞬間 */
};
「何ミリ秒でチャタリングとみなすか」「何ミリ秒からリピートを始めるか」は、キー走査層の中だけの話です。UI層はDown/Press/Upという結果だけを見ます。この分離により、キーマトリクスが独立GPIOへ変わってもWidget側は変更せずに済みます。
この3イベントを分けた理由は、単純な長押しリピートだけではありません。実機では、電圧レベルのような細かい値をSpinで調整すると、UI、ハードウェア制御、LCD描画の速度差が表面化しました。押し始めは1送り、長押しが続くと5送り、10送りへ段階的に加速し、キーを離した時点の候補値を最終反映する、といった調整にも使っています。
これはRTOSやハードリアルタイム制御における期限保証の話ではありません。異なる速度で動くものを、ユーザーから見て一貫した操作に感じられるよう整えるUX上の工夫です。実機で何を調整したのかは、第6回で詳しく扱います。
同じキーでも、モードによって意味が変わる
ComboやSpinは、通常モードと編集モードを持ちます。
| 状態 | 上下キー | Enter | Cancel |
|---|---|---|---|
| 通常 | フォーカス移動 | 編集開始 | 画面側の取消 |
| 編集中 | 候補または値を変更 | 確定 | 編集開始前へ戻す |
重要なのは、Widgetがキーを処理したかどうかをディスパッチャへ返すことです。第3回のディスパッチャは、次の順序で動きます。
bool gui_page_dispatch_key_down(GuiPage *page, GuiKey key)
{
GuiItem *current;
GuiItem *next;
uint8_t guard;
if (page->selected_child == NULL) {
return false;
}
/* 1. まず選択中のWidgetへ渡す。消費されたらそこで終わり。 */
if (gui_widget_key_down(page->selected_child, key)) {
gui_page_repaint(page);
return true;
}
/* 2. 消費されなければ、方向リンクをたどってフォーカスを移す。 */
current = (GuiItem *)page->selected_child;
next = gui_item_link(current, key);
for (guard = 0; next != NULL && guard < 16u; guard++) {
if (widget_selectable(&next->visual.widget)) {
gui_page_set_focus(page, next);
gui_page_repaint(page);
return true;
}
next = gui_item_link(next, key);
}
return false;
}
編集中のComboが上下キーを消費した場合、gui_widget_key_down()がtrueを返すので、Pageはフォーカスを移動しません。Widgetが処理しなかった場合だけ、方向リンクへフォールバックします。
gui_item_link()は、キー種別からGuiItemLinksのどのメンバを見るかを決めるだけの小さな関数です。GUI_KEY_F1ならlinks.function[0]、GUI_KEY_RIGHTならlinks.rightを返します。
非表示や無効なItemはこのループが読み飛ばします。guardは、リンクが循環していたときに無限ループへ入らないための上限です。
Homeとショートカットは別ルートで扱う
Homeや専用ショートカットは、フォーカス中のWidgetへ渡す前に大域キーとして判定します。上のディスパッチャを呼ぶより手前の層で処理し、GuiItemLinksには載せません。
ただし、編集中の値がある場合は、先に次の規則を決める必要があります。
- 自動的に確定する
- 自動的に取り消す
- 移動を止めて確認する
小型LCDでは確認ダイアログ用の行も貴重です。私は、設定の危険度と復元可能性に応じて、確定または取消を画面単位で決める方が扱いやすいと考えています。
ファンクションキーは画面上の位置と対応させる
ファンクションキーがLCDの下に並ぶ機器では、最下行を機能ラベルへ割り当てる設計ができます。
+--------------------+
|Device Settings |
|>Rate:115k [Apply] |
| Lvl:5 Port:P1 |
|[Help] |
+--------------------+
実画面では、物理キーの中心位置とラベルの表示位置をできるだけそろえます。機能がない場所へ空のラベルを表示するか、別の意味を割り当てるかも、機器全体で統一します。
ここでも本数は機器依存です。ラベル行のレイアウトは、GUI_FUNCTION_KEY_COUNTと最下行の桁数から機種ごとに決めます。
外字を意味ベースで扱う
キャラクタLCDの外字は、数ドット幅×8行程度のビットマップをLCDコントローラへ登録して使います。登録できる数やコード位置は、コントローラによって異なります。
Widgetへ実際の外字コードを書かせると、LCD変更時に画面コードまで修正が必要になります。そこでWidgetは意味を指定し、描画ポートが変換します。第3回のGuiDisplayPortが、その境界です。
/* 第3回で定義済み。シリーズ共通。 */
typedef enum {
GUI_ICON_NONE = 0,
GUI_ICON_CURSOR,
GUI_ICON_UP_DOWN,
GUI_ICON_LOCKED,
GUI_ICON_EDITING
} GuiIcon;
typedef struct {
void (*clear)(void *context);
void (*draw_text)(void *context, uint8_t x, uint8_t y, const char *text);
void (*draw_icon)(void *context, uint8_t x, uint8_t y, GuiIcon icon);
void (*flush)(void *context);
} GuiDisplayPortOps;
typedef struct {
const GuiDisplayPortOps *ops;
void *context;
} GuiDisplayPort;
contextは、LCDドライバ側の状態(どのバス、どのチップセレクトか)を持ち回るための不透明ポインタです。関数ポインタだけでなくcontextを組にしておくと、同じ実装で2画面を駆動する、テスト時にモックへ差し替える、といった使い分けができます。
別のLCDで外字が使えなければ、draw_icon()の実装を">"、"^"、"#"などの通常文字へ置き換えられます。Widget側のコードは1行も変わりません。
物理キーが変わる機種展開
同じ機能を持つ機種でも、廉価版ではファンクションキーがなく、上位版では専用キーが追加されることがあります。
このとき、Widgetの機能そのものを作り直す必要はありません。
- Widgetは編集と実行を担当する
- Pageは利用可能なキーから
GuiItemLinksを組み立てる - Key Mapは物理キーを
GuiKeyへ変換する - 表示レイアウトは機種ごとのPage定義で調整する
完全に同じ画面を使い回すことより、共通Widgetとイベント規約を保ち、機種ごとの差を画面構築へ閉じ込める方が現実的です。
実装時に決めておくこと
- フォーカスリンクが
NULLのとき、停止するか循環するか - 非表示または無効なItemをどうスキップするか(サンプルではディスパッチャ側で読み飛ばす)
- 編集中にHomeやショートカットを押したときの扱い
- 長押し(
key_press)を受け付けるWidgetと、単発(key_down)だけのWidget - ファンクション表示と物理キーの位置関係
- 外字が不足した場合の優先順位と代替文字
- 画面切り替え時の初期フォーカスと復帰フォーカス
まとめ
4行LCDのUIは、4行の文字列だけでは決まりません。
- 物理キーを役割で分類する
- フォーカス移動を、
GuiItemLinksによる方向別のグラフとして表す - リンクはWidgetの型ではなく、Pageの構築時または
before_showで設定する - 大域キーとWidget向けキーを分ける
- キー走査の事情をUI層へ持ち込まない
- 外字を
GuiIconという意味で指定し、GuiDisplayPortで変換する
物理ボタンとLCDを同じ設計対象として見ると、画面ごとの例外分岐を減らし、キー構成が異なる機種にも展開しやすくなります。
次回は、UIイベントから専用ASICやFPGAの操作を直接呼び出さず、機能境界を挟む設計を扱います。
参考資料
- HD44780U (LCD-II) Dot Matrix Liquid Crystal Display Controller/Driver データシート(PDF) — 日立製作所作成のデータシートをSparkFunが公開しているミラーです。CGRAMへの外字登録手順と、登録できる文字数の制約について
- Character Type Driver IC 製品一覧 — Sitronix — ST7066Uを含む互換コントローラのメーカー公式製品ページです。CGRAMのアドレス割り当てが製品ごとに異なることの確認用(本ページからPDFの直リンクは提供されていません)


