C++
example
imgui
context-menu
statefull

ImGui を使い"コンテキストメニュー"をもう少し"高度"にステートフルなオールインワン実装する例を紹介したい

More than 1 year has passed since last update.


概要

ImGui は試作やエンジニア向けのウィジェットツールキットとしてはプログラマーが扱いやすく大変便利です。しかし、一般的なPCに留まらない幅広いプラットフォーム、例えば3DSなど"特殊"なターゲットやツールチェインも考慮する特性から、どうしても最新の C++ 機能の使用やステートフルな実装を避けるため、一般的なPC向けに使う場合には軽いラッパーや実装上の工夫を行い、モダンな C++ 実装を噛ませた上で扱うのが便利で安全で善い事が多いでしょう。

ImGui 自体の紹介は Qiita でも最近幾つか見かけますのでそれらに譲る事にして、本記事では一般的なモダンな C++ を扱える、一般的なPC向け等の環境で ImGui を使う場合のコツを、ボタンの表示などに比べれば若干複雑となる"コンテキストメニュー"のステートフルな実装例を基に紹介します。

image

↑こんなのの実装例。


ソース

↓今回解説する"コンテキストメニュー"のステートフル実装のソースコードはこちら↓


解説


ImGui のウィジェット単位でのステート管理について

ImGui のステート管理には少々癖があるので先に簡単な例を示したい。


ImGui::Button の例

例えば単純なウィジェットパーツとしてボタンを扱う時には次のようなリソース管理がボタンのためにユーザーコードの実装として必要になる。

std::string label = "hoge";

std::string id = "xyz";
ImVec2 size;
auto clicked =
ImGui::Button
( ( id.empty() ? label : label + "###" + id ).c_str() // param-1 label & id
, size
);

↑は便宜上簡単のためローカル変数として書いたが、実際のところモダンな C++ 環境を対象にする場合は↓のようにクラスとして整理するとリソースを整理して扱い易くなる。(但し↓では簡単のため struct として public にメンバーを列べた簡単な例示とする。)

struct button

{
std::string label;
std::string id;
std::function< auto () -> void > on_click; // †1
ImVec2 size;
auto operator()() const
{
if
( ImGui::Button
( ( id.empty() ? label : label + "###" + id ).c_str()
, size
)
)
on_click();
}
};

†1: もちろん、 std::function の代わりに必要なら boost::signals2::signal を使い、 register_on_click を用意して std::shared_ptr から slot_typetrack_foreign しても善いが、本記事では逸脱するのでこうして一言述べるに留めておく。

なお、この段階から既に ImGui を扱う上で大変重要な点として、 label 、より厳密には id は通常重複して定義してはならないというルールがある。実際に重複してはならないのは id で、これがうっかり重複して定義された場合、ImGuiは内部状態の管理手法やAPIの呼び出し位置と実際の処理の行われるタイミングがずれる仕様が絡み、ユーザーの意図した挙動で動作しなくなる。


ステートフルなコンテキストメニューを実装する

↑こんな調子でコンテキストメニューを実装するまでを↓で解説する。


扱う ImGui の API 群

ImGui の扱いに慣れていないと少々混乱しそうな数の API を触る必要があるので先に概要を整理しておく。



  • ImGui::BeginMenu: メニューの定義を開始する API


  • ImGui::EndMenu: メニューの定義を終了する API


  • ImGui::MenuItem: 定義中のメニューにアイテムを定義する API


  • ImGui::BeginPopup: コンテキストメニューのようなポップアップの定義を開始する API


  • ImGui::EndPopup: ポップアップの定義を終了する API


  • ImGui::OpenPopup: ImGui の処理タイミングで定義済みのポップアップの表示を定義する API

以上の6つのAPIを扱う事になる。


ImGui の API における Begin/End 系

ImGui の API には BeginMenuEndMenu のようにセットで扱う必要のある API が幾つかあるが、ここにも少々の癖があるので事前に解説する。

ImGuiBegin/End 系 API の基本的なルールは次の通り。



  • Begin 系の API の戻り値はたいてい bool になっている。


  • End 系の API は対になる Begin 系の API が呼び出され、かつ戻り値が true だった場合にのみ呼び出されなければならない。


  • Begin 系の API が true を返したならば必ず対になる End 系の API を呼び出さなければならない。

つまり、ImGuiBegin/End 系の API を使う場合には、次のような実装がユーザーコードとして必要となる。

if ( ImGui::BeginXXX( ... ) )

{
...
ImGui::EndXXX( ... );
}

これを踏まえると、コンテキストメニューで扱う API は、


  1. Menu 系: Begin --> Item --> End

  2. Popup 系: Begin --> End --> Open

これら2系統だとわかる。そうなると後は簡単。


ネストしたメニュー構造の実装

↑APIの扱いがわかると簡単に思えるが、ここでコンテキストメニューは一般的にネストしたメニュー構造を取り、それをどのようにして ImGui で扱えば良いか知る必要がある。

ImGui の Menu 系 API は次の特性を持ちネストした構造を実現できる。



  1. ImGui::MenuItem を呼び出すとネストしたメニュー構造の末端のリーフ的な、最終的なノードを定義できる。


  2. ImGui::MenuBegin/ImGui::MenuEnd の中で ImGui::MenuItem に代わり ImGui::MenuBegin/ImGui::MenuEnd を呼び出すとネストしたメニュー構造の途中の、ファイルシステムで言えばディレクトリー的な中間のノードを定義できる。

つまり、ネストしたメニュー構造は↓次のように実装すれば実現できると分かる。†2

auto operator()() const

{
// 第1階層のメニュー定義のルートノードを開始
if ( ImGui::BeginMenu( ROOT, ... ) )
{
// 第1階層に末端のメニューノード A を定義
if ( ImGui::MenuItem( ROOT-A, ... ) )
on_click( ROOT-A, ... );
// 第1階層から第2階層へネストを展開する中間のメニューノード B を定義
if ( ImGui::BeginMenu( ROOT-B ... ))
{
// 第2階層に末端のメニューノード ROOT-B-X を定義
if ( ImGui::MenuItem( ROOT-B-X, ... ) )
on_click( ROOT-B-X, ... );
// 第1階層から第2階層へネストしたメニュー定義 ROOT-B を終了
ImGui::EndMenu( ... );
}
// 第1階層のメニュー定義のルートノードを終了
ImGui::EndMenu( ... );
}
}

†2: ↑簡単のため API の引数はネストしたメニュー構造の定義を感覚として読み取りやすいように擬似的に書いた。


実際の実装

実際には愚直にメニューを定義するたびに↑のように書くのはロールプレイングゲームやシューティングの実装を1つの関数に ifswitch の塊で定義するような事で通常は行わない。それでは実際の実装例を解説しよう。

実装上の重要な構造を抜粋して↓に示す。

struct statefull_menu_type

{
// ctor: https://github.com/usagi/usagi/blob/e11b5ee19ef8c79ba9a5387407c1a4fc17bb9d8f/include/usagi/imgui/statefull_menu_type.hxx#L28
statefull_menu_type( ... );
// https://github.com/usagi/usagi/blob/e11b5ee19ef8c79ba9a5387407c1a4fc17bb9d8f/include/usagi/imgui/statefull_menu_type.hxx#L37
auto operator()( ... );
// https://github.com/usagi/usagi/blob/e11b5ee19ef8c79ba9a5387407c1a4fc17bb9d8f/include/usagi/imgui/statefull_menu_type.hxx#L149
auto show( ... );
// https://github.com/usagi/usagi/blob/e11b5ee19ef8c79ba9a5387407c1a4fc17bb9d8f/include/usagi/imgui/statefull_menu_type.hxx#L65
auto clear( ... );
// https://github.com/usagi/usagi/blob/e11b5ee19ef8c79ba9a5387407c1a4fc17bb9d8f/include/usagi/imgui/statefull_menu_type.hxx#L78
auto remove( ... );
// https://github.com/usagi/usagi/blob/e11b5ee19ef8c79ba9a5387407c1a4fc17bb9d8f/include/usagi/imgui/statefull_menu_type.hxx#L83
auto add( ... );
// https://github.com/usagi/usagi/blob/e11b5ee19ef8c79ba9a5387407c1a4fc17bb9d8f/include/usagi/imgui/statefull_menu_type.hxx#L102
auto modify( ... );
private:
// https://github.com/usagi/usagi/blob/e11b5ee19ef8c79ba9a5387407c1a4fc17bb9d8f/include/usagi/imgui/statefull_menu_type.hxx#L155
auto _split( ... );
// https://github.com/usagi/usagi/blob/e11b5ee19ef8c79ba9a5387407c1a4fc17bb9d8f/include/usagi/imgui/statefull_menu_type.hxx#L162
auto _generate_random_number( ... );
// https://github.com/usagi/usagi/blob/e11b5ee19ef8c79ba9a5387407c1a4fc17bb9d8f/include/usagi/imgui/statefull_menu_type.hxx#L182
auto _remove( ... );
// https://github.com/usagi/usagi/blob/e11b5ee19ef8c79ba9a5387407c1a4fc17bb9d8f/include/usagi/imgui/statefull_menu_type.hxx#L225
auto _add( ... );
// https://github.com/usagi/usagi/blob/e11b5ee19ef8c79ba9a5387407c1a4fc17bb9d8f/include/usagi/imgui/statefull_menu_type.hxx#L259
auto _recursive_menu_render( ... );
// https://github.com/usagi/usagi/blob/e11b5ee19ef8c79ba9a5387407c1a4fc17bb9d8f/include/usagi/imgui/statefull_menu_type.hxx#L170
struct recursive_mapper_type;
};

この実装は↓次のように使える事を目的に書いた。

  using usagi::imgui::statefull_menu_type;

// テストコードの例として簡単のため static にオブジェクトを定義する。
// 実際にはクラスのメンバー変数にする事が多いだろう。
static statefull_menu_type o;
static statefull_menu_type n;
static statefull_menu_type q;

// テストコードの例として簡単のためメニュー定義が空ならば初期化するよう定義する。
// 実際にはクラスの initialize タイミングで行う事が多いだろう。
if ( q.empty() )
// メニューの階層構造は ImGui らしい便利さを優先したい事が多いだろうから、
// テキストから簡単に階層構造を定義できるようにしてみた。
// また、コンテキストメニューは末端のノードがクリックされた際に何らかのイベントを処理したい用途が通常なので、
// イベントはラムダ式で放り込めるようにしてみた。
// ImGui では callback 系に `std::function` を採用できない事情があるためステートレスでなければならないが、
// 今回はラッパークラスを定義して任意の C++ ファンクターを扱えるので便利も善い。
q.add
( decltype( q )::value_type{ "aaa/bbb/ccc" , [&]{ q.clear(); std::cerr << 0; } }
, decltype( q )::value_type{ "aaa/bbb/ddd" , []{ std::cerr << 1; } }
, decltype( q )::value_type{ "aaa/xxx/yyy/zzz" , []{ std::cerr << 2; } }
, decltype( q )::value_type{ "aaa/xxx/yyy/www" , []{ std::cerr << 3; } }
, decltype( q )::value_type{ "ppp/xxx/sss/ttt" , []{ std::cerr << 4; } }
, decltype( q )::value_type{ "ppp/xxx/sss/uuu/vvv", []{ std::cerr << 5; } }
, decltype( q )::value_type{ "qqq/xxx/sss/ttt" , [&]{ q.remove( "qqq/xxx/sss/ttt" ); std::cerr << 6; } }
, decltype( q )::value_type{ "qqq/xxx/sss/uuu/vvv", [&]{ q.remove( "qqq/xxx/sss/uuu/vvv" ); std::cerr << 7; } }
);

if ( n.empty() )
n.add
( decltype( n )::value_type{ "nnn/1/2/3", [&]{ std::cerr << "n3 "; } }
, decltype( n )::value_type{ "nnn/4/5/6", [&]{ std::cerr << "n6 "; } }
, decltype( n )::value_type{ "nnn/7/8/9", [&]{ std::cerr << "n9 "; } }
);

if ( o.empty() )
o.add
( decltype( o )::value_type{ "ooo/1/2/3", [&]{ std::cerr << "o3 "; } }
, decltype( o )::value_type{ "ooo/4/5/6", [&]{ std::cerr << "o6 "; } }
, decltype( o )::value_type{ "ooo/7/8/9", [&]{ std::cerr << "o9 "; } }
);

// 蛇足的な注意となるが、ここでもボタンの ID は重複してはならない。
if ( ImGui::Button( "open context menu 1" ) )
// 実際にはクラスの update 処理中などに何らかのトリガーに応じて呼ぶことになるだろう。
q.show();
if ( ImGui::Button( "open context menu 2" ) )
n.show();
if ( ImGui::Button( "open context menu 3" ) )
o.show();

// 実際にはクラスの render 処理中に定義する事になるだろ。
q();
n();
o();

これは↓次のように動作してくれる。

image


実装の tips


  1. ctor


    • ルートノードのIDはコンテキストメニューごとに重複しなければ善くユーザーが設定したいケースはそうそう無いので今回は乱数をIDに入れておいた。UUIDv4でも構わないし、より単純に現実的に重複しない情報量の整数値を単にインクリメントして適当な文字列と結合して使っても善いだろう。




  2. operator()()


    • ImGui API を実際に呼び付けて表示するメニューを定義するが、この際にポップアップ以外のメニューもこの実装だけで扱えるように BeginPopup/EndPopup を挟むかどうかを設定可能に工夫してある。

    • 実際のネストしたメニュー構造は _recursive_menu_render を再帰呼び出しする事で実装している。




  3. _recursive_menu_render


    • 処理すべき「ある階層のメニュー構造」を recursive_mapper_type として受け取り、末端ならば MenuItem 、そうでなければ BeginMenu --> _recursive_menu_render --> EndMenu を再帰呼び出しで実装している。




  4. recursive_mapper_type


    • 若干の手抜きのため std::map を基底に継承させたネストされたメニュー構造を定義している。 std::map を継承して使っては云々という場合は僅かに記述コストが増えるが単にメンバーにコンポジションする構造にすれば善い。


    • map::value_typepair::first がメニューのラベル(≃今回実装ではID)、 pair::second がネストした子構造を持つ場合の子構造(ない場合には empty )とし、メニューの末端としてクリックされた場合に発火させたいファンクターをメンバー _f として保持する構造とした。 _recursive_menu_render はこの再帰的にネスト可能とした map 構造をベースにした recursive_mapper_type 型の値を range-based-for で単純に列挙しながら再帰的にメニュー構造を定義している。


    • std::map をベースにしたのは単に key によって自動的にソートされる事を目的とした実装で、これは必要ならば std::vector< std::pair< ... > > として任意にソートしたり、あるいはより高度に Boost Multi-index Library を用いたり、列挙順序を制御したりできる実装にしても善い。




  5. add, remove, clear


    • これらは ImGui の内部処理のタイミングと API の呼び出しのタイミングのずれを考慮して、 add された内容、 remove された内容を一端メンバー変数にバッファリングした上で、実際には operator() 時点でまとめて _remove --> _add または _clear されるように実装してある。そのせいで少々複雑で冗長になっているが、いつでも add, remove を呼び出せるため、テストコードのようにメニューの末端のノードへ設定するファンクター内から add, remove, clear を呼び出す事もできるようになっている。



このような実装を施しながら使うと ImGui も一般的なPC向けの開発中に使うにあたって便利となる。


参考


おまけ