コンテンツにスキップ

RustのGUIアプリ開発:Tauri・egui・Slint・Iced

ターミナルに文字を出すだけでなく、ボタンのある本物のウィンドウを作りたくなる日がいつか来ます。それはRustでもできるのでしょうか? 答えは「はい」です。RustのGUIアプリは速く、小さく、安全で、今ではよいツールキットがいくつもあります。ただ、RustのGUIの世界は、たとえばC#のWPFやJavaScriptのElectronと比べるとまだ若いので、選ぶ前に全体の様子を知っておくと安心です。

このページでは、正直な現状の紹介、選ぶときに役立つ比較表、イミディエイトモード(immediate mode)とリテインドモード(retained mode)の考え方、そして人気のツールキットで書く最初の小さなプログラムを紹介します。

コードはどこで動かせる? GUIには本物のウィンドウが必要なので、egui、Tauri、Slint、Icedのプログラムは自分のパソコンで cargo run して動かすためのものです。このページのeguiとIcedのプログラムは、最新のバージョンに対して cargo check でコンパイルを確認しています。また、2つのセクションでは、ウィンドウを描く代わりに文字を出力するふつうのRustでGUIを再現しているので、ブラウザでそのまま動かせます。「▶ 実際に試してみよう」ボタンを探してください。

コミュニティのサイトAre we GUI yet?(英語)は、現状を「The roots aren’t deep but the seeds are planted.」(根はまだ浅いけれど、種はまかれている)という一文でまとめています。今でもこれは的を射ています。

  • すばらしい点: 速さ、アプリの小ささ、そしてRustの安全性です。egui、Iced、Slintはすべてを自分で描画するので、ひとつのコードでWindows、macOS、Linuxのどれでも動きます。TauriならWebの知識をそのまま使えて、しかもシステムにもともと入っているWebViewを使うので、Electronよりアプリがずっと小さくなります。
  • まだ成長中の点: C++のQtやC#のWPFのような、ひとつに決まった「標準」のツールキットはありません。人気のクレートの中にもまだバージョン0.xのものがいくつかあり、アップデートでコードが動かなくなることがあります。アクセシビリティ、右から左に書く言語への対応、OSらしい見た目(ネイティブなルック&フィール)は、ツールキットごとにばらばらです。
  • よいニュース: 完璧なツールキットを探す必要はありません。最初のアプリなら、下の表のどれを選んでもうまくいきます。

もっと詳しく比べたいなら、2025 survey of Rust GUI libraries(英語)がたくさんのツールキットをひとつずつ試しています。

今すぐ見られる実際のアプリを紹介します。

  • Rerun Viewerは、ロボットやコンピュータビジョンのデータを見るためのツールで、eguiで作られています(Rerunはeguiの開発のスポンサーでもあります)。
  • System76が作っているLinux向けのデスクトップ環境COSMICは、Icedで作られています。IRCチャットクライアントのHalloyもIcedです。
  • Webのような見た目のデスクトップツール:メモアプリ、ダッシュボード、ちょっとした業務ツールはTauriとよく合います。
  • 機器の画面:Slintはマイコンの上でも動くので、機械や家電のタッチパネルにぴったりです。

バージョンは2026年10月時点の最新リリースです。

クレート バージョン UIの書き方 動く環境 こんなときに選ぶ
Tauri 2.12(3.0はアルファ版) 画面はHTML・CSS・JavaScript、ロジックはRust Windows、macOS、Linux、Android、iOS Web開発の経験がある、または凝ったデザインの画面を作りたい
egui + eframe 0.37 ふつうのRust、イミディエイトモード Windows、macOS、Linux、Web(Wasm) ツール、デバッグ用のパネル、エディタ。純粋なRustでいちばん早く始めたい
Slint 1.18 独自の .slint マークアップ+ロジックはRust デスクトップ、組み込み、モバイル、Web 見た目の整ったアプリ、または組み込み機器の画面
Iced 0.14 ふつうのRust、Elmアーキテクチャ(状態、メッセージ、update、view) Windows、macOS、Linux、Web すべての変更が1か所に集まる、すっきりして予測しやすいコードが好き
gtk-rs(gtk4) 0.11 GTK 4へのRustバインディング、リテインドモード まずLinux。WindowsとmacOSは追加の準備をすれば動く LinuxのGNOMEデスクトップになじむアプリを作りたい
Dioxus 0.7 rsx! マクロを使うReact風のコンポーネント Web、デスクトップ(WebView)、モバイル Reactを知っていて、Webとデスクトップをひとつのコードで作りたい

ライセンスと成熟度について、正直に補足しておきます。

  • SlintはGPLv3なら無料です。また、ロイヤリティフリーのライセンスを使えば、クローズドソースのデスクトップ、モバイル、Webのアプリも無料で作れます(Slintを使っていることを表示する必要があります)。クローズドソースの組み込み製品には有料ライセンスが必要です。リリースする前にslint.dev/pricing(英語)を読んでください。
  • egui、Iced、Dioxusはまだ0.xです。リリースのたびに小さな破壊的変更があると思っておき、チュートリアルが自分のバージョンに合っているか確かめましょう。たとえば、古いeguiのチュートリアルは fn update(&mut self, ctx: &egui::Context, ...) を使っていますが、eframe 0.37では fn ui(&mut self, ui: &mut egui::Ui, ...) です。
  • Tauri、Dioxusのデスクトップ版、gtk-rsはシステムのライブラリに依存します。下のプラットフォームごとの準備を見てください。

イミディエイトモードとリテインドモード

Section titled “イミディエイトモードとリテインドモード”

ツールキットの考え方は、ひとつの大きな問いで分かれます。「ウィジェットを覚えておくのは誰か?」です。

リテインドモード(gtk-rs、Slint、TauriのWebページ、そしてほかの言語のほとんどのツールキット)では、ボタンを一度だけ作ります。ツールキットはそれをウィジェットのツリーの中に保持します。コールバックを登録しておくと、ユーザーがクリックしたときにツールキットがそのコールバックを呼びます。あとでボタンの文字を変えたいときは、そのボタンへのハンドルが必要です。

イミディエイトモード(egui)では、何も保持されません。UIの関数がフレームごとに毎回実行され、「今この瞬間に何を表示するか」を書くだけです。ボタンの描画とクリックの確認は1行で済みます:if ui.button("+").clicked() { ... }。コールバックもハンドルもありません。アプリの状態は自分の構造体の中にあり、UIはいつもそこから描かれるので、画面とデータがずれることはありません。

その代わり、ツールキットはフレームごとにUI全体のレイアウトを計算するので、巨大なUIではCPUの時間がかかりますし、一部のレイアウト(たとえば、まだ大きさがわからないウィンドウを中央に置く)は難しくなります。eguiはマウスが動いたときのように何かが起きたときだけ新しいフレームを描くので、何もしていないアプリはCPUをほとんど使いません。

ふつうのRustで書いた、ごく小さな「なんちゃってイミディエイトモード」のツールキットを見てみましょう。ループの1回が1フレームです。配列 clicks がマウスの役をします。

// このフレームで何が描かれたかを記録する、なんちゃって「Ui」
struct Ui {
clicked: Option<&'static str>, // このフレームでマウスがクリックしたボタン
drawn: Vec<String>,
}
impl Ui {
fn label(&mut self, text: &str) {
self.drawn.push(text.to_string());
}
// ボタンを描き、さらにクリックされたかどうかをその場で返す
fn button(&mut self, text: &'static str) -> bool {
self.drawn.push(format!("[{text}]"));
self.clicked == Some(text)
}
}
// あなたのUIのコード:フレームごとに毎回呼ばれる
fn draw(count: &mut i32, ui: &mut Ui) {
ui.label(&format!("Count: {count}"));
if ui.button("+") {
*count += 1;
}
if ui.button("Reset") {
*count = 0;
}
}
fn main() {
let mut count = 0; // アプリの状態はUIのコードの外にある
let clicks = [None, Some("+"), Some("+"), None, Some("Reset"), None];
for (frame, click) in clicks.into_iter().enumerate() {
let mut ui = Ui { clicked: click, drawn: Vec::new() };
draw(&mut count, &mut ui);
println!("frame {frame}: {}", ui.drawn.join(" "));
}
}

実行結果:

frame 0: Count: 0 [+] [Reset]
frame 1: Count: 0 [+] [Reset]
frame 2: Count: 1 [+] [Reset]
frame 3: Count: 2 [+] [Reset]
frame 4: Count: 2 [+] [Reset]
frame 5: Count: 0 [+] [Reset]
▶ 実際に試してみよう

フレーム1を見てください。「+」をクリックしたのに、ラベルはまだ0のままです。ボタンがクリックに気づく前に、ラベルが描かれてしまったからです。新しい値は1フレーム遅れて表示されます。本物のeguiはこれをうまく処理してくれます。クリックやキー入力があるとすぐに次のフレームを描くので、古い数字が目に入ることはありません。

はじめてのeguiアプリ:カウンター

Section titled “はじめてのeguiアプリ:カウンター”

eframeはeguiの公式の「アプリフレームワーク」で、ウィンドウを開き、その中でeguiを動かします。プロジェクトを作って追加しましょう。

Terminal window
cargo new counter
cd counter
[dependencies]
eframe = "0.37"

src/main.rs を次の内容に置き換えます。

use eframe::egui;
fn main() -> eframe::Result {
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default().with_inner_size([300.0, 160.0]),
..Default::default()
};
eframe::run_native(
"Counter",
options,
Box::new(|_cc| Ok(Box::<CounterApp>::default())),
)
}
#[derive(Default)]
struct CounterApp {
count: i32,
}
impl eframe::App for CounterApp {
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
egui::CentralPanel::default().show(ui, |ui| {
ui.heading(format!("Count: {}", self.count));
ui.horizontal(|ui| {
if ui.button("-").clicked() {
self.count -= 1;
}
if ui.button("+").clicked() {
self.count += 1;
}
if ui.button("Reset").clicked() {
self.count = 0;
}
});
});
}
}

cargo run で実行します。最初のビルドでは数百個のクレートをダウンロードしてコンパイルするので、数分かかります。2回目からは速くなります。「Count: 0」と3つのボタンがある小さなウィンドウが開きます。

各部分の役割はこうです。

  • CounterApp があなたの状態です。#[derive(Default)] によって count は0から始まります。
  • run_native がウィンドウを開きます。クロージャはアプリを Box の中に一度だけ作ります(スマートポインタを参照)。
  • eframeはフレームごとに ui を呼びます。CentralPanel がウィンドウ全体を埋め、ui.horizontal が3つのボタンを横に並べます。
  • それぞれの if ui.button(..).clicked() は、上のセクションで見たイミディエイトモードのパターンです。ボタンを描き、確認して、状態を変えます。

状態を必ず self に置き、ui の中のローカル変数に置いてはいけない理由もここにあります。そこに let mut count = 0; と書くと、フレームのたびに0からやり直しになってしまいます。

Tauriのコマンド:Webページの裏で動くRust

Section titled “Tauriのコマンド:Webページの裏で動くRust”

Tauriのアプリは2つの部分からできています。フロントエンドは、システムのWebViewで動くふつうのWebページです(HTML、CSS、JavaScript、またはReactやSvelteのようなフレームワーク)。バックエンドはあなたのRustのコードです。フロントエンドは、コマンド(command)と呼ばれるRustの関数を呼び出します。

いちばん簡単な始め方は、公式のプロジェクト生成ツールを使うことです。名前とフロントエンドのテンプレートを聞かれます(フレームワークを使わない「vanilla」テンプレートが、最初の選択としておすすめです)。

Terminal window
cargo install create-tauri-app --locked
cargo create-tauri-app

すでにNode.jsを使っているなら npm create tauri-app@latest もあります。作られたフォルダに移動したら、npmのテンプレートなら npm install と npm run tauri dev、Rustだけのテンプレートなら cargo install tauri-cli --version "^2.0.0" --locked と cargo tauri dev でアプリを起動します。どのコマンドを使えばいいかは、ツールが選んだ内容に合わせて表示してくれます。

生成された src-tauri/src/lib.rs には、最初のコマンドが入っています。

// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}! You've been greeted from Rust!", name)
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![greet])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

そしてフロントエンド(vanillaテンプレートの src/main.js。ここでは短くしています)からは、次のように呼び出します。

const { invoke } = window.__TAURI__.core;
greetMsgEl.textContent = await invoke("greet", { name: greetInputEl.value });

何が起きているのかを見てみましょう。

  • #[tauri::command] は属性マクロです(手続き的マクロを参照)。JavaScriptからの呼び出しをふつうのRustの関数呼び出しに変える、つなぎのコードを書いてくれます。
  • 引数はJSONで届くので、引数の型はそれぞれserdeの Deserialize を、戻り値の型は Serialize を実装している必要があります(serdeでJSONを扱うを参照)。JavaScript側では引数名をキャメルケースで書きます。Rustの引数 invoke_message は { invokeMessage: "..." } になります。
  • generate_handler![greet] がコマンドを登録します。新しいコマンドをここに追加し忘れると、JavaScriptからの呼び出しは失敗します。
  • エラーを伝えるには Result を返します。Ok ならJavaScriptのPromiseが解決(resolve)され、Err なら拒否(reject)されるので、.catch() で処理できます。たとえば fn load(path: String) -> Result<String, String> のように書きます。
  • main.rs は run() を呼ぶだけです。同じアプリをAndroidやiOS向けにもビルドできるように、コードは lib.rs に置かれています。

Slint:UIは専用のマークアップ言語で

Section titled “Slint:UIは専用のマークアップ言語で”

Slintは、HTMLとCSSを合わせたような小さな宣言的な言語で画面を記述します。ロジックはRustのコードが担当します。公式のhello worldを見てみましょう。[dependencies] に slint = "1.18" を追加して、src/main.rs に次のコードを書きます。

slint::slint!{
export component HelloWorld {
Text {
text: "hello world";
color: green;
}
}
}
fn main() {
HelloWorld::new().unwrap().run().unwrap();
}

slint! マクロはビルドのときにマークアップをコンパイルし、Rustの型 HelloWorld を作ってくれます。本格的なアプリでは、マークアップは独立した .slint ファイルに書きます。次のカウンターは、公式のSlint Rustテンプレート(英語)からのものです。ファイル ui/app-window.slint はこうなります。

import { Button, VerticalBox } from "std-widgets.slint";
export component AppWindow inherits Window {
in-out property <int> counter: 42;
callback request-increase-value();
VerticalBox {
Text {
text: "Counter: \{root.counter}";
}
Button {
text: "Increase value";
clicked => {
root.request-increase-value();
}
}
}
}

そして src/main.rs のRust側です。

slint::include_modules!();
fn main() -> Result<(), Box<dyn std::error::Error>> {
let ui = AppWindow::new()?;
let ui_handle = ui.as_weak();
ui.on_request_increase_value(move || {
let ui = ui_handle.unwrap();
ui.set_counter(ui.get_counter() + 1);
});
ui.run()?;
Ok(())
}

Slintはリテインドモードです。counter プロパティはUIの中にあり、ボタンはコールバックを呼びます。Slintはマークアップから get_counter、set_counter、on_request_increase_value を生成します(ダッシュはアンダースコアになります)。クロージャが弱い(weak)ハンドル(as_weak())を持っているのは、ウィンドウと、そのウィンドウ自身のコールバックが、お互いを永遠に生かし続けないようにするためです(WeakとCowを参照)。.slint ファイルを使う場合は、さらに [build-dependencies] に slint-build = "1.18" を追加し、1行だけの build.rs を用意します:slint_build::compile("ui/app-window.slint").expect("Slint build failed");。

Icedは、Elmという言語にちなんで名付けられたElmアーキテクチャに従います。どのアプリも4つの部分からできています。

  1. 状態(State): アプリが覚えておくすべてを持つ構造体。
  2. メッセージ(Messages): 起こりうるすべてのことを並べた列挙型(enum)(「+ボタンが押された」など)。
  3. update: 状態とメッセージを1つ受け取り、状態を変えます。変更が起きるのはここだけです。
  4. view: 状態からウィジェットを組み立てます。ウィジェット自身は何も変えません。メッセージを送るだけです。

公式のIcedのカウンターを見てみましょう([dependencies] に iced = "0.14" を追加します)。

use iced::widget::{button, column, text, Column};
use iced::Center;
fn main() -> iced::Result {
iced::run(Counter::update, Counter::view)
}
#[derive(Default)]
struct Counter {
value: i64,
}
#[derive(Debug, Clone, Copy)]
enum Message {
Increment,
Decrement,
}
impl Counter {
fn update(&mut self, message: Message) {
match message {
Message::Increment => self.value += 1,
Message::Decrement => self.value -= 1,
}
}
fn view(&self) -> Column<'_, Message> {
column![
button("Increment").on_press(Message::Increment),
text(self.value).size(50),
button("Decrement").on_press(Message::Decrement),
]
.padding(20)
.align_x(Center)
}
}

on_press(Message::Increment) は、何のコードも実行しません。「クリックされたら、このメッセージを送ってね」と言っているだけです。するとIcedがそのメッセージで update を呼び、新しい状態を表示するためにもう一度 view を呼びます。すべての変更がひとつの match を通るので、アプリにできることを全部見渡す(そしてテストする)のが簡単です。列挙型とmatchのよい活用例です。

Elmアーキテクチャをブラウザで試す

Section titled “Elmアーキテクチャをブラウザで試す”

この考え方を練習するのに、Icedは必要ありません。次のTODOアプリのモデルにも、同じ4つの部分があります。view はウィジェットの代わりに文字列を返し、ユーザーのキー入力やクリックの代わりにメッセージのリストを使います。

// 1. 状態:アプリが覚えておく必要があるすべて
#[derive(Default)]
struct App {
draft: String,
todos: Vec<Todo>,
}
struct Todo {
title: String,
done: bool,
}
// 2. メッセージ:起こりうるすべてのこと
#[derive(Debug)]
enum Message {
DraftChanged(String),
AddPressed,
Toggle(usize),
ClearDone,
}
impl App {
// 3. update:状態が変わる「唯一の」場所
fn update(&mut self, message: Message) {
match message {
Message::DraftChanged(text) => self.draft = text,
Message::AddPressed => {
let title = self.draft.trim();
if !title.is_empty() {
self.todos.push(Todo { title: title.to_string(), done: false });
}
self.draft.clear();
}
Message::Toggle(index) => {
if let Some(todo) = self.todos.get_mut(index) {
todo.done = !todo.done;
}
}
Message::ClearDone => self.todos.retain(|todo| !todo.done),
}
}
// 4. view:状態をユーザーに見えるもの(ここでは文字列)に変える
fn view(&self) -> String {
let mut screen = format!("New: [{}]\n", self.draft);
for (index, todo) in self.todos.iter().enumerate() {
let mark = if todo.done { "x" } else { " " };
screen.push_str(&format!("{index}. [{mark}] {}\n", todo.title));
}
screen
}
}
fn main() {
let mut app = App::default();
// キー入力やボタンのクリックから届いたつもりのメッセージ
let messages = vec![
Message::DraftChanged("牛乳を買う".to_string()),
Message::AddPressed,
Message::DraftChanged("銀行に電話する".to_string()),
Message::AddPressed,
Message::DraftChanged("植物に水をやる".to_string()),
Message::Toggle(0),
];
for message in messages {
println!("> {message:?}");
app.update(message);
}
print!("{}", app.view());
app.update(Message::ClearDone);
println!("> after ClearDone:");
print!("{}", app.view());
}

実行結果:

> DraftChanged("牛乳を買う")
> AddPressed
> DraftChanged("銀行に電話する")
> AddPressed
> DraftChanged("植物に水をやる")
> Toggle(0)
New: [植物に水をやる]
0. [x] 牛乳を買う
1. [ ] 銀行に電話する
> after ClearDone:
New: [植物に水をやる]
0. [ ] 銀行に電話する
▶ 実際に試してみよう

view は何も変更せず、update は何も出力しないことに注目してください。この役割分担こそが、このしくみのポイントです。これを本物のIcedアプリにするには、view をウィジェットを返すように書き直すだけです(DraftChanged を送る text_input や、AddPressed を送る button など)。状態、メッセージ、update はまったく同じままで大丈夫です。

GUIのクレートはOSとやり取りするので、ふつうのRustより少し多くの準備が必要です。まずRustのツールと環境構築で基本をそろえてください。そのうえで、次のものを用意します。

OS egui/eframe、Iced、Slint Tauriでさらに必要なもの
Windows MSVCのC++ツール以外は不要 WebView2。Windows 10(バージョン1803以降)とWindows 11には最初から入っています
macOS Xcodeのコマンドラインツール(xcode-select --install) デスクトップアプリならほかには不要。iOS向けには完全版のXcode
Linux(Debian/Ubuntu) eframeのREADMEには libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev libxkbcommon-dev libssl-dev が挙がっています webkit2gtk:libwebkit2gtk-4.1-dev と、Tauriの前提条件のページ(英語)にあるいくつかのパッケージ

Linuxでのビルドが、ライブラリが見つからないという pkg-config のメッセージで失敗したら、そのライブラリの -dev パッケージをインストールしてからビルドし直してください。gtk-rsにはGTK 4の開発用ファイル(Ubuntuなら libgtk-4-dev)が必要です。

  • UIのスレッドを止めてしまう。 eguiの ui 関数やIcedの update の中でダウンロードや長い計算をすると、終わるまでウィンドウ全体が固まります。時間のかかる処理は別のスレッドかasyncのタスクで行い(並行処理とasync/awaitを参照)、結果はメッセージとしてUIに送り返しましょう。
  • eguiの状態をローカル変数に置く。 ui はフレームごとに毎回実行されるので、その中の let mut は毎回まっさらな状態から始まります。状態はアプリの構造体に置きましょう。
  • Tauriのコマンドを登録し忘れる。 すべてのコマンドは tauri::generate_handler![...] に書く必要があります。また、lib.rs の中ではコマンドに pub を付けないでください。Tauriのドキュメントによると、付けると E0255 の「defined multiple times」(複数回定義されている)エラーになります。
  • 古いチュートリアルどおりに書く。 0.xのクレートでは、去年の例がコンパイルできないことがあります。チュートリアルのバージョンと自分の Cargo.toml を見比べ、使っているバージョンに合ったクレート自身の examples フォルダを見ましょう。

1. 0より小さくならないカウンター

Section titled “1. 0より小さくならないカウンター”

Increment、Decrement、Reset のメッセージを持つ、Elm風のカウンターを書いてください。ユーザーが「-」を押し続けても、値は決して0より小さくなってはいけません。メッセージのリストを update に順番に通し、そのたびに view() を出力しましょう。

ヒント: u32 と saturating_sub(1) を使いましょう。オーバーフローせずに0で止まってくれます(整数オーバーフローを参照)。

解答を見る
#[derive(Default)]
struct Counter {
value: u32,
}
#[derive(Debug)]
enum Message {
Increment,
Decrement,
Reset,
}
impl Counter {
fn update(&mut self, message: Message) {
match message {
Message::Increment => self.value += 1,
Message::Decrement => self.value = self.value.saturating_sub(1),
Message::Reset => self.value = 0,
}
}
fn view(&self) -> String {
format!("Count: {}", self.value)
}
}
fn main() {
let mut counter = Counter::default();
let messages = [
Message::Increment,
Message::Increment,
Message::Decrement,
Message::Decrement,
Message::Decrement,
Message::Increment,
Message::Reset,
];
for message in messages {
let name = format!("{message:?}");
counter.update(message);
println!("{name:<9} -> {}", counter.view());
}
}

実行結果:

Increment -> Count: 1
Increment -> Count: 2
Decrement -> Count: 1
Decrement -> Count: 0
Decrement -> Count: 0
Increment -> Count: 1
Reset -> Count: 0
▶ 実際に試してみよう

「0より小さくならない」というルールは、update というたった1か所にあります。メッセージの名前を update を呼ぶ前に文字列にしているのは、update がメッセージを値で受け取って、ムーブしてしまうからです。

2. 元に戻す(Undo)ボタンを追加する

Section titled “2. 元に戻す(Undo)ボタンを追加する”

カウンターに Undo メッセージを追加してください。変更のたびに古い値を覚えておき、Undo で1つ前に戻します。履歴がないときの Undo は何もしません。あと何回元に戻せるかを view に表示しましょう。

ヒント: 状態に history: Vec<u32> を持たせます。変更の前に古い値を push し、Undo のときに pop します。

解答を見る
#[derive(Default)]
struct Counter {
value: u32,
history: Vec<u32>, // 以前の値。最新のものが末尾
}
#[derive(Debug)]
enum Message {
Increment,
Decrement,
Reset,
Undo,
}
impl Counter {
fn update(&mut self, message: Message) {
let new_value = match message {
Message::Increment => self.value + 1,
Message::Decrement => self.value.saturating_sub(1),
Message::Reset => 0,
Message::Undo => {
if let Some(previous) = self.history.pop() {
self.value = previous;
}
return;
}
};
self.history.push(self.value); // まず古い値を覚えておく
self.value = new_value;
}
fn view(&self) -> String {
format!("Count: {} (undo steps: {})", self.value, self.history.len())
}
}
fn main() {
let mut counter = Counter::default();
let messages = [
Message::Increment,
Message::Increment,
Message::Decrement,
Message::Increment,
Message::Reset,
Message::Undo,
Message::Undo,
Message::Undo,
];
for message in messages {
let name = format!("{message:?}");
counter.update(message);
println!("{name:<9} -> {}", counter.view());
}
}

実行結果:

Increment -> Count: 1 (undo steps: 1)
Increment -> Count: 2 (undo steps: 2)
Decrement -> Count: 1 (undo steps: 3)
Increment -> Count: 2 (undo steps: 4)
Reset -> Count: 0 (undo steps: 5)
Undo -> Count: 2 (undo steps: 4)
Undo -> Count: 1 (undo steps: 3)
Undo -> Count: 2 (undo steps: 2)
▶ 実際に試してみよう

すべての変更がもともと update を通っているので、Undoは簡単に追加できました。本物のIcedアプリなら、Message::Undo を送るボタンを1つ足すだけです。

  • RustのGUIは今でも十分に使えますが、標準のツールキットはまだひとつに決まっておらず、人気のクレートのいくつかはまだ0.xです。
  • TauriはWebのフロントエンドとRustのバックエンドを組み合わせます。eguiはふつうのRustで書くイミディエイトモードです。Slintは独自のマークアップを使い、組み込み機器でも動きます。IcedはElmアーキテクチャを使います。
  • リテインドモードはウィジェットのツリーを保持し、あなたのコールバックを呼びます。イミディエイトモードはコールバックを使わず、フレームごとにあなたの状態からUIを描き直します。
  • Elmアーキテクチャはアプリを状態、メッセージ、update、view に分けるので、変更を追いかけたりテストしたりするのが簡単になります。
  • TauriにはWindowsでWebView2、Linuxでwebkit2gtkが必要です。基本はRustのツールと環境構築で確認しましょう。

理解度チェック

1. eguiで、カウンターの値を ui の中のローカル変数ではなく、アプリの構造体に置かなければならないのはなぜですか?

2. Tauriのアプリで、#[tauri::command] は何をしますか?

3. Icedが使うElmアーキテクチャでは、アプリの状態はどこで変わりますか?

4. Linuxで、eguiには不要でTauriには必要なものは何ですか?

スコア: 0 / 4

  • Are we GUI yet?(英語):RustのGUIクレートをまとめたコミュニティのサイトです。「immediate mode」や「reactive」といったタグと、最近のニュースがあります。
  • Understanding immediate mode — egui docs(英語):eguiのしくみと長所・短所を、作者自身が説明しています。
  • Calling Rust from the Frontend — Tauri(英語):引数、戻り値、エラー、asyncのコマンド、状態など、コマンドのすべてがわかります。
  • Slint Rust documentation(英語):RustのAPI、slint! マクロ、そして slint-build で .slint ファイルを使う方法。
  • The Iced book: Architecture(英語):状態、メッセージ、update、viewをわかりやすく説明しています。