全体アウトライン
本書は、プログラミング言語 Rust で画面つきのアプリケーション(GUI アプリ)を作るための人気ライブラリ「egui(イーグイ)」と、その公式フレームワーク「eframe(イーフーム)」の使い方を、基礎から応用までやさしく解説するガイドブックです。
本書の目的と対象読者
本書は、Rust の基本的な文法(変数、構造体、トレイト、参照など)をひと通り学んだことがあり、自分の手でデスクトップアプリや Web ブラウザ上で動くツールを作ってみたい人を対象としています。
従来の GUI 開発では、ボタンやテキストボックスの部品(オブジェクト)を作ってコールバック関数を登録し、画面の表示とプログラム内のデータを一致させるための複雑な管理が必要でした。特に Rust では「所有権と借用」のルールが厳格なため、従来の方式で GUI を作ろうとするとコンパイルエラーに悩まされがちです。
egui が採用している「Immediate Mode GUI(即時モード GUI)」は、この問題を根本から解決します。本書を通じて、なぜ egui なら Rust らしくシンプルに画面を作れるのかを理解し、実際に動くコードと図解を通して実用的なアプリを自在に組み立てられる力を身につけます。
章構成一覧
第1章 Immediate Mode GUIの仕組みとeguiの特徴
GUI ライブラリの大きく 2 つの設計方式である「Retained Mode(保持モード)」と「Immediate Mode(即時モード)」の違いを比べます。毎フレーム画面を描き直す Immediate Mode がなぜ Rust の所有権システムと相性が良いのか、また egui と eframe がそれぞれどのような役割を担っているのかを学びます。
第2章 開発環境の構築と最初のeframeアプリケーション
Cargo を使って新しいプロジェクトを作り、eframe を導入して最初のウィンドウを画面に表示します。アプリの状態を持つ構造体の定義、eframe::App トレイトの update メソッドの役割、そして日本語を表示したときに起きる文字化け(豆腐現象)を防ぐための日本語フォント設定手順を詳しく解説します。
第3章 基本ウィジェットとユーザー入力の扱い方
画面を構成する基本的な部品(ウィジェット)の使い方を学びます。文字を表示するラベル、クリックを受け取るボタン、文字を入力するテキストボックス、数値を調整するスライダー、チェックボックス、ラジオボタン、ドロップダウンリスト(コンボボックス)などを扱い、ユーザーの操作結果を egui::Response から受け取る方法を習得します。
第4章 レイアウトシステムとパネル・ウィンドウ構成
部品をきれいに並べるためのレイアウト機能と、画面全体を分割するパネルの使い方を解説します。上部メニューやサイドバーを作る TopBottomPanel・SidePanel・CentralPanel の配置順序のルール、横並び(horizontal)と縦並び(vertical)、スクロール領域(ScrollArea)、表形式のグリッド(Grid)、折りたたみ表示、そしてアプリ内ウィンドウ(Window)の作り方を学びます。
第5章 スタイル調整と2Dカスタム描画
アプリ全体の見た目を整えるテーマ切り替え(ダークモード・ライトモード)や、余白・角丸・配色のカスタマイズ方法を解説します。さらに、画像の読み込み表示や、egui::Painter を使って直線・四角形・円・曲線を自由に描き、マウス操作に反応するお絵かきキャンバスやグラフを作る方法を学びます。
第6章 状態永続化と非同期・マルチスレッド連携
アプリを閉じてもデータを残すための自動保存(Persistence)機能と、重い処理を実行しても画面が固まらない(フリーズしない)ためのマルチスレッド連携を解説します。std::sync::mpsc チャネルを使って別スレッドから進捗状況を受け取り、プログレスバーやスピナーを更新する実践的なパターンを身につけます。
第7章 実践:タスク管理アプリの開発とWeb展開
これまでの章で学んだ知識をすべて組み合わせて、実用的な「タスク管理(ToDo)アプリ」をゼロから完成させます。さらに、表作成などを支援する公式拡張クレート(egui_extras や egui_plot)の紹介と、同じ Rust コードを WebAssembly(WASM)にビルドして Web ブラウザ上で動かす手順を解説します。
1. Immediate Mode GUIの仕組みとeguiの特徴
GUIにおける2つの設計方式
パソコンやスマートフォンの画面にボタンや入力欄を表示するプログラムを「GUI(グラフィカル・ユーザー・インターフェース)」と呼びます。GUI ライブラリには、画面の部品をどのように管理して描画するかによって「Retained Mode(保持モード)」と「Immediate Mode(即時モード)」という 2 つの設計方式があります。
▸ Retained Mode(保持モード)の仕組み
HTML/DOM(Web ページ)、Windows Forms、Qt、GTK など、従来の多くの GUI ツールキットは Retained Mode を採用しています。この方式では、プログラムの起動時に「ボタンオブジェクト」や「ラベルオブジェクト」を作成し、GUI ライブラリ側に預けて保持(Retain)してもらいます。
画面の数字を 1 つ増やしたいときは、プログラム内の変数を増やすだけでなく、あらかじめ作っておいたラベルオブジェクトを探し出して label.set_text("1") のように書き換える命令を送る必要があります。また、ボタンが押されたときの処理は「コールバック関数(イベントハンドラ)」としてボタンに登録しておきます。
▸ Immediate Mode(即時モード)の仕組み
egui が採用している Immediate Mode GUI(IMGUI)では、GUI ライブラリ側にボタンやラベルのオブジェクトを保持させません。その代わり、1 秒間に 60 回(または画面の更新が必要なタイミングごと)実行される描画ループの中で、毎回ゼロから「いま画面にあるべき部品」をコードで記述します。
flowchart TD
subgraph Retained["Retained Mode(保持モード)"]
R1["1. 初期化時にボタンやラベルのオブジェクトを生成"] --> R2["2. コールバック関数を部品に登録"]
R2 --> R3["3. イベント発生時にコールバックが呼ばれる"]
R3 --> R4["4. データの更新と画面部品の書き換えを手動で同期"]
end
subgraph Immediate["Immediate Mode(即時モード:egui)"]
I1["1. アプリ自身のデータ(構造体)だけを持つ"] --> I2["2. 毎フレームの描画関数(update)が呼ばれる"]
I2 --> I3["3. 現在のデータを見ながらUI部品を描画し、その場で入力判定"]
I3 --> I1
end
たとえば「ボタンを描画し、もし今のフレームで押されていたらカウンターを 1 増やす」という処理を、1 つの if 文としてその場に直接書きます。次のフレームでは増えた後のカウンターの値がそのまま画面に描かれるため、データと画面表示のズレが原理的に発生しません。
なぜRustではImmediate Modeが選ばれるのか
Rust でプログラムを書くとき、最も特徴的なルールが「所有権(Ownership)」と「借用(Borrowing)」です。Rust のコンパイラは、同じデータに対して複数の場所から同時に書き換えが行われないように厳しくチェックします。
Retained Mode の GUI を Rust で作ろうとすると、この所有権のルールが大きな壁になります。画面のツリー構造を管理する GUI ライブラリ側と、クリックされたときにデータを書き換えるコールバック関数側の両方が、同じデータの参照を長期間持ち続けようとするためです。結果として、Rc<RefCell<T>> や Arc<Mutex<T>> といった複雑な型を大量に組み合わせなければならず、コードが読みにくくなります。
一方、Immediate Mode である egui では、コールバック関数を登録する必要がありません。毎フレーム呼び出される update メソッドの中で、アプリ自身の構造体(&mut self)を上から下へ順番に読み書きするだけです。借用はその場限りで即座に終わるため、Rust の所有権ルールとぶつかることなく、普通の Rust コードと同じ感覚ですっきりと画面を作ることができます。
#![allow(unused)]
fn main() {
// Immediate Mode (egui) での直感的な記述イメージ
if ui.button("カウントアップ").clicked() {
self.counter += 1;
}
ui.label(format!("現在の値: {}", self.counter));
}
上のわずか 4 行のコードだけで、ボタンの配置、クリックの判定、データの更新、そして最新データの表示までが完結します。
eguiとeframeの役割分担
Rust で egui を使ったアプリを作るときは、egui と eframe という 2 つのクレート(ライブラリ)が連携して動きます。それぞれの役割を知っておくと、公式ドキュメントを読むときに迷いません。
| クレート名 | 役割と担当範囲 |
|---|---|
egui | GUI のコアライブラリ。ボタンや文字の配置計算、マウス位置との当たり判定、2D 図形の描画データ(三角形の頂点リスト)の生成だけを担当します。OS のウィンドウ管理や GPU の直接操作は行いません。 |
eframe | egui を動かすための公式アプリケーションフレームワーク。OS のウィンドウ作成(winit)や Web ブラウザの Canvas 取得、そして GPU を使った画面への描画(glow や wgpu)をまとめて引き受けます。 |
flowchart LR
App["あなたのRustコード<br/>(eframe::App を実装)"] --> Eframe["eframe<br/>(ウィンドウ管理・描画統合)"]
Eframe --> Egui["egui<br/>(レイアウト計算・UI部品・入力判定)"]
Eframe --> Platform["OSウィンドウ(Windows / macOS / Linux)<br/>または Webブラウザ(WebAssembly)"]
自分で一からゲームエンジン(Bevy など)に組み込むような特殊なケースを除き、一般的なデスクトップアプリや Web アプリを作る場合は、Cargo.toml に eframe を追加するだけで egui の機能もすべて利用できます。eframe の中で egui が再エクスポート(eframe::egui)されているためです。
Immediate Modeのメリットと注意点
egui を採用することで得られるメリットと、開発時に知っておくべき注意点を整理します。
▸ メリット1:状態同期のバグが起きない
画面の表示は常に「アプリ構造体の現在のフィールド値」から直接作られます。「データは変わったのに画面の表示が古いまま」「画面のチェックを外したのに内部フラグが残っている」といった、GUI 開発で最も多い同期バグが起きません。
▸ メリット2:UIの動的な出し分けが簡単
「チェックボックスが ON のときだけ詳細設定欄を表示する」といった処理は、特別な表示・非表示メソッドを呼ぶ必要がありません。通常の if 文で囲むだけで、条件を満たすフレームだけその部品が描画されます。リストの表示も for ループを回すだけです。
▸ メリット3:ネイティブとWebの両方で動く
egui は OS 固有の部品(Windows の標準ボタンなど)を使わず、すべての部品を自前の 2D グラフィックスとして描画します。そのため、Windows、macOS、Linux、そして Web ブラウザ(WebAssembly)のどの環境で動かしても、1 ピクセルの狂いもなく同じ見た目と動作を再現できます。
▸ 注意点1:updateメソッド内で重い処理をしない
update メソッドは画面を描き直すたびに呼ばれます。この中で数秒かかるファイル読み込みやネットワーク通信を直接行うと、その間は次のフレームが描かれず、ウィンドウが固まってしまいます。重い処理は第 6 章で学ぶように別スレッドへ逃がす必要があります。
▸ 注意点2:前フレームのレイアウト結果を使う処理
Immediate Mode は上から順番に部品を置いていくため、「まだ描画していない下側の部品のサイズに合わせて、上側の部品を中央揃えにする」といった処理が苦手です。ただし、egui にはパネル分割やグリッド、2 パスレイアウト(前のフレームのサイズを記憶して調整する仕組み)が用意されており、実用上のレイアウトで困ることはほとんどありません。
次章では、実際に Cargo プロジェクトを作成し、最初の eframe アプリケーションを動かしながら日本語表示の設定までを行います。
2. 開発環境の構築と最初のeframeアプリケーション
プロジェクトの作成と依存クレートの追加
手元のパソコンに Rust の開発環境(rustup と cargo)が入っていれば、すぐに egui の開発を始められます。まずはターミナル(Windows なら PowerShell やコマンドプロンプト)を開き、新しいプロジェクトを作成します。
cargo new egui_hello
cd egui_hello
作成されたディレクトリの中にある Cargo.toml を開き、[dependencies] セクションに eframe を追加します。本書では安定して広く使われている 0.29 系をベースに解説します。
[package]
name = "egui_hello"
version = "0.1.0"
edition = "2021"
[dependencies]
eframe = "0.29"
eframe を追加すると、その内部で自動的に同じバージョンの egui クレートも読み込まれます。ソースコードの中では use eframe::egui; と書くことで、egui のすべての機能にアクセスできます。
最小構成のHello Worldアプリ
src/main.rs を以下のコードに書き換えて、最初のウィンドウを表示してみましょう。
use eframe::egui;
fn main() -> eframe::Result<()> {
// ウィンドウの初期サイズなどのオプションを設定する
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_inner_size([400.0, 250.0]),
..Default::default()
};
// アプリケーションを起動する
eframe::run_native(
"My First egui App",
options,
Box::new(|_cc| Ok(Box::<MyApp>::default())),
)
}
// アプリの状態(データ)を保持する構造体
struct MyApp {
name: String,
age: u32,
}
// 初期値を設定する Default トレイトの実装
impl Default for MyApp {
fn default() -> Self {
Self {
name: "Taro".to_owned(),
age: 20,
}
}
}
// eframe::App トレイトを実装して画面の描画処理を定義する
impl eframe::App for MyApp {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
egui::CentralPanel::default().show(ctx, |ui| {
ui.heading("Hello, egui!");
ui.horizontal(|ui| {
ui.label("Your name:");
ui.text_edit_singleline(&mut self.name);
});
ui.add(egui::Slider::new(&mut self.age, 0..=120).text("age"));
if ui.button("Increment age").clicked() {
self.age += 1;
}
ui.label(format!("Hello '{}', age {}", self.name, self.age));
});
}
}
ファイルを保存したら、ターミナルで cargo run を実行します。初回は関連ライブラリのダウンロードとコンパイルが行われるため 1〜2 分ほどかかりますが、2 回目以降は変更した部分だけが数秒でビルドされます。
実行すると幅 400 ピクセル、高さ 250 ピクセルのウィンドウが立ち上がり、名前の入力欄と年齢のスライダー、そしてボタンが表示されます。
eframeのライフサイクルとupdateメソッド
先ほどのコードがどのような流れで動いているのかを順番に確認します。
sequenceDiagram
participant Main as main関数
participant Eframe as eframe::run_native
participant App as MyApp構造体
Main->>Eframe: 1. ウィンドウタイトル・NativeOptions・生成クロージャを渡す
Eframe->>App: 2. 起動時に1回だけ MyApp::default()(または new)を呼ぶ
loop アプリが閉じられるまで繰り返し
Eframe->>App: 3. update(&mut self, ctx, frame) を呼び出す
App->>Eframe: 4. CentralPanel 等を通じて UI 部品を配置・入力判定
end
▸ NativeOptions と ViewportBuilder
main 関数で作っている eframe::NativeOptions は、OS のウィンドウに関する設定をまとめた構造体です。ウィンドウの初期サイズや最小サイズ、アイコン、最前面表示(Always on top)などは、viewport フィールドに渡す egui::ViewportBuilder のメソッドチェーンで設定します。
#![allow(unused)]
fn main() {
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_inner_size([600.0, 400.0]) // 初期サイズ(幅, 高さ)
.with_min_inner_size([300.0, 200.0]) // 最小サイズ
.with_resizable(true), // リサイズ許可
..Default::default()
};
}
▸ run_native の第3引数(アプリ生成クロージャ)
eframe::run_native の第 3 引数には、ウィンドウの準備が整った直後に 1 回だけ呼ばれるクロージャ(無名関数)を渡します。引数の _cc は eframe::CreationContext 型で、フォントの初期登録や前回保存したデータの読み込みに使います。
▸ update メソッドと ctx / ui の違い
eframe::App トレイトで必ず実装するのが update メソッドです。ここで登場する ctx(&egui::Context)と、パネルの中で登場する ui(&mut egui::Ui)は、egui において最も重要な 2 つの変数です。
| 変数名と型 | 役割の違い |
|---|---|
ctx: &egui::Context | アプリ全体を管理する司令塔です。画面全体のテーマ変更、フォント設定、パネルやサブウィンドウの配置、キーボードやマウスの全体入力状態の取得に使います。 |
ui: &mut egui::Ui | パネルやウィンドウなど「特定の領域の中」を表す変数です。ボタン(ui.button)やラベル(ui.label)などの具体的なウィジェットは、すべてこの ui に対して追加します。 |
つまり、最初に ctx を使って CentralPanel(中央パネル)などの大きな箱を作り、その箱が渡してくれる ui を使って中にボタンや文字を並べていく、というのが egui の基本パターンです。
日本語フォントの設定と文字化け対策
先ほどの Hello World アプリの名前入力欄に「太郎」と日本語を入力したり、ラベルの文字列を日本語に変えたりすると、文字が「□□」のような四角い記号(通称:豆腐)になってしまいます。
これは、egui のファイルサイズを小さく保つために、標準では英語(ラテン文字)と一部の記号しか含まれない軽量フォントが内蔵されているためです。日本語を表示するには、アプリ起動時に日本語フォント(.ttf や .otf)を読み込んで登録します。
▸ 推奨される方法:フォントファイルをプロジェクトに含める
最も確実でどの OS でも同じ見た目になる方法は、NotoSansJP-Regular.ttf(Google Fonts 等から無料で入手可能)などの日本語フォントファイルをプロジェクト内の assets/ フォルダなどに置き、include_bytes! マクロで実行ファイルに埋め込む方法です。
一方、手元ですぐに試したい場合は、パソコンの OS に最初から入っている標準の日本語フォントファイルを読み込むこともできます。以下のコードは、アプリ起動時のコンストラクタ MyApp::new(cc) の中で日本語フォントを追加する完全な実装例です。
use eframe::egui;
fn main() -> eframe::Result<()> {
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default().with_inner_size([450.0, 300.0]),
..Default::default()
};
eframe::run_native(
"日本語表示サンプル",
options,
// CreationContext (cc) を MyApp::new に渡して初期化する
Box::new(|cc| Ok(Box::new(MyApp::new(cc)))),
)
}
struct MyApp {
message: String,
}
impl MyApp {
fn new(cc: &eframe::CreationContext<'_>) -> Self {
// 起動時に1回だけフォント設定を行う
setup_custom_fonts(&cc.egui_ctx);
Self {
message: "こんにちは、eguiの世界!".to_owned(),
}
}
}
fn setup_custom_fonts(ctx: &egui::Context) {
// 1. デフォルトのフォント定義を取得する
let mut fonts = egui::FontDefinitions::default();
// 2. 日本語フォントデータを読み込む
// ※ 配布用アプリでは FontData::from_static(include_bytes!("../assets/NotoSansJP-Regular.ttf")) を推奨
// ここでは手軽な動作確認用として Windows 標準のメイリオ(または游ゴシック)を読み込む例を示す
if let Ok(font_bytes) = std::fs::read("C:/Windows/Fonts/meiryo.ttc") {
fonts.font_data.insert(
"japanese_font".to_owned(),
egui::FontData::from_owned(font_bytes).into(),
);
// 3. プロポーショナル(通常文字)の一番優先される先頭(インデックス0)に追加する
fonts
.families
.entry(egui::FontFamily::Proportional)
.or_default()
.insert(0, "japanese_font".to_owned());
// 4. モノスペース(等幅文字)の末尾(フォールバック)にも追加する
fonts
.families
.entry(egui::FontFamily::Monospace)
.or_default()
.push("japanese_font".to_owned());
// 5. コンテキストに新しいフォント定義を反映する
ctx.set_fonts(fonts);
}
}
impl eframe::App for MyApp {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
egui::CentralPanel::default().show(ctx, |ui| {
ui.heading("日本語フォントの表示テスト");
ui.separator();
ui.label("日本語が文字化けせずにきれいに表示されます。");
ui.text_edit_singleline(&mut self.message);
ui.label(format!("入力内容: {}", self.message));
});
}
}
setup_custom_fonts 関数は、毎フレーム呼ばれる update の中ではなく、起動時に 1 回だけ呼ばれる MyApp::new の中で実行するのが鉄則です。毎フレームフォントを読み直すと動作が極端に重くなってしまうためです。
なお、macOS でシステムフォントを試す場合は /System/Library/Fonts/ヒラギノ角ゴシック W3.ttc、Ubuntu などの Linux では /usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc などを指定できます。他人に配布するアプリを作る際は、プロジェクト内に .ttf ファイルを配置して egui::FontData::from_static(include_bytes!("...")) を使うと、どの OS でも確実に動作します。
次章では、画面を彩るさまざまな基本ウィジェット(ボタン、入力欄、スライダー、選択リストなど)の詳しい使い方を学びます。
3. 基本ウィジェットとユーザー入力の扱い方
テキスト表示とRichTextによる装飾
画面に文字を表示するための最も基本的な部品がラベル(label)です。egui では用途に合わせていくつかのショートカットメソッドが用意されています。
#![allow(unused)]
fn main() {
ui.heading("大きな見出し文字");
ui.label("通常の本文テキスト");
ui.monospace("let x = 42; // 等幅フォント(コード表示向き)");
ui.small("小さな補足テキスト");
}
さらに、文字の大きさや色、太字、下線、背景色などを細かく指定したいときは、文字列を egui::RichText で包んで渡します。
#![allow(unused)]
fn main() {
use eframe::egui::{self, Color32, RichText};
ui.label(
RichText::new("重要なお知らせ")
.size(20.0) // フォントサイズ(ポイント)
.color(Color32::from_rgb(255, 100, 100)) // 文字色(RGB指定)
.strong(), // 太字(環境に応じた強調表示)
);
ui.label(
RichText::new("ハイライト付きの文字")
.background_color(Color32::DARK_BLUE)
.color(Color32::WHITE)
.italics() // 斜体
.underline(), // 下線
);
}
RichText は ui.label() だけでなく、ui.button(RichText::new("削除").color(Color32::RED)) のように、ボタンやチェックボックスの見出し文字にもそのまま使うことができます。
ボタンとResponseによるイベント判定
egui に配置するすべてのウィジェットは、描画メソッドの戻り値として egui::Response という構造体を返します。この Response の中に、「今この部品がクリックされたか」「マウスカーソルが乗っているか」「値が変更されたか」といったすべての情報が詰まっています。
flowchart LR
Widget["ui.button("保存")"] --> Resp["戻り値: egui::Response"]
Resp --> C1[".clicked() : 左クリックされたか"]
Resp --> C2[".secondary_clicked() : 右クリックされたか"]
Resp --> C3[".double_clicked() : ダブルクリックされたか"]
Resp --> C4[".hovered() : マウスが乗っているか"]
Resp --> C5[".changed() : 内部の値が書き換わったか"]
ボタンのさまざまな操作を判定するコード例を見てみましょう。
#![allow(unused)]
fn main() {
let response = ui.button("ここをクリック");
if response.clicked() {
println!("左クリックされました!");
}
if response.secondary_clicked() {
println!("右クリックされました!");
}
if response.double_clicked() {
println!("ダブルクリックされました!");
}
if response.hovered() {
ui.label("いまボタンの上にマウスがあります");
}
}
多くの場合は変数 response に入れず、if ui.button("実行").clicked() { ... } のように直接 if 文につなげて書きます。
テキスト入力と可変参照のバインディング
ユーザーから文字を入力してもらうには、String 型のフィールドの可変参照(&mut String)をテキスト入力ウィジェットに渡します。
▸ 1行入力と複数行入力のショートカット
最も簡単な方法は、ui.text_edit_singleline(1 行)と ui.text_edit_multiline(複数行)を使うことです。
#![allow(unused)]
fn main() {
ui.label("タイトル:");
let res = ui.text_edit_singleline(&mut self.title);
if res.changed() {
// 文字が1文字でも入力・削除された瞬間に実行される
println!("現在のタイトル: {}", self.title);
}
ui.label("メモ本文:");
ui.text_edit_multiline(&mut self.memo);
}
▸ TextEdit構造体による詳細な設定
入力欄が空のときに薄い文字でヒントを表示する「プレースホルダー(hint_text)」や、パスワード入力用の伏せ字(password(true))、横幅の指定などを行いたい場合は、egui::TextEdit 構造体を作って ui.add() に渡します。
#![allow(unused)]
fn main() {
// ヒント文字つきの1行入力
ui.add(
egui::TextEdit::singleline(&mut self.username)
.hint_text("ユーザー名を入力してください...")
.desired_width(200.0),
);
// パスワード入力(文字が ● で隠れる)
ui.add(
egui::TextEdit::singleline(&mut self.password)
.password(true)
.hint_text("パスワード"),
);
}
また、1 行入力欄で Enter キーが押された瞬間を検知したいときは、以下のように判定できます。
#![allow(unused)]
fn main() {
let response = ui.text_edit_singleline(&mut self.input_text);
if response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
// Enterキーで入力を確定したときの処理
self.submit();
}
}
数値入力と選択ウィジェット
数値の調整や、複数の選択肢から選ばせるための代表的なウィジェットをまとめます。
▸ Slider(スライダー)と DragValue(ドラッグ数値入力)
数値を直感的に変えるには egui::Slider や egui::DragValue に数値変数(i32、f32、f64 など)の可変参照を渡します。
#![allow(unused)]
fn main() {
// 0.0 から 100.0 の範囲で動かすスライダー(単位サフィックスつき)
ui.add(
egui::Slider::new(&mut self.volume, 0.0..=100.0)
.text("音量")
.suffix("%"),
);
// 左右にドラッグするかダブルクリックで直接数値入力できる部品
ui.horizontal(|ui| {
ui.label("X座標:");
ui.add(egui::DragValue::new(&mut self.pos_x).speed(0.5));
});
}
▸ Checkbox(チェックボックス)
ON / OFF の真偽値(bool)を切り替えるには ui.checkbox を使います。
#![allow(unused)]
fn main() {
ui.checkbox(&mut self.enable_notification, "通知を有効にする");
}
▸ RadioButton(ラジオボタン)と SelectableValue
複数の選択肢から 1 つだけを選ばせるときは、Rust の列挙型(enum)に PartialEq トレイトを派生(#[derive(PartialEq)])させておき、ui.radio_value またはボタン風の ui.selectable_value を使います。
#![allow(unused)]
fn main() {
#[derive(PartialEq, Clone, Copy, Debug)]
enum Priority {
Low,
Medium,
High,
}
// update メソッド内での描画
ui.horizontal(|ui| {
ui.label("優先度:");
ui.radio_value(&mut self.priority, Priority::Low, "低");
ui.radio_value(&mut self.priority, Priority::Medium, "中");
ui.radio_value(&mut self.priority, Priority::High, "高");
});
}
ui.radio_value の代わりに ui.selectable_value を使うと、タブ切り替えボタンのような見た目になります。
▸ ComboBox(ドロップダウンリスト)
選択肢が多くて画面の場所を節約したいときは、egui::ComboBox を使ってクリック時に開くドロップダウンメニューを作ります。
#![allow(unused)]
fn main() {
egui::ComboBox::from_label("優先度の選択")
.selected_text(format!("{:?}", self.priority))
.show_ui(ui, |ui| {
ui.selectable_value(&mut self.priority, Priority::Low, "低 (Low)");
ui.selectable_value(&mut self.priority, Priority::Medium, "中 (Medium)");
ui.selectable_value(&mut self.priority, Priority::High, "高 (High)");
});
}
ツールチップと有効・無効状態の制御
実用的なアプリでは、「なぜ今このボタンを押せないのか」をユーザーに伝えたり、条件が揃うまでボタンをグレーアウト(無効化)したりする工夫が欠かせません。
▸ ホバー時のツールチップ表示(on_hover_text)
Response に対して .on_hover_text("説明文") をつなげるだけで、マウスカーソルを乗せたときに補足説明のポップアップ(ツールチップ)を表示できます。
#![allow(unused)]
fn main() {
if ui
.button("設定をリセット")
.on_hover_text("すべての設定を初期状態に戻します(元に戻せません)")
.clicked()
{
self.reset();
}
}
▸ ウィジェットの有効・無効の切り替え(add_enabled / add_enabled_ui)
たとえば「テキスト欄が空でないときだけ送信ボタンを押せるようにする」場合は、ui.add_enabled または複数の部品をまとめて無効化できる ui.add_enabled_ui を使います。
#![allow(unused)]
fn main() {
let can_submit = !self.username.trim().is_empty();
if ui
.add_enabled(can_submit, egui::Button::new("送信する"))
.on_disabled_hover_text("ユーザー名を入力すると送信できます")
.clicked()
{
println!("{} さんを登録しました", self.username);
}
}
on_disabled_hover_text を組み合わせると、ボタンが無効化されているときにマウスを乗せた人に対して「どうすれば押せるようになるか」を親切に案内できます。
次章では、これらのウィジェットを画面上で美しく整列させるためのレイアウトシステムと、パネル・ウィンドウによる画面分割の仕組みを解説します。
4. レイアウトシステムとパネル・ウィンドウ構成
画面全体を分割するパネルと配置順序のルール
本格的なデスクトップアプリでは、画面の一番上にメニューバー、一番下にステータスバー、左側にナビゲーション用のサイドバー、そして残った中央にメインの作業領域を配置するのが一般的です。egui ではこれらを「パネル(Panel)」と呼びます。
| パネル名 | 配置場所と主な用途 |
|---|---|
egui::TopBottomPanel::top | 画面の最上部。メニューバーやツールバーの配置に使います。 |
egui::TopBottomPanel::bottom | 画面の最下部。ステータスバーや進捗表示の配置に使います。 |
egui::SidePanel::left / right | 画面の左端または右端。フォルダツリーや設定一覧の配置に使います。境界線をドラッグして幅を変えられます。 |
egui::CentralPanel | 上下左右のパネルを置いた後に残った中央の領域すべてを埋めます。 |
flowchart TD
Screen["ウィンドウ全体(ctx)"] --> Top["1. TopBottomPanel::top(上部メニュー)"]
Top --> Bottom["2. TopBottomPanel::bottom(下部ステータスバー)"]
Bottom --> Side["3. SidePanel::left(左サイドバー)"]
Side --> Central["4. CentralPanel(残りの中央領域すべて)"]
▸ パネル配置の鉄則:CentralPanelは必ず最後に呼ぶ
egui のパネル配置には、必ず守らなければならない重要なルールが 1 つあります。それは「CentralPanel は必ずすべてのパネルの一番最後に呼び出す」というルールです。
egui のパネルは、呼ばれた順番に画面の端から場所を切り取っていきます。CentralPanel は「残っている空きスペースをすべて使い切る」という性質を持っているため、最初に CentralPanel を呼んでしまうと画面全体が埋め尽くされ、後から SidePanel などを置くと重なってしまいます。
#![allow(unused)]
fn main() {
impl eframe::App for MyApp {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
// 1. 最上部のパネル
egui::TopBottomPanel::top("top_panel").show(ctx, |ui| {
egui::menu::bar(ui, |ui| {
ui.menu_button("File", |ui| {
if ui.button("Quit").clicked() {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
});
});
});
// 2. 最下部のパネル
egui::TopBottomPanel::bottom("bottom_panel").show(ctx, |ui| {
ui.label("Ready");
});
// 3. 左側のサイドパネル(初期幅 180px)
egui::SidePanel::left("left_panel")
.default_width(180.0)
.show(ctx, |ui| {
ui.heading("Menu");
ui.separator();
ui.label("Item 1");
ui.label("Item 2");
});
// 4. 最後に残りの中央領域をすべて埋める CentralPanel
egui::CentralPanel::default().show(ctx, |ui| {
ui.heading("Main Content Area");
ui.label("ここがメインの作業領域になります。");
});
}
}
}
TopBottomPanel と SidePanel の第 1 引数に渡している文字列("top_panel" や "left_panel")は、パネルのサイズ変更状態などを egui が記憶するための識別 ID です。同じ画面内で重複しない名前を付けます。
横並びと縦並びのレイアウト制御
パネルの中(ui)では、何もしなければウィジェットは上から下へ「縦並び」で配置されていきます。これを横並びにしたり、右寄せにしたりする方法を見ていきましょう。
▸ ui.horizontal と ui.vertical
部品を横に並べたいときは ui.horizontal(|ui| { ... }) で囲みます。横並びの途中で再び縦並びにしたいときは ui.vertical(|ui| { ... }) を入れ子(ネスト)にできます。
#![allow(unused)]
fn main() {
ui.horizontal(|ui| {
ui.label("検索ワード:");
ui.text_edit_singleline(&mut self.query);
if ui.button("検索").clicked() {
self.search();
}
});
}
また、横幅がいっぱいになったときに自動で次の行へ折り返したい場合は、ui.horizontal_wrapped(|ui| { ... }) を使います。タグの一覧ボタンなどを並べるときに便利です。
▸ Layout構造体による右寄せや中央揃え
「画面の右端にボタンを寄せたい」「中央に文字を配置したい」という場合は、egui::Layout と ui.with_layout を組み合わせます。
#![allow(unused)]
fn main() {
// 横並びの行の中で、残りの右側部分を使って右から左へ部品を配置する
ui.horizontal(|ui| {
ui.label("左側のタイトル");
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("削除").clicked() {
// 一番右端に配置される
}
if ui.button("編集").clicked() {
// 「削除」の左隣に配置される
}
});
});
// 縦方向に進みつつ、水平方向の中央に揃える
ui.with_layout(egui::Layout::top_down(egui::Align::Center), |ui| {
ui.heading("中央揃えのタイトル");
ui.button("中央のボタン");
});
}
空白・区切り線とグループ化コンテナ
画面の情報量が増えてきたときに、見た目を整理して読みやすくするための部品です。
#![allow(unused)]
fn main() {
// 水平の区切り線を引く(horizontal の中では垂直の区切り線になる)
ui.separator();
// 指定したピクセル数(ここでは 16.0px)だけ空白を空ける
ui.add_space(16.0);
// 枠線で囲まれたグループ領域を作る
ui.group(|ui| {
ui.label("アカウント設定");
ui.checkbox(&mut self.is_public, "プロフィールを公開する");
});
// クリックで開閉できる折りたたみ領域を作る
egui::CollapsingHeader::new("詳細オプション")
.default_open(false)
.show(ui, |ui| {
ui.label("普段は隠しておきたい細かい設定項目をここに入れます。");
ui.add(egui::Slider::new(&mut self.timeout, 1..=60).text("タイムアウト(秒)"));
});
}
グリッド配置とスクロール領域
▸ Grid(表・フォーム形式の整列)
設定画面や入力フォームを作るときに、ui.horizontal だけを並べると、ラベルの文字数の違いによって入力欄の左端がガタガタにずれてしまいます。このような場面では egui::Grid を使うと、列の幅が自動的に揃います。
#![allow(unused)]
fn main() {
egui::Grid::new("profile_grid")
.num_columns(2)
.spacing([20.0, 8.0]) // [列の間隔, 行の間隔]
.striped(true) // 1行おきに背景色を交互に変える
.show(ui, |ui| {
ui.label("氏名:");
ui.text_edit_singleline(&mut self.name);
ui.end_row(); // ここで1行目を終えて次の行へ進む
ui.label("メールアドレス:");
ui.text_edit_singleline(&mut self.email);
ui.end_row();
ui.label("年齢:");
ui.add(egui::DragValue::new(&mut self.age));
ui.end_row();
});
}
Grid では、1 行分の部品を置いたあとに必ず ui.end_row() を呼ぶことで改行されます。
▸ ScrollArea(スクロール可能な領域)
通常のパネルは、中身が増えても自動的にスクロールバーを出してくれません。長い文章やたくさんのリストを表示するときは、egui::ScrollArea で囲みます。
#![allow(unused)]
fn main() {
// 縦方向のスクロール領域(高さを最大 200px に制限する例)
egui::ScrollArea::vertical()
.max_height(200.0)
.show(ui, |ui| {
for i in 1..=50 {
ui.label(format!("リスト項目 #{i}"));
}
});
}
なお、何万件もの大量データを表示する場合は、画面に見えている行だけを計算して高速に描画する ScrollArea::vertical().show_rows(ui, row_height, total_rows, |ui, row_range| { ... }) という仮想スクロール用メソッドも用意されています。
アプリ内サブウィンドウの活用
パネルが画面に固定された領域であるのに対し、egui::Window はアプリの画面内でユーザーがマウスで自由にドラッグ移動したり、折りたたんだり、サイズを変えたりできる「サブウィンドウ(フローティングウィンドウ)」を作ります。
#![allow(unused)]
fn main() {
// self.show_settings が true のときだけ表示され、右上の「×」ボタンで閉じられるウィンドウ
egui::Window::new("環境設定")
.open(&mut self.show_settings) // ×ボタンが押されると自動で false になる
.resizable(true)
.default_size([280.0, 180.0])
.show(ctx, |ui| {
ui.label("ウィンドウをドラッグして移動できます。");
ui.checkbox(&mut self.auto_save, "自動保存を有効にする");
});
}
.open(&mut self.show_settings) に bool 変数の可変参照を渡しておくと、ウィンドウの右上に閉じるボタン(×)が自動で表示され、ユーザーがクリックすると self.show_settings が false に切り替わってウィンドウが閉じます。
次章では、アプリ全体の配色テーマや余白をカスタマイズする方法と、Painter を使って自由な 2D 図形やお絵かきキャンバスを描画する方法を学びます。
5. スタイル調整と2Dカスタム描画
テーマの切り替え(ダークモードとライトモード)
egui は最初から「ダークモード(暗い背景)」と「ライトモード(明るい背景)」の両方の配色テーマを備えています。アプリ全体の配色を切り替えるには、ctx.set_visuals メソッドに egui::Visuals::dark() または egui::Visuals::light() を渡します。
#![allow(unused)]
fn main() {
ui.horizontal(|ui| {
ui.label("画面テーマ:");
if ui.button("🌙 ダーク").clicked() {
ctx.set_visuals(egui::Visuals::dark());
}
if ui.button("☀ ライト").clicked() {
ctx.set_visuals(egui::Visuals::light());
}
});
}
また、標準で用意されているテーマ切り替えボタンを置きたい場合は、egui::widgets::global_theme_preference_buttons(ui) を呼ぶだけで、ライト・ダーク・システム設定追従の 3 つの切り替えボタンを 1 行で配置できます。
StyleとVisualsによる見た目のカスタマイズ
ボタンの大きさや部品同士のすき間、角の丸み、背景色などを細かく調整したいときは、余白やサイズを管理する Spacing と、色や角丸を管理する Visuals をも含んだ egui::Style を書き換えます。
▸ アプリ全体のスタイルを変更する(ctx.style_mut)
起動時(MyApp::new)などに ctx.style_mut を呼ぶと、アプリ全体の標準デザインを一括でカスタマイズできます。
#![allow(unused)]
fn main() {
fn customize_style(ctx: &egui::Context) {
ctx.style_mut(|style| {
// 1. 部品と部品の間のすき間(横, 縦)を広げる
style.spacing.item_spacing = egui::vec2(10.0, 8.0);
// 2. ボタンの内側の余白(横, 縦)を大きくして押しやすくする
style.spacing.button_padding = egui::vec2(12.0, 6.0);
// 3. すべてのウィジェットの角を丸くする(半径 8px)
let rounding = egui::Rounding::same(8.0);
style.visuals.widgets.noninteractive.rounding = rounding;
style.visuals.widgets.inactive.rounding = rounding;
style.visuals.widgets.hovered.rounding = rounding;
style.visuals.widgets.active.rounding = rounding;
});
}
}
style.visuals.widgets の中には、部品の状態ごとに 4 つの設定(操作できない部品 noninteractive、通常時 inactive、マウスが乗ったとき hovered、押されているとき active)が分かれており、それぞれの背景色(bg_fill)や枠線(bg_stroke)、角丸(rounding)を自由に変更できます。
▸ 特定の領域だけスタイルを変える(ui.scope)
「この警告枠の中だけボタンを赤くしたい」「このツールバーの中だけ余白をゼロにしたい」という場合は、ui.scope(|ui| { ... }) で囲んで ui.style_mut() を書き換えます。scope を抜けると自動的に元のスタイルに戻るため、他の場所に影響を与えません。
#![allow(unused)]
fn main() {
ui.scope(|ui| {
// このスコープの中だけ、部品の間隔をゼロにする
ui.spacing_mut().item_spacing = egui::vec2(0.0, 0.0);
let _ = ui.button("左");
let _ = ui.button("中央");
let _ = ui.button("右");
});
}
画像ファイルの読み込みと表示
アプリの中にアイコンやイラスト、写真を表示するには、公式の補助クレートである egui_extras の画像ローダー機能を使います。
まず Cargo.toml に egui_extras(all_loaders フィーチャー有効)と、対応したい画像形式を指定した image クレートを追加します。
[dependencies]
eframe = "0.29"
egui_extras = { version = "0.29", features = ["all_loaders"] }
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
次に、アプリ起動時に 1 回だけ egui_extras::install_image_loaders(&cc.egui_ctx) を呼び出します。あとは egui::include_image! マクロやファイルパス・URL を使って ui.image で簡単に画像を表示できます。
#![allow(unused)]
fn main() {
// 起動時(MyApp::new の中)に1回だけ呼ぶ
egui_extras::install_image_loaders(&cc.egui_ctx);
// update メソッド内で画像を表示する(横幅を最大 200px に制限し、角を丸める例)
ui.add(
egui::Image::new(egui::include_image!("../assets/logo.png"))
.max_width(200.0)
.rounding(10.0),
);
}
Painterを使った2D図形の直接描画
ボタンやラベルといった既存のウィジェットだけでは表現できないグラフ、図解、ゲーム画面、カスタムメーターなどを作りたいときは、egui::Painter を使って画面に直線や円、四角形を直接描くことができます。
flowchart LR
Step1["1. ui.allocate_painter で<br/>描画サイズとマウス反応領域を確保"] --> Step2["2. 戻り値から<br/>Response(座標・入力)と Painter を取得"]
Step2 --> Step3["3. painter.line_segment や<br/>painter.circle_filled で図形を描画"]
カスタム描画を行うときは、まず ui.allocate_painter(サイズ, 検知したいマウス操作) を呼び出してキャンバス領域を確保します。
#![allow(unused)]
fn main() {
use eframe::egui::{self, Color32, Pos2, Rect, Rounding, Sense, Stroke, Vec2};
// 幅 300px、高さ 150px のキャンバス領域を確保する
let (response, painter) = ui.allocate_painter(Vec2::new(300.0, 150.0), Sense::hover());
// 確保された領域の画面上の矩形座標(左上 min 〜 右下 max)を取得する
let rect: Rect = response.rect;
// 1. キャンバスの背景を塗りつぶす(角丸 6px)
painter.rect_filled(rect, Rounding::same(6.0), Color32::from_rgb(30, 30, 40));
// 2. 左上から右下へ緑色の対角線を引く(太さ 2px)
painter.line_segment(
[rect.left_top(), rect.right_bottom()],
Stroke::new(2.0, Color32::GREEN),
);
// 3. キャンバスの中心に赤い円を描く(半径 30px)
let center: Pos2 = rect.center();
painter.circle_filled(center, 30.0, Color32::from_rgb(220, 60, 60));
}
注意すべきポイントは、Painter に渡す座標は「キャンバスの左上を (0, 0) とした相対座標」ではなく、「ウィンドウ全体の左上を (0, 0) とした絶対座標(スクリーン座標)」である点です。そのため、response.rect.min(キャンバスの左上座標)や response.rect.center()(キャンバスの中心座標)を基準にして足し算することで、正しい位置に図形を描けます。
マウス操作と連動するお絵かきキャンバスの実装
allocate_painter の第 2 引数に Sense::drag() を指定すると、その領域内でマウスがドラッグされたときの座標を response.interact_pointer_pos() でリアルタイムに取得できます。これを利用して、マウスで自由に線が描ける「お絵かきアプリ」を作ってみましょう。
use eframe::egui::{self, Color32, Pos2, Rounding, Sense, Stroke};
fn main() -> eframe::Result<()> {
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default().with_inner_size([500.0, 400.0]),
..Default::default()
};
eframe::run_native(
"Mini Drawing Canvas",
options,
Box::new(|_cc| Ok(Box::<DrawingApp>::default())),
)
}
struct DrawingApp {
// 1本の線は「座標(Pos2)の並び(Vec<Pos2>)」として表現し、複数本の線を保持する
lines: Vec<Vec<Pos2>>,
stroke_color: Color32,
stroke_width: f32,
}
impl Default for DrawingApp {
fn default() -> Self {
Self {
lines: Vec::new(),
stroke_color: Color32::LIGHT_BLUE,
stroke_width: 3.0,
}
}
}
impl eframe::App for DrawingApp {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
egui::CentralPanel::default().show(ctx, |ui| {
// 上部のツールバー(色選択・太さスライダー・クリアボタン)
ui.horizontal(|ui| {
ui.label("Pen Color:");
ui.color_edit_button_srgba(&mut self.stroke_color);
ui.label("Width:");
ui.add(egui::Slider::new(&mut self.stroke_width, 1.0..=15.0));
if ui.button("Clear").clicked() {
self.lines.clear();
}
});
ui.separator();
// 残りの画面領域いっぱいにドラッグ検知可能なキャンバスを確保する
let available_size = ui.available_size();
let (response, painter) = ui.allocate_painter(available_size, Sense::drag());
// キャンバスの背景を塗る
painter.rect_filled(response.rect, Rounding::same(4.0), Color32::from_gray(25));
// ドラッグ開始時に新しい線(空の Vec)を追加する
if response.drag_started() {
self.lines.push(Vec::new());
}
// ドラッグ中であれば、現在のマウス座標を最後の線に追加する
if let Some(pointer_pos) = response.interact_pointer_pos() {
if response.dragged() {
if let Some(current_line) = self.lines.last_mut() {
// 前回の点と異なる場合のみ追加する
if current_line.last() != Some(&pointer_pos) {
current_line.push(pointer_pos);
}
}
}
}
// 記録されているすべての線を順番に描画する
let stroke = Stroke::new(self.stroke_width, self.stroke_color);
for line in &self.lines {
if line.len() >= 2 {
painter.add(egui::Shape::line(line.clone(), stroke));
}
}
});
}
}
このプログラムに登場する ui.color_edit_button_srgba(&mut self.stroke_color) は、egui に標準搭載されているカラーピッカー(色選択ボタン)です。クリックするだけで本格的な色選択パレットが開き、選んだ色が即座に self.stroke_color に反映されます。
次章では、アプリを閉じてもデータを消さないための自動保存(Persistence)と、時間のかかる処理を裏側で動かして画面のフリーズを防ぐマルチスレッド連携を解説します。
6. 状態永続化と非同期・マルチスレッド連携
アプリ状態の自動保存と復元(Persistence)
メモ帳や設定ツールを作ったとき、ウィンドウを閉じたら入力した内容がすべて消えてしまうのでは不便です。eframe には、Rust の定番データ変換ライブラリである serde と連携して、アプリ構造体のデータを自動でファイル(Web の場合はブラウザの LocalStorage)に保存・復元する persistence 機能が組み込まれています。
▸ Cargo.toml の設定
eframe の persistence フィーチャーと、serde の derive フィーチャーを有効にします。
[dependencies]
eframe = { version = "0.29", features = ["persistence"] }
serde = { version = "1.0", features = ["derive"] }
▸ 構造体への Serialize / Deserialize の付与と save / new の実装
保存したいアプリ構造体に #[derive(serde::Deserialize, serde::Serialize)] を付けます。そして起動時の new メソッドで前回のデータを読み込み、eframe::App の save メソッドで現在のデータを書き出します。
#![allow(unused)]
fn main() {
use eframe::egui;
#[derive(serde::Deserialize, serde::Serialize)]
#[serde(default)] // 新しいフィールドを追加したときに古い保存データでもエラーにしないおまじない
struct PersistentApp {
memo: String,
counter: i32,
// 保存したくない一時的なフィールドには #[serde(skip)] を付ける
#[serde(skip)]
session_clicks: u32,
}
impl Default for PersistentApp {
fn default() -> Self {
Self {
memo: String::new(),
counter: 0,
session_clicks: 0,
}
}
}
impl PersistentApp {
fn new(cc: &eframe::CreationContext<'_>) -> Self {
// 前回保存されたデータがあれば復元し、なければ Default::default() を返す
if let Some(storage) = cc.storage {
return eframe::get_value(storage, eframe::APP_KEY).unwrap_or_default();
}
Default::default()
}
}
impl eframe::App for PersistentApp {
// 定期的、およびアプリ終了時に自動で呼ばれる保存メソッド
fn save(&mut self, storage: &mut dyn eframe::Storage) {
eframe::set_value(storage, eframe::APP_KEY, self);
}
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
egui::CentralPanel::default().show(ctx, |ui| {
ui.heading("Auto-Save Memo");
ui.text_edit_multiline(&mut self.memo);
if ui.button("Count Up").clicked() {
self.counter += 1;
self.session_clicks += 1;
}
ui.label(format!("Saved Counter: {}", self.counter));
ui.label(format!("Session Clicks (not saved): {}", self.session_clicks));
});
}
}
}
#[serde(skip)] を付けたフィールド(上の例では session_clicksや、後述するスレッド通信用のチャネルなど)は保存対象から除外され、起動時には Default の初期値が入ります。
再描画タイミングの制御と省電力化
第 1 章で「Immediate Mode は毎フレーム update を呼ぶ」と説明しましたが、実は egui はマウスもキーボードも動いていない静止状態では、update の呼び出しをピタッと止めて待機します。これにより、ノートパソコンのバッテリーや CPU を無駄に消費しないよう賢く省電力化されています。
しかし、時計アプリのように「マウスを動かしていなくても 1 秒ごとに画面を書き換えたい」場合や、「別スレッドからデータが届いたら画面を更新したい」場合があります。そのときは ctx のメソッドを使って明示的に再描画をリクエストします。
| メソッド | 動作と使いどころ |
|---|---|
ctx.request_repaint() | 次のフレームをただちに再描画します。連続的なアニメーションを行うときや、別スレッドで処理が完了した瞬間に呼びます。 |
ctx.request_repaint_after(Duration) | 指定した時間が経過した後に再描画します。時計の表示更新(1 秒ごと)や定期的なステータス確認に使います。 |
#![allow(unused)]
fn main() {
use std::time::Duration;
// 例:マウス操作がなくても 1秒(1000ミリ秒)後に必ず update を呼び出してもらう
ctx.request_repaint_after(Duration::from_secs(1));
}
なぜ重い処理をupdate内で直接実行してはいけないのか
もしボタンが押されたときに、インターネットから大きなファイルをダウンロードしたり、数万枚の画像を変換したりする処理を update の中で直接実行してしまうとどうなるでしょうか。
#![allow(unused)]
fn main() {
// 悪い例:update メソッドの中で時間のかかる処理を直接呼んでいる
if ui.button("重い処理を実行").clicked() {
// ここで5秒間止まると、その5秒間はウィンドウが完全にフリーズ(応答なし)になる!
std::thread::sleep(std::time::Duration::from_secs(5));
}
}
update メソッドが終わらない限り、egui は次の画面を描くことも、マウスの移動やウィンドウの閉じるボタンに反応することもできません。そのため、0.1 秒以上かかるような処理は必ず「バックグラウンドスレッド(裏方の別スレッド)」に任せて、update メソッド自体は一瞬で終わらせるのが GUI プログラミングの基本原則です。
チャネル(mpsc)を使った非同期タスクと進捗表示
Rust の標準ライブラリにあるスレッド(std::thread)と、スレッド間で安全にメッセージを送受信するパイプであるチャネル(std::sync::mpsc)を組み合わせると、画面を一切フリーズさせずに重い処理を進めながら、進捗バー(ProgressBar)を滑らかに更新できます。
sequenceDiagram
participant UI as メインスレッド (update)
participant Ch as mpscチャネル (tx / rx)
participant Worker as バックグラウンドスレッド
UI->>Worker: 1. ボタン押下時に thread::spawn で起動 (tx と Context を渡す)
loop 処理が完了するまで
Worker->>Ch: 2. tx.send(進捗メッセージ) を送信
Worker->>UI: 3. ctx.request_repaint() で画面更新を通知
UI->>Ch: 4. rx.try_recv() で待たずにメッセージを受信・画面反映
end
ここで最も重要なポイントは、データを受け取る update 側では、メッセージが届くまで立ち止まって待つ rx.recv() ではなく、「届いていれば受け取り、届いていなければ待たずにすぐ次へ進む rx.try_recv()」を使うことです。
実際に動作する完全なサンプルコードを見てみましょう。
use eframe::egui;
use std::sync::mpsc::{self, Receiver};
use std::thread;
use std::time::Duration;
fn main() -> eframe::Result<()> {
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default().with_inner_size([420.0, 220.0]),
..Default::default()
};
eframe::run_native(
"Background Task Demo",
options,
Box::new(|_cc| Ok(Box::<AsyncDemoApp>::default())),
)
}
// バックグラウンドスレッドからメインスレッドへ送るメッセージの種類
enum WorkerMessage {
Progress(f32), // 0.0 〜 1.0 の進捗率
Completed(String), // 完了時の結果テキスト
}
#[derive(Default)]
struct AsyncDemoApp {
is_running: bool,
progress: f32,
result_text: String,
// スレッドからメッセージを受け取る受信機
receiver: Option<Receiver<WorkerMessage>>,
}
impl eframe::App for AsyncDemoApp {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
// 1. バックグラウンドスレッドから届いているメッセージをすべて確認する(try_recv はブロックしない)
if let Some(rx) = &self.receiver {
while let Ok(msg) = rx.try_recv() {
match msg {
WorkerMessage::Progress(p) => {
self.progress = p;
}
WorkerMessage::Completed(text) => {
self.progress = 1.0;
self.is_running = false;
self.result_text = text;
}
}
}
}
egui::CentralPanel::default().show(ctx, |ui| {
ui.heading("Background Worker Example");
ui.add_space(8.0);
// 実行中はボタンを押せないように無効化する
ui.horizontal(|ui| {
if ui
.add_enabled(!self.is_running, egui::Button::new("Start Heavy Task"))
.clicked()
{
self.is_running = true;
self.progress = 0.0;
self.result_text.clear();
// 送信用(tx)と受信用(rx)のチャネルペアを作成する
let (tx, rx) = mpsc::channel();
self.receiver = Some(rx);
// egui::Context は Clone 可能で、別スレッドへ安全に渡せる
let ctx_clone = ctx.clone();
// バックグラウンドスレッドを起動する
thread::spawn(move || {
for step in 1..=20 {
thread::sleep(Duration::from_millis(150));
let _ = tx.send(WorkerMessage::Progress(step as f32 / 20.0));
// 進捗を送ったら、画面をすぐ描き直すよう egui に知らせる
ctx_clone.request_repaint();
}
let _ = tx.send(WorkerMessage::Completed(
"すべてのデータ処理が正常に完了しました!".to_owned(),
));
ctx_clone.request_repaint();
});
}
// 実行中はくるくる回るスピナーを表示する
if self.is_running {
ui.spinner();
ui.label("Processing...");
}
});
ui.add_space(12.0);
// プログレスバー(進捗率バー)を表示する
ui.add(
egui::ProgressBar::new(self.progress)
.show_percentage()
.animate(self.is_running),
);
if !self.result_text.is_empty() {
ui.add_space(8.0);
ui.label(&self.result_text);
}
});
}
}
egui::Context は内部でスレッド安全な参照カウント(Arc)を持っているため、ctx.clone() で簡単に複製して別スレッドの中へ持ち込むことができます。別スレッド側でデータを送った直後に ctx_clone.request_repaint() を呼ぶことで、ユーザーがマウスを動かしていなくても即座にプログレスバーが更新されます。
最終章となる次章では、これまで学んだ知識を総動員して実用的な「タスク管理(ToDo)アプリ」を構築し、さらに Web ブラウザ向けにビルドする手順を解説します。
7. 実践:タスク管理アプリの開発とWeb展開
タスク管理(ToDo)アプリの設計と機能要件
本書の集大成として、これまで学んだパネル構成、ウィジェット、レイアウト、色装飾を組み合わせて、日常的に使える「タスク管理(ToDo)アプリ」を作成します。
実装する主な機能は以下のとおりです。
- 新しいタスクの追加(タイトル入力、優先度の選択、Enter キーまたは追加ボタンでの登録)
- 状態による絞り込みフィルタ(すべて / 未完了のみ / 完了済みのみ)とキーワード検索
- タスク一覧のスクロール表示、完了チェックボックス、優先度バッジの色分け、個別削除ボタン
- 下部ステータスバーでの進捗集計(残りタスク数、完了率のプログレスバー、完了済み一括削除)
flowchart TD
Root["TodoApp(アプリ全体の状態)"] --> Top["TopBottomPanel::top<br/>タイトル・キーワード検索・状態フィルタ"]
Root --> Bottom["TopBottomPanel::bottom<br/>残り件数・達成率ProgressBar・完了済み一括削除"]
Root --> Central["CentralPanel<br/>新規タスク入力バー + ScrollAreaによるタスク一覧"]
タスク管理アプリの完全なコード実装
src/main.rs にそのまま貼り付けて cargo run で動かせる完全なソースコードです(日本語表示を行う場合は第 2 章の setup_custom_fonts 関数を追加してください。ここではどの環境でもそのまま動くよう英語ラベルを基本としつつ、日本語入力にも対応できる構成にしています)。
use eframe::egui::{self, Color32, RichText};
fn main() -> eframe::Result<()> {
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_inner_size([560.0, 480.0])
.with_min_inner_size([400.0, 300.0]),
..Default::default()
};
eframe::run_native(
"egui Task Manager",
options,
Box::new(|_cc| Ok(Box::<TodoApp>::default())),
)
}
// タスクの優先度を表す列挙型
#[derive(PartialEq, Clone, Copy, Debug)]
enum Priority {
Low,
Medium,
High,
}
impl Priority {
fn label(&self) -> &'static str {
match self {
Priority::Low => "LOW",
Priority::Medium => "MED",
Priority::High => "HIGH",
}
}
fn color(&self) -> Color32 {
match self {
Priority::Low => Color32::from_rgb(100, 180, 240),
Priority::Medium => Color32::from_rgb(240, 180, 60),
Priority::High => Color32::from_rgb(240, 90, 90),
}
}
}
// 表示フィルタの種類
#[derive(PartialEq, Clone, Copy)]
enum Filter {
All,
Active,
Completed,
}
// 1件のタスクを表す構造体
struct TaskItem {
id: usize,
title: String,
completed: bool,
priority: Priority,
}
// アプリケーション全体の状態を保持する構造体
struct TodoApp {
tasks: Vec<TaskItem>,
next_id: usize,
new_title: String,
new_priority: Priority,
filter: Filter,
search_query: String,
}
impl Default for TodoApp {
fn default() -> Self {
Self {
tasks: vec![
TaskItem {
id: 1,
title: "Read egui guidebook chapters".to_owned(),
completed: true,
priority: Priority::Medium,
},
TaskItem {
id: 2,
title: "Build a custom desktop tool in Rust".to_owned(),
completed: false,
priority: Priority::High,
},
TaskItem {
id: 3,
title: "Try compiling to WebAssembly with trunk".to_owned(),
completed: false,
priority: Priority::Low,
},
],
next_id: 4,
new_title: String::new(),
new_priority: Priority::Medium,
filter: Filter::All,
search_query: String::new(),
}
}
}
impl TodoApp {
fn add_task(&mut self) {
let trimmed = self.new_title.trim();
if !trimmed.is_empty() {
self.tasks.push(TaskItem {
id: self.next_id,
title: trimmed.to_owned(),
completed: false,
priority: self.new_priority,
});
self.next_id += 1;
self.new_title.clear();
}
}
}
impl eframe::App for TodoApp {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
// 1. 上部パネル:ヘッダーと検索・フィルタバー
egui::TopBottomPanel::top("header_panel").show(ctx, |ui| {
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.heading("Task Manager");
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
egui::widgets::global_theme_preference_buttons(ui);
});
});
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.label("Search:");
ui.add(
egui::TextEdit::singleline(&mut self.search_query)
.hint_text("Filter by keyword...")
.desired_width(160.0),
);
ui.separator();
ui.selectable_value(&mut self.filter, Filter::All, "All");
ui.selectable_value(&mut self.filter, Filter::Active, "Active");
ui.selectable_value(&mut self.filter, Filter::Completed, "Completed");
});
ui.add_space(4.0);
});
// 2. 下部パネル:統計情報と一括削除ボタン
egui::TopBottomPanel::bottom("footer_panel").show(ctx, |ui| {
let total = self.tasks.len();
let completed_count = self.tasks.iter().filter(|t| t.completed).count();
let active_count = total - completed_count;
let progress = if total > 0 {
completed_count as f32 / total as f32
} else {
0.0
};
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.label(format!("{active_count} items left"));
ui.add(
egui::ProgressBar::new(progress)
.desired_width(140.0)
.show_percentage(),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui
.add_enabled(
completed_count > 0,
egui::Button::new("Clear Completed"),
)
.clicked()
{
self.tasks.retain(|t| !t.completed);
}
});
});
ui.add_space(4.0);
});
// 3. 中央パネル:新規タスク追加欄とタスク一覧リスト
egui::CentralPanel::default().show(ctx, |ui| {
// 新規タスク入力エリア
ui.horizontal(|ui| {
let text_res = ui.add(
egui::TextEdit::singleline(&mut self.new_title)
.hint_text("What needs to be done?")
.desired_width(260.0),
);
// Enterキーが押されたらタスクを追加
if text_res.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
self.add_task();
text_res.request_focus(); // 連続入力しやすいようフォーカスを戻す
}
egui::ComboBox::from_id_salt("priority_select")
.selected_text(self.new_priority.label())
.width(70.0)
.show_ui(ui, |ui| {
ui.selectable_value(&mut self.new_priority, Priority::Low, "LOW");
ui.selectable_value(&mut self.new_priority, Priority::Medium, "MED");
ui.selectable_value(&mut self.new_priority, Priority::High, "HIGH");
});
let can_add = !self.new_title.trim().is_empty();
if ui.add_enabled(can_add, egui::Button::new("Add")).clicked() {
self.add_task();
}
});
ui.separator();
// タスク一覧のスクロール領域
let query = self.search_query.to_lowercase();
let mut delete_id: Option<usize> = None;
egui::ScrollArea::vertical().show(ui, |ui| {
for task in &mut self.tasks {
// 状態フィルタの判定
let matches_filter = match self.filter {
Filter::All => true,
Filter::Active => !task.completed,
Filter::Completed => task.completed,
};
// キーワード検索の判定
let matches_query =
query.is_empty() || task.title.to_lowercase().contains(&query);
if !matches_filter || !matches_query {
continue;
}
ui.group(|ui| {
ui.horizontal(|ui| {
ui.checkbox(&mut task.completed, "");
// 優先度バッジ
ui.label(
RichText::new(task.priority.label())
.color(Color32::BLACK)
.background_color(task.priority.color())
.small()
.strong(),
);
// 完了済みの場合は取り消し線(strikethrough)を付ける
let mut title_text = RichText::new(&task.title);
if task.completed {
title_text = title_text.strikethrough().weak();
}
ui.label(title_text);
// 右端に削除ボタンを配置する
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
if ui.small_button("Delete").clicked() {
delete_id = Some(task.id);
}
},
);
});
});
}
});
// ループの外で削除対象のタスクを取り除く(借用ルールを守るための定番パターン)
if let Some(id) = delete_id {
self.tasks.retain(|t| t.id != id);
}
});
}
}
▸ Rustらしい実装のポイント:ループ内での要素削除
上のコードの末尾にある delete_id の使い方は、Rust と egui でリストを扱うときの非常に重要なテクニックです。for task in &mut self.tasks でループを回している最中は、self.tasks が可変借用されているため、そのループの中で直接 self.tasks.retain(...) や remove(...) を呼ぶとコンパイルエラーになります。
そこで、ループの中では「削除ボタンが押されたタスクの ID」だけをローカル変数 delete_id に記録しておき、for ループを抜けた直後に self.tasks.retain(|t| t.id != id) で削除を実行します。こうすることで、Rust の借用ルールをきれいに守りながら安全に要素を削除できます。
公式拡張クレート(egui_extras・egui_plot)の活用
標準の egui クレートだけでも多くの画面を作れますが、さらに高度な部品が必要になったときは、公式がメンテナンスしている拡張クレートを追加できます。
| クレート名 | 主な機能と特徴 |
|---|---|
egui_extras | 列幅をドラッグで変更できる本格的なテーブル(TableBuilder)、日付選択カレンダー(DatePickerButton)、画像ローダーなどを提供します。 |
egui_plot | 折れ線グラフ(Line)、棒グラフ(BarChart)、散布図(Points)を数行のコードで描画でき、マウスでのズームやスクロールにも標準対応しています。 |
egui_dock / egui_tiles | VS Code やゲームエンジンのように、複数のタブやパネルをドラッグ&ドロップで自由に分割・ドッキングできる画面構成を提供します。 |
たとえば、egui_extras::TableBuilder を使うと、数万行のデータでも高速にスクロールできるヘッダー固定付きの表を簡単に作成できます。
WebAssembly(WASM)へのビルドとブラウザ実行
egui と eframe の大きな魅力のひとつが、デスクトップ向けに書いた Rust コードを WebAssembly(WASM)にコンパイルして、Chrome や Safari などの Web ブラウザ上でそのまま動かせる点です。
Web 向けにビルドする際は、Rust 製の WASM ビルドツールである trunk を使うのが最も簡単です。
▸ 1. WASMターゲットとtrunkのインストール
ターミナルで以下のコマンドを 1 回だけ実行し、WASM 用のコンパイルターゲットと trunk を導入します。
rustup target add wasm32-unknown-unknown
cargo install --locked trunk
▸ 2. index.html の作成
プロジェクトのルートディレクトリ(Cargo.toml と同じ階層)に、キャンバスを配置するためのシンプルな index.html を作成します。
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>egui Web App</title>
<link data-trunk rel="rust" data-wasm-opt="2" />
<style>
html, body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow: hidden;
}
canvas {
width: 100%;
height: 100%;
}
</style>
</head>
<body>
<canvas id="the_canvas_id"></canvas>
</body>
</html>
▸ 3. main関数のネイティブ・Web両対応化
デスクトップ(ネイティブ)でビルドしたときは eframe::run_native を呼び、WebAssembly 向けにビルドしたときは eframe::WebRunner を使って HTML 上の <canvas id="the_canvas_id"> に描画するように、条件付きコンパイル属性 #[cfg(...)] で分岐します。
// デスクトップ(ネイティブ)実行時の main 関数
#[cfg(not(target_arch = "wasm32"))]
fn main() -> eframe::Result<()> {
let options = eframe::NativeOptions::default();
eframe::run_native(
"Todo App",
options,
Box::new(|_cc| Ok(Box::<TodoApp>::default())),
)
}
// Webブラウザ(WASM)実行時の main 関数
#[cfg(target_arch = "wasm32")]
fn main() {
use eframe::wasm_bindgen::JsCast as _;
let web_options = eframe::WebOptions::default();
wasm_bindgen_futures::spawn_local(async {
let document = web_sys::window()
.expect("No window")
.document()
.expect("No document");
let canvas = document
.get_element_by_id("the_canvas_id")
.expect("Failed to find the_canvas_id")
.dyn_into::<web_sys::HtmlCanvasElement>()
.expect("the_canvas_id was not a HtmlCanvasElement");
let _ = eframe::WebRunner::new()
.start(
canvas,
web_options,
Box::new(|_cc| Ok(Box::<TodoApp>::default())),
)
.await;
});
}
▸ 4. ローカルサーバーでの起動と本番ビルド
準備ができたら、以下のコマンドを実行するだけで自動的に WASM へのビルドと開発用 Web サーバーの起動が行われます。
# 開発用ローカルサーバーを起動する(ブラウザで http://127.0.0.1:8080 を開く)
trunk serve
# GitHub Pages や Cloudflare Pages 等に配置できる静的ファイルを dist/ に出力する
trunk build --release
trunk build --release を実行すると、dist/ フォルダの中に HTML、JavaScript、そして最適化された .wasm ファイルが出力されます。これを任意の Web サーバーに置くだけで、インストール不要で誰でもブラウザから触れる Rust 製 GUI アプリを公開できます。
本書で学んだ Immediate Mode の考え方、ウィジェットと Response の扱い方、パネルとレイアウトの組み合わせ、そしてスレッド連携のパターンを活用すれば、ちょっとした業務効率化ツールから本格的なビジュアライザーまで、Rust の安全性と速さを活かした GUI アプリケーションを自在に開発できます。