🥚

eguiで作るRustのGUI(基本的な使い方と日本語表示)

8 min read

Rust の GUI クレートは全体的にまだまだ発展途上で決定版がない状況ではあるものの、 egui が良い感じに進歩しているので、基本的な使い方を書いてみる。

egui の概要と特徴

https://github.com/emilk/egui

egui は書き方も見た目も使い勝手も独特なGUIライブラリ。名前の読み方は「エグーイ」だと思う。Emil氏がやっているので。

特徴を知るためにはとりあえずWebブラウザで動くサンプルを触ってみるのが良い。

https://emilk.github.io/egui/index.html

とにかくクセがすごい。ウィンドウ内のウィンドウなんて今日日見かけないものが普通に存在している。
でも豊富なウィジェットがあるので、それなりに使えそうな感触もあるかとも思う。

egui の特徴をざっくりと列挙してみると、以下のような感じ。

  • 動作もビルドも速い。
  • 色々な環境で動く。上記サンプルみたいにWASMでも動く。
  • 即時モードを採用している。(詳細は後述)
  • ネイティブのような見た目や、複雑なレイアウトは目指していない。
  • 日本語表示・日本語入力・コピペが普通に可能。

見た目のクセの強さに反して、分かりやすくて書きやすいライブラリ。現状、プロダクションレベルのものを作ろうとするとまだ困る部分があるけど、簡単なツール類を作る程度であれば問題なく使えるくらいには仕上がっているかも。

即時モードとは何か

これは egui に限ったものではなくて一般的な方式の話ではあるけど、他のGUIクレートに比べて egui の特徴的な部分であるので、簡単に説明する。
リポジトリのREADMEに「Why immediate mode」としてつらつらと書いてあるのでそこを読んでもらうのが一番わかりやすいかも。一応日本語訳したものは本記事の最後から飛べる)

即時モード(immediate mode)を一言で言い表せば、「毎フレーム毎フレームGUI要素を描画し直す」動作モードのこと。60FPSで表示していれば、毎秒60回描画し直されることになる。

これはゲームの画面と同じ作りなので、egui はゲームとの相性が良く、他のRust製ゲームエンジンと組み合わせて使えるようなクレートが多数ある。というかRust関係の情報を追っていると egui の名前をゲーム関係でよく見かけるのはこれが要因。

でももちろんゲームだけではなく、普通のGUIも作れる。
しかも egui では即時モードの利点を活かして、イベント処理やステート管理といった部分がかなりシンプルになっていて、簡単に使えるようになっている。

ここで気になるのが、毎秒60回も描画し直して処理性能は大丈夫なのか、というところかと思うけど、特に問題ない。最近のコンピュータはとんでもなく速いし、そもそもRustの実行速度も速いので、通常は1~2ミリ秒もあれば処理は終わるらしい。(ただすごく長いスクロールのように苦手なものもあるということ)

ちなみに即時モードではないやり方として保持モード(retained mode)があり、実はこっちの方が一般的。これはフレーム間で変更があった部分だけを再描画する方法。パフォーマンスが良かったり、複雑なレイアウトを実現可能な点ではこっちの方が優位と言えるけど、どこを再描画するのかとか、コールバックの処理とかで仕組みが複雑になりがち。

ひとまずテンプレートで動かす

今回自分が試した環境は以下の通り。

  • Rust 1.56.1
  • egui 0.15.0
  • Windows10 64bit

自分はWindowsのネイティブ環境でやっているけど、Macでも普通に動くはず。

ボイラープレートが少し多いので、公式で用意されているテンプレートから始めるのが手っ取り早い。

https://github.com/emilk/eframe_template/

ということで、このテンプレートをローカルに適当な名前でクローンしてきて、以下のコマンドを実行すればひとまず動くと思う。

git clone https://github.com/emilk/eframe_template/ egui_test
cd egui_test
cargo run


テンプレートのままでの実行画面

Linuxで実行する場合は、リポジトリの README にかかれている通り、いくつかインストールが必要らしい。

テンプレートの中には色々ファイルがあるけど、重要なのは以下の3つ。

  • src/main.rs
    • ネイティブで動かすときのエントリーポイントとなるファイル。
  • src/lib.rs
    • WASMで動かすときのエントリーポイントとなるファイル。
  • src/app.rs
    • 一番のメインとなる、各種処理が書かれたファイル。

中身があるのは src/app.rs なので、この中身を解説していく。

src/app.rs の解説

pub struct TemplateApp

この TemplateApp 構造体がアプリ本体。

ここにアプリのデータ(ステート)を持っていて、文字列のテキストフィールドの値である label: String と、スライダーの値である value: f32 がある。

この構造体に実装されている epi::App トレイトにより、アプリが動作する。

fn name(&self) -> &str

アプリの名前、要するにウィンドウのタイトルになる文字列スライスを返す。

fn setup(&mut self, ~~~ )

最初の描画フレームの前に1回だけ呼び出される。

初期状態で書いてあるコードはデータの永続化処理で、前回の終了時に呼び出されたデータを読み込むもの。

fn save(&mut self, ~~~ )

アプリ終了時に呼び出される。

初期状態ではデータ永続化として、アプリの状態(データ)を保存している。

fn update()

この関数が一番のメイン。これが毎フレーム呼び出されて、画面の構築(描画)とイベント処理を行う。

ウィジェットの配置場所

実際のUI要素(ウィジェット)は、その配置場所となる SidePanel, TopPanel, CentralPanel, Window, Area のいずれかの配下に配置する。これはそれぞれ以下のような位置と動作になる。

  • egui::SidePanel 左右のエリア。
  • egui::TopPanel 上のエリア。メニュー表示に適している。
  • egui::CentralPanel 中央のエリア。メインはここ。
  • egui::Window ウィンドウ内ウィンドウ。CentralPanel内で動かせる。
  • egui::Area ウィンドウ内エリア。CentralPanel内で動かせる。あまり使わないと思う。

一部のものは追加する順番に意味がある。例えば SidePanelTopPanel のどちらを先に書くかによって、どちらが大きいかが決まる。

CentralPanel は、 SidePanelTopPanel の残りの部分になり、この中に WindowArea が表示される。
ちなみに CentralPanel だけはIDを渡さないことから分かる通り、1つのみ表示可能。

これらはそれぞれ生成するときの呼び出し方や引数が微妙に違う。

egui::SidePanel::left("Left", 200.0).show(ctx, |ui| { ... // 左側。数値は最小幅
egui::SidePanel::right("Right", 200.0).show(ctx, |ui| { ... // 右側。数値は最小幅
egui::TopPanel::top("Top").show(ctx, |ui| { ...
egui::CentralPanel::default().show(ctx, |ui| { ...
egui::Window::new("Window").show(ctx, |ui| { ...
egui::Area::new("Area").show(ctx, |ui| { ...

これらの引数で渡している文字列は、その要素のID。impl std::hash::Hash を実装していれば何でも使えるものの、管理を考えると普通は文字列を使うことになると思う。

なお、同じ親要素内でIDが重複していると実行画面に赤くエラーが表示されるのですぐに分かる。

ウィジェットの表示

SidePanel 等に対して、クロージャとしてウィジェットを追加していく。

egui::SidePanel::left("side_panel").show(ctx, |ui| {
    ui.heading("Side Panel");

    ui.horizontal(|ui| {
        ui.label("Write something: ");
        ui.text_edit_singleline(label);
    });

どんなものを追加できるかは、Ui のドキュメントを見れば分かる。

https://docs.rs/egui/0.15.0/egui/struct.Ui.html#adding-widgets

イベント処理

テンプレートでは、「Increment」と表示されたボタンを押すと数値に +1 されるようなイベントが設定されている。

この部分のコードは以下の通り、とても単純。

if ui.button("Increment").clicked() {
    *value += 1.0;
}

コールバックを渡したりするわけではなく、単純に自身である TemplateApp 構造体の value をインクリメントしている。(これは fn update() の最初の let Self { label, value } = self; で、変数名のみでアクセスできるようにしている)

どのように動作しているのかというと、すごい頻度で fn update() が呼び出される中、そのときにボタンが押されていたら(最初の1回)、clicked()true を返すので、 if 式の中身が実行されて値が変化し、最終的にその値をもとにしてUIが描画される、という流れになっている。

この単純さが即時モードのUIのメリット。

謎の非実行部分(ウィンドウ内ウィンドウ)

コードの下の方に、 if 式に false を渡して実行されていない部分がある。

if false {
    egui::Window::new("Window").show(ctx, |ui| {
        ui.label("Windows can be moved by dragging them.");
        ui.label("They are automatically sized based on contents.");
        ui.label("You can turn on resizing and scrolling if you like.");
        ui.label("You would normally chose either panels OR windows.");
    });
}

ここの falsetrue に変えて実行してみると、ウィンドウ内ウィンドウが表示される。

恐らく使用頻度が低いものの、「こういうこともできるんだよ」と説明するために残してあるものだと思う。


あまり使わないであろうウィンドウ内ウィンドウ

日本語の表示

日本語も特に問題なく扱えるので、そのやり方を説明する。

フォントファイルの準備

日本語フォントのファイルを準備する。例として、定番の Noto Sans をダウンロードしてくる。

https://fonts.google.com/noto/specimen/Noto+Sans+JP

zipファイルを解凍し、中の .otf ファイルを適当な場所に保存する。とりあえず今回は、src/ と同じ階層に fonts/ フォルダを作ってそこに入れた。

フォントの指定

fn setup() の中に、以下の記述を追加する。

src/app.rs
let mut fonts = FontDefinitions::default();
fonts.font_data.insert(
    "my_font".to_owned(),
    std::borrow::Cow::Borrowed(include_bytes!("../fonts/NotoSansJP-Regular.otf")),
);
fonts
    .fonts_for_family
    .get_mut(&FontFamily::Proportional)
    .unwrap()
    .insert(0, "my_font".to_owned());
_ctx.set_fonts(fonts);

またファイルの先頭に、必要な import を追加する。

src/app.rs
use eframe::{
    egui::{self, FontDefinitions, FontFamily},
    epi,
};

ついでに日本語表示を試すため、ウィジェットの文字を適当に変更する。例えば以下のようにお好みで。

src/app.rs
egui::SidePanel::left("side_panel").show(ctx, |ui| {
    ui.heading("横パネル");

    ui.horizontal(|ui| {
        ui.label("何か書いてね: ");
        ui.text_edit_singleline(label);
    });

    ui.add(egui::Slider::new(value, 0.0..=10.0).text("value"));
    if ui.button("プラスワン").clicked() {
        *value += 1.0;
    }

これで cargo run すると、普通に日本語が表示される。

また画像の通り、(ひとまず自分の環境では)特に問題なく日本語を入力可能。インライン入力ではないけど、入力している部分に近いところに表示されるため、違和感は少ない。

ただ自分の環境では、一度 egui のウィンドウで日本語入力を始めると、他のアプリで変換候補の表示がおかしくなる(すぐ消える)ような動作になるので、完全ではない。恐らくIMEの動作をフックしすぎている模様。
あと、後述の高解像度対応をすると位置がズレるのはご愛嬌。

ちなみに日本語表示(フォント変更)に関するドキュメントはここにある。等幅フォントも同じように設定できる。

https://docs.rs/egui/0.15.0/egui/struct.FontDefinitions.html

高解像度環境での表示拡大

egui は今のところ、実行環境に合わせて拡大率を変えるということをしてくれない。つまり論理的な1ピクセルは物理的な1ピクセルとして表示されるので、高解像度環境ではUI要素が小さくなる。

ここまでいくつか画面のキャプチャを載せているけど、なんかやたらと文字が小さいのはこれが原因。実行している自分が高解像度環境なので。

これを解決するには、 fn update() の中で以下のような関数を呼び出し、拡大率をセットすれば良い。

src/app.rs
ctx.set_pixels_per_point(1.5);

これで実行するとUI要素が大きくなる。

これは名前の通り、1ポイント(1/72インチ)を何ピクセルで表示するか、という値。

どんな値をセットすれば良いのかというと、理屈的には、ディスプレイの解像度(dpi)を72で割った値をセットするのが正当なはず。……なんだけど、それだとちょっと大きすぎる気がするので、色々と試してみるのが良いと思う。

まとめ

以上、サクッと egui の使い方を解説してみた。

クセがすごいものの、ツールなんかにサクッとGUIを被せたいときには有用かと思う。日本語も使えるし。

ちなみに以前、egui についてスクラップにつらつらと書き連ねていたので、古いバージョンの話ではあるもののそっちも少しは参考になるかも。即時モードについてリポジトリに書いてある内容を訳したり、色々調べたりしていた。

https://zenn.dev/tris/scraps/866dcddae90f46

Discussion

ログインするとコメントできます