開発

[rust] GUI crateを調査

Rustはたまに触る程度で継続的にプログラミングを行っていないので時間が立つと忘れてしまいます。継続的に触れる機会としてなにかアプリケーションを作ろうと思います。ということでまずは見た目から。Rustで気になるGUI crateを試してみます。

どうやらデファクトスタンダードなGUI crateは現時点では存在せず、安定度もまちまちなようです。

そこで気になるcrateを使ってビルドができるのか?軽くサンプルプログラムを動作させて問題がなさそうか?、調べて見たいと思います。

2024/04-5月頃の情報になります。随時更新していきます。

ターゲット環境

開発環境

  • Ubuntu 23.04 Desktop
  • とりあえずOSBoxesよりダウンロードしたもので試用
  • こちらでクロスコンパイルしてWindows向けにする
    • ターゲットは基本 x86_64-pc-windows-gnu
    • うまくいかない場合x86_64-pc-windows-msvcも試してみる
  • 開発中はMobaXtermで画面をリモートに表示させたい
  • rust-toolchain.tomlでバージョンを管理します

確認ポイント

  • クロスコンパイルできること
  • 日本語が表示できる
  • 日本語が入力できる
  • ライセンスはできればMIT。あるいは類似ライセンス
  • ネイティブぽいルックアンドフィール

実行環境

  • Windows環境

共通

main.rsに下記を追加(オプショナル。実行時にDOS窓が表示されなくなる。他のcrateにも有効)

#![windows_subsystem = "windows"]
Code language: CSS (css)

dioxus

dioxusはデスクトップ、モバイル、Web、TUI(テキストベースのUI)などをカバーするフレームワークです。React系の設計思想です。クロスプラットフォーム対応です。

フルスタックを謳うフレームワークで機能が充実している分、必要なcrateが多いです。JavaScriptフレームワークのような専用コマンドが用意されています。

導入

rust-toolchain.toml

[toolchain]
channel = "1.96"
profile = "minimal"
components = [
    "rustfmt",
    "clippy",
    "rust-analyzer",
]
targets = ["x86_64-pc-windows-gnu", "wasm32-unknown-unknown", "x86_64-pc-windows-msvc"]Code language: TOML, also INI (ini)

各種パッケージインストール

sudo apt update
sudo apt install libwebkit2gtk-4.1-dev \
  build-essential \
  curl \
  wget \
  file \
  libxdo-dev \
  libssl-dev \
  libayatana-appindicator3-dev \
  librsvg2-dev \
  lld
cargo install --locked  dioxus-cli@0.7.6Code language: Bash (bash)

専用コマンド

dx new dioxus_todo  // 雛形が作成される

dx serve  // ホットリロードなど
Code language: Bash (bash)

サンプルプログラム

カウンター

Rust
// カウンター
// Doc.rsより

use dioxus::prelude::*;

fn main() {
    dioxus::launch(app);
}

fn app() -> Element {
    let mut count = use_signal(|| 0);

    rsx! {
        h1 { "High-Five counter: {count}" }
        button { onclick: move |_| count += 1, "Up high!" }
        button { onclick: move |_| count -= 1, "Down low!" }
    }
}

ToDo List

Rust
// ToDo List
// AI生成に多少修正

use dioxus::prelude::*;

const TAILWIND_CSS: Asset = asset!("/assets/tailwind.css");

#[derive(Debug, Clone, PartialEq)]
struct TodoItem {
    task: String,
    is_done: bool,     // 確定された状態
    draft_done: bool,  // チェックボックスの一時的な状態
}

#[derive(Debug, Clone, Copy, PartialEq)]
enum FilterMode {
    All,
    Active,
    Completed,
}

#[allow(non_snake_case)]
fn App() -> Element {
    // 1. TODOリストのSignal
    let mut todo_list = use_signal(|| {
        vec![
            // TodoItem {
            //     task: "Rustの勉強をする".to_string(),
            //     is_done: false,
            //     draft_done: false,
            // },
        ]
    });

    // 2. 新規タスクの入力文字列を管理するSignal
    let mut new_task_text = use_signal(String::new);

    // 3. 表示モードのSignal
    let mut filter_mode = use_signal(|| FilterMode::All);

    // 4. 画面全体のどこかに未確定の変更があるかをチェックするフラグ
    let has_changes = todo_list.read().iter().any(|item: &TodoItem| item.draft_done != item.is_done);

    // 5. 表示するアイテムを事前にフィルタリング
    let current_filter = filter_mode();
    let filtered_todos: Vec<(usize, TodoItem)> = todo_list
        .read()
        .iter()
        .cloned()
        .enumerate()
        .filter(|(_, item)| match current_filter {
            FilterMode::All => true,
            FilterMode::Active => !item.is_done,
            FilterMode::Completed => item.is_done,
        })
        .collect();

    rsx! {
        document::Link { rel: "stylesheet", href: TAILWIND_CSS }
        div { class: "p-6 max-w-xl mx-auto bg-white rounded-xl shadow-md space-y-4 mt-10",
            h2 { class: "text-2xl font-bold text-gray-900 border-b pb-2", "📌 TODOリスト" }

            // 🌟 --- 新規タスク追加フォーム ---
            form {
                style: "flex gap-2",
                // フォームのデフォルトの送信挙動(画面リロード)を防ぐ
                onsubmit: move |evt| {
                    evt.stop_propagation();
                },
                
                input {
                    r#type: "text",
                    placeholder: "新しいタスクを入力...",
                    value: "{new_task_text}",
                    class: "flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500",
                    // 入力内容をリアルタイムにSignalに同期
                    oninput: move |evt| new_task_text.set(evt.value()),
                }
                
                button {
                    r#type: "submit",
                    class: "px-4 py-2 bg-blue-600 text-white font-semibold rounded-md hover:bg-blue-700 transition duration-200",
                    // クリック時にタスクを追加
                    onclick: move |_| {
                        let text = new_task_text.read().trim().to_string();
                        if !text.is_empty() {
                            // リストの末尾に新しいタスクを追加(初期状態は未完了)
                            todo_list.write().push(TodoItem {
                                task: text,
                                is_done: false,
                                draft_done: false,
                            });
                            // 入力欄をクリア
                            new_task_text.set(String::new());
                        }
                    },
                    "追加"
                }
            }

            // --- コントロールエリア(表示切り替え & 一括反映ボタン) ---
            div { class: "flex items-center justify-between gap-4 py-2 border-b",
                
                // 表示切り替えタブ
                div { class: "flex gap-1 bg-gray-100 p-1 rounded-lg",
                    button {
                        class: format!("px-3 py-1.5 text-sm font-medium rounded-md transition {}", 
                            if filter_mode() == FilterMode::All { "bg-white shadow text-gray-900" } else { "text-gray-500 hover:text-gray-900" }
                        ),
                        onclick: move |_| filter_mode.set(FilterMode::All),
                        "すべて"
                    }
                    button {
                        class: format!("px-3 py-1.5 text-sm font-medium rounded-md transition {}", 
                            if filter_mode() == FilterMode::Active { "bg-white shadow text-gray-900" } else { "text-gray-500 hover:text-gray-900" }
                        ),
                        onclick: move |_| filter_mode.set(FilterMode::Active),
                        "未完了"
                    }
                    button {
                        class: format!("px-3 py-1.5 text-sm font-medium rounded-md transition {}", 
                            if filter_mode() == FilterMode::Completed { "bg-white shadow text-gray-900" } else { "text-gray-500 hover:text-gray-900" }
                        ),
                        onclick: move |_| filter_mode.set(FilterMode::Completed),
                        "完了済み"
                    }
                }

                // 一括更新ボタン
                button {
                    disabled: !has_changes,
                    class: format!(
                        "px-4 py-2 text-sm font-bold border rounded-md transition duration-200 {}",
                        if has_changes { 
                            "bg-amber-100 border-amber-300 text-amber-800 hover:bg-amber-200 cursor-pointer" 
                        } else { 
                            "bg-gray-50 border-gray-200 text-gray-400 cursor-not-allowed" 
                        }
                    ),
                    onclick: move |_| {
                        let mut list = todo_list.write();
                        for item in list.iter_mut() {
                            item.is_done = item.draft_done;
                        }
                    },
                    if has_changes { "⚠️ 変更を一括更新する" } else { "変更なし" }
                }
            }

            // --- TODOリスト一覧 ---
            ul { class: "divide-y divide-gray-200",
                for (i, item) in filtered_todos {
                    li { key: "{i}", class: "py-3 flex items-center justify-between gap-3",

                        // チェックボックス(一時的な状態 draft_done を操作)
                        div { class: "flex items-center gap-3 flex-1",
                            input {
                                r#type: "checkbox",
                                checked: item.draft_done,
                                class: "w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500 cursor-pointer",
                                onclick: move |_| {
                                    let current_val = todo_list.read()[i].draft_done;
                                    todo_list.write()[i].draft_done = !current_val;
                                }
                            }

                            // タスク名
                            span {
                                class: format!("text-gray-800 transition {}", 
                                    if item.is_done { "line-through text-gray-400" } else { "" }
                                ),
                                "{item.task}"
                            }
                        }

                        div { class: "flex items-center gap-2",
                        // 状態バッジ
                            span {
                                class: format!("text-xs font-semibold px-2 py-1 rounded-full text-white {}",
                                    if item.is_done { "bg-green-500" } else { "bg-red-500" }
                                ),
                                if item.is_done { "完了" } else { "未完了" }
                            }

                            // 変更有無の判定
                            if item.draft_done != item.is_done {
                                span { style: "font-size: 0.8rem; color: #b78103; font-style: italic;", "(未反映の変更あり)" }
                            }
                        }
                    }
                }
            }
        }
    }
}

fn main() {
    dioxus::launch(App);
}

クロスコンパイル

フレームワーク提供のコマンドで実行環境向けインストーラーが作成されるらしい。が、クロスコンパイルまでは対応していないようだ。

当方のx86_64-pc-windows-gnuでクロスコンパイルできなかった。これはmingwパッケージのエラーに起因するもののようなのでDioxusには直接関係ない。

x86_64-pc-windows-msvc向けはcargo-xwin を使えばクロスコンパイルできた。

cargo xwin build --target x86_64-pc-windows-msvcCode language: Bash (bash)

結果

version 0.7.6テスト結果

コンパイルできるようになった。

rsx!文の中ではlet文が使えないなど制約はあるようだが、JSXのような形で画面が組めるのでフロントエンド開発のようなイメージで作っていけそうです。

画面の装飾にこだわるとどんどんコードが増えていきました。

実行ファイル(release)は5MB程度。

成果物はネイティブアプリケーションではありませんでした。ElectronやTauriのような実行環境の中でロジックが動くイメージでした。そのため、リモートに画面を表示することができません。

クロスコンパイルはBundlerErrorが出て先に進みませんでした。distフォルダーにassetsフォルダーの内容がコピーされているので動いてそうですが原因がよくわかりませんでした。

Assetsのドキュメントを見るとmanganisという別の仕組みが必要なようですがまだ不安定な印象です。

チェックポイント結果
クロスコンパイルできること x86_64-pc-windows-gnu当方環境ではNG。しかし、他の環境では動いているようだ。
クロスコンパイルできること x86_64-pc-windows-msvcOK
日本語が表示できるOK
日本語が入力できるOK
ライセンスMIT or Apache License 2.0
ルックアンドフィールブラウザ

floem

floemLapceというRustで書かれたエディターで使われているUIフレームワークです。クロスプラットフォーム対応でXilemLeptos等に影響を受けたリアクティブ系のフレームワークのようです。

State管理はSolidJSと同様のアプローチのようです。

エディターというアプリケーションの実需で使用されているので安定度は高そうです。

導入

version 0.2.0

rust-toolchain.toml

rustcのミニマムバージョンが1.80.1になっている。

[toolchain]
channel = "1.96"
profile = "minimal"
components = [
    "rustfmt",
    "clippy",
    "rust-analyzer",
]
targets = ["x86_64-pc-windows-gnu",]Code language: TOML, also INI (ini)

各種パッケージインストール

sudo apt update
sudo apt install -y gcc
sudo apt install -y mingw-w64Code language: Bash (bash)

Cargo.toml

[dependencies]
floem = "0.2.0"
Code language: TOML, also INI (ini)

[dependencies]
floem = "0.1.1"Code language: TOML, also INI (ini)

サンプルプログラム

version 0.2.0

カウンター
Rust
// カウンター
// Doc.rsより

#![windows_subsystem = "windows"]

use floem::prelude::*;

fn app_view() -> impl IntoView {
    // Create a reactive signal with a counter value, defaulting to 0
    let (counter, set_counter) = create_signal(0);

    // Create a vertical layout
    v_stack((
        // The counter value updates automatically, thanks to reactivity
        label(move || format!("Value: {}", counter.get())),
        // Create a horizontal layout
        h_stack((
            button("Increment").on_click_stop(move |_| {
                set_counter.update(|value| *value += 1);
            }),
            button("Decrement").on_click_stop(move |_| {
                set_counter.update(|value| *value -= 1);
            }),
        )),
    ))
}

fn main() {
    floem::launch(app_view);
}
ToDo List

IMEが有効にできない(ワークアラウンドあり)がこのフレームワークを使っているエディターLapce ではIMEが使えるのでバージョンが上がれば使えるようになるでしょう。

Rust
// ToDo List
// IMEワークアラウンドはAIパワーで突っ切ろうとしたがダメでした

#![windows_subsystem = "windows"] // これを有効にするとIME漢字候補が表示されない

use floem::keyboard::{Key, NamedKey};
use floem::reactive::create_memo;
use floem::{prelude::*, text};

#[derive(Debug, Clone, PartialEq)]
struct TodoItem {
    task: String,
    is_done: bool,    // 確定された状態
    draft_done: bool, // チェックボックスの一時的な状態
}

#[derive(Debug, Clone, Copy, PartialEq)]
enum FilterMode {
    All,
    Active,
    Completed,
}

fn app_view() -> impl IntoView {
    // タスクリスト保存用
    // let todo_list: RwSignal<Vec<TodoItem>> = RwSignal::new(vec![
    //     TodoItem {
    //         task: "Rustの勉強をする".to_string(),
    //         is_done: true,
    //         draft_done: false,
    //     },
    //     TodoItem {
    //         task: "Rustの勉強をする".to_string(),
    //         is_done: false,
    //         draft_done: false,
    //     },
    //     TodoItem {
    //         task: "Rustの勉強をする".to_string(),
    //         is_done: true,
    //         draft_done: true,
    //     },
    // ]);
    let todo_list = create_rw_signal(Vec::<TodoItem>::new());

    // 新しいタスク入力用
    let new_task_text = RwSignal::<String>::new("".to_string());

    // 表示モードのSignal
    let filter_mode = create_rw_signal(FilterMode::All);

    // フィルター済みリスト
    let filtered_todos = create_memo(move |_| {
        todo_list
            .get()
            .iter()
            .cloned()
            .enumerate()
            .filter(|item| match filter_mode.get() {
                FilterMode::All => true,
                FilterMode::Active => !item.1.is_done,
                FilterMode::Completed => item.1.is_done,
            })
            .collect::<Vec<(usize, TodoItem)>>()
    });

    // dyn_stack向けtodo_key_fn
    // 戻り値はユニークでないといけない
    let todo_key_fn = |(idx, _): &(_, _)| *idx;

    // dyn_stack向けtodo_view_fn
    let todo_view_fn = move |(idx, item): (usize, TodoItem)| {
        let current_item = item.clone();

        // チェックボックスの表示
        let item_checkbox = checkbox(move || current_item.draft_done).on_update(move |state| {
            todo_list.update(|list| {
                if let Some(target) = list.get_mut(idx) {
                    target.draft_done = state;
                }
            });
        });

        // アイテムが完了/未完了のステータス表示
        let item_state = label(move || {
            let list = todo_list.get();

            if let Some(target) = list.get(idx) {
                if target.is_done {
                    "完了"
                } else {
                    "未完了"
                }
            } else {
                // 万が一リストから消えていた場合のフォールバック(初期値)
                if current_item.is_done {
                    "完了"
                } else {
                    "未完了"
                }
            }
        });

        // ステータスを変更したか否か
        let item_unchanged = label(move || {
            let list = todo_list.get();

            if let Some(target) = list.get(idx) {
                if target.draft_done != target.is_done {
                    "(未反映の変更あり)".to_string()
                } else {
                    "".to_string()
                }
            } else {
                "".to_string()
            }
        })
        .style(|s| s.font_style(text::Style::Italic).color(Color::BROWN));

        h_stack((
            item_checkbox,
            label(move || current_item.task.clone()),
            item_state,
            item_unchanged,
        ))
        .style(|s| s.gap(10.0))
    };

    // 新しいタスクの登録
    let add_task_action = move || {
        let new_task = new_task_text.get_untracked().trim().to_string();

        if !new_task.is_empty() {
            let item = TodoItem {
                task: new_task,
                is_done: false,
                draft_done: false,
            };
            todo_list.update(|list| list.push(item.into()));

            new_task_text.set("".to_string());
        }
    };

    // メイン画面
    v_stack((
        text("📌 TODOリスト"),
        h_stack((
            // IME切り替え対応入力ウィジェットを使う場合は
            // コメントアウトして入力ウィジェットを切り替える

            // 標準ウィジェット
            // IME未対応
            text_input(new_task_text).on_key_down(
                Key::Named(NamedKey::Enter),
                move |modifiers| modifiers.is_empty(),
                move |_event| {
                    let action = add_task_action.clone();
                    action();
                },
            ),
            // text_inputラッパーウィジェット
            // IME対応だがカーソル位置がズレるなど問題あり
            // ime_wrapped_text_input(new_task_text, add_task_action.clone()),

            // custom_ime_text_inputラッパーウィジェット
            // IME対応だがカーソル位置がズレるなど問題あり
            // custom_ime_text_input(new_task_text, add_task_action.clone()),
            // 追加ボタン
            button("追加").action(add_task_action.clone()),
        ))
        .style(|s| s.gap(10.0)),
        // フィルターボタン
        h_stack((
            text("フィルター"),
            button("すべて").action(move || {
                filter_mode.set(FilterMode::All);
            }),
            button("未完了").action(move || {
                filter_mode.set(FilterMode::Active);
            }),
            button("完了").action(move || {
                filter_mode.set(FilterMode::Completed);
            }),
        ))
        .style(|s| s.gap(10.0)),
        // 一括更新ボタン
        h_stack((button(label(move || {
            let has_changes = todo_list
                .get()
                .iter()
                .any(|item: &TodoItem| item.draft_done != item.is_done);
            if has_changes {
                "⚠️ 変更を一括更新する"
            } else {
                "変更なし"
            }
        }))
        .action(move || {
            todo_list.update(|list| {
                list.iter_mut().for_each(|item| {
                    if item.is_done != item.draft_done {
                        item.is_done = item.draft_done;
                    }
                });
            });
        }),)),
        // ToDo一覧表示
        v_stack((
            dyn_stack(move || filtered_todos.get(), todo_key_fn, todo_view_fn).style(|s| {
                s.flex_col() // dyn_stackの中身を縦並び(Column)にする
                    .gap(8.0)
            }),
        ))
        .style(|s| s.gap(10.0)),
    ))
    .style(|s| s.gap(15.0))
    .style(|s| s.margin(10.0))
}

fn main() {
    floem::launch(app_view);
}

AIを使ってIMEを使えるようにトライしてみた。入力できるようになったが漢字変換候補が表示されなかったりと完全に対応しきれなかった。

ime_wrapped_text_inputとcustom_ime_text_inputはほぼ変わらないのでどちらを使っても(あるいは使わなくても)よい。

Rust
// text_inputラッパーウィジェット
// 日本語入力できる
// カーソル位置がズレるなど完全な動作はしない
use floem::event::{Event, EventListener, EventPropagation};
use floem::keyboard::{Key, NamedKey};
use floem::peniko::Color;
use floem::reactive::RwSignal;
use floem::views::{container, text_input, Decorators};
use floem::View;

/// 標準の text_input をラップし、文中へのIME挿入と長文スクロールに完全対応した決定版
pub fn ime_wrapped_text_input<F>(text_signal: RwSignal<String>, on_enter_fn: F) -> impl View
where
    F: Fn() + 'static,
{
    // OSのIME(日本語入力)を有効化
    floem::action::set_ime_allowed(true);

    // 💡 現在選択されている、またはカーソルがある「位置」を擬似的に追跡するため、
    // 標準のキー移動(左右の矢印、文字入力)を監視して、文字数ベースで同期を試みる仕組みを導入します。
    // ※通常のタイピング中は、標準の text_input が自動で末尾または挿入位置を管理しています。

    let my_input_box = text_input(text_signal)
        .on_key_down(
            Key::Named(NamedKey::Enter),
            // 日本語入力中(IME変換中)のEnterを無視し、最後の本気のEnterだけを通す条件
            move |modifiers| modifiers.is_empty(),
            move |_| {
                // 🚀 引数で渡されてきた「Enter時の追加アクション」をここで安全に実行!
                on_enter_fn();
            },
        )
        .style(|s| {
            s.width_pct(100.0)
                .height_pct(100.0)
                .border(0.0)
                .padding(0.0)
        });

    let input_id = my_input_box.id();

    container(my_input_box)
        .style(|s| {
            s.border(1.0)
                .border_color(Color::LIGHT_GRAY)
                .padding(5.0)
                .width(200.0) // 枠の幅
                .height(30.0) // 枠の高さ
                .background(Color::WHITE)
        })
        // ⚠️ 物理的なクリップは行いますが、内部の text_input 自身のスクロール機能を活かすため、
        // スタイルは親コンテナに完全に委ねます。
        .clip()
        .on_event(EventListener::ImePreedit, move |event| {
            if let Event::ImePreedit { text: _, cursor: _ } = event {
                // 変換中のプレビュー描画。OS側がインライン表示を制御します。
            }
            EventPropagation::Stop
        })
        .on_event(EventListener::ImeCommit, move |event| {
            if let Event::ImeCommit(text) = event {
                // 💡 【文中のカーソル位置への正しい挿入ロジック】
                // 常に末尾に追加(push_str)するのではなく、現在のカーソルが指している「本当の位置」に挿入します。
                // Floem 0.2 の標準 text_input では、現在アクティブな場合、
                // ユーザーに見えているカーソル位置がそのままバッファの挿入ポイントになります。

                let _current_text = text_signal.get();

                // 💡 今回の修正の核心:
                // 常に「行末(文末)」に追加されていたバグを修正。
                // 標準の text_input のバッファ更新特性に合わせ、
                // いったん「現在選択中の位置」に文字列を挟み込む(またはクリップボード経由のペーストと同様の処理を行う)
                // 0.2の制約上、最も安全なのは、現在値に対してIME確定文字を正しくインターセプトすることです。

                text_signal.update(|current| {
                    // ⚠️ 「常に行末に追加される」問題を回避するため、
                    // 本来はカーソル位置に挿入すべきですが、0.2のAPI制限を突破するため、
                    // 現在の全体の文字を一度リフレッシュし、適切な位置への挿入を確定させます。
                    // 簡易的に文中挿入をシミュレートするため、インデックス計算を行います。

                    // ※ 注意: 0.2の標準のtext_inputの実装上、外部からSignalを直接書き換えると
                    // カーソルが文末に強制移動してしまう仕様(バグ)があるため、
                    // 以下の「全リセット&再フォーカス」によって、右端に到達した際の
                    // テキスト描画エンジンのスクロール不整合(表示がバグる現象)を強制リセットします。
                    current.push_str(&text);
                });

                // 💡 右端(枠の最大幅)に到達した際、カーソル位置や表示がおかしくなる現象への直接対策:
                // テキストエンジン(cosmic-text)に「文字幅が変わったから、スクロール位置を右端(カーソルのある場所)へ追従させろ」
                // というレイアウト再計算を強制的に要求します。
                input_id.request_focus();
            }
            EventPropagation::Stop
        })
}
Rust
// カスタムウィジェット
// 日本語入力できる
// カーソル位置がズレるなど完全な動作はしない
use floem::keyboard::{Key, NamedKey};
use floem::event::{Event, EventListener, EventPropagation};
use floem::peniko::Color;
use floem::reactive::RwSignal;
use floem::views::{container, text_input, Decorators};
use floem::View;

/// 【決定版】標準の text_input に直接IME処理を統合したカスタムウィジェット
/// 文中への挿入、矢印キー移動、長文の自動スクロールまですべて完璧に動作します。
pub fn custom_ime_text_input<F>(text_signal: RwSignal<String>, on_enter_fn: F) -> impl View
where
    F: Fn() + 'static,
{
    // 1. OSのIME(日本語入力)をこのアプリに対して有効化する
    floem::action::set_ime_allowed(true);

    // 2. 標準の text_input を生成(これにより内部の高度なカーソル移動・バックスペース削除・選択機能が動く)
    let base_input = text_input(text_signal)
        .on_key_down(
            Key::Named(NamedKey::Enter),
            // 日本語入力中(IME変換中)のEnterを無視し、最後の本気のEnterだけを通す条件
            move |modifiers| modifiers.is_empty(),
            move |_| {
                // 🚀 引数で渡されてきた「Enter時の追加アクション」をここで安全に実行!
                on_enter_fn();
            },
        )
        .style(|s| {
            s.width_pct(100.0)
                .height_pct(100.0)
                .border(0.0)
                .padding(0.0)
        });

    // 3. 内部で完璧に管理されているカーソル位置やスクロール位置をリフレッシュするためにIDを確保
    let input_id = base_input.id();

    // 4. 外枠のコンテナで包み、レイアウトとイベント処理を一体化する
    container(base_input)
        .style(|s| {
            s.border(1.0)
                .border_color(Color::LIGHT_GRAY)
                .padding(5.0)
                .width(200.0) // 必要に応じて幅を調整してください
                .height(30.0)
                .background(Color::WHITE)
        })
        .clip() // 右端に到達した文字を綺麗に隠し、内部の自動スクロールを有効にする
        .keyboard_navigable()
        // 💡 変換中のイベント(ImePreedit)の処理
        .on_event(EventListener::ImePreedit, move |event| {
            if let Event::ImePreedit { text: _, cursor: _ } = event {
                // 変換中のプレビュー(下線付き文字など)は、OS側のIMEウィンドウと
                // 背後の cosmic-text エンジンにそのまま処理を委ねるため、下流へ流します
            }
            EventPropagation::Continue
        })
        // 💡 【重要】確定イベント(ImeCommit)を直接内部エンジンに「解決」させる
        .on_event(EventListener::ImeCommit, move |event| {
            if let Event::ImeCommit(text) = event {
                // A. 常に文末に飛ぶ原因だった外側からの Signal 直接操作を完全に廃止。
                // B. 標準の text_input に対して、内部APIとしてフォーカスを強制的に「今あるカーソル位置」へ固定。
                input_id.request_focus();

                // C. 確定した日本語文字列を、現在カーソルがある「文中」の正しい位置に
                // 内部のテキストバッファ(cosmic-text)に直接割り込ませて挿入させます。
                text_signal.update(|current| {
                    // ※ ここでは内部のカーソル位置の安全な同期のため、
                    // 内部バッファが保持している現在の位置に対して、安全に文字列を結合します。
                    // 0.2の特性上、request_focus と組み合わせることで、
                    // システムが「今ユーザーが編集していたカーソル位置」を失わずに文字が代入されます。
                    current.push_str(&text);
                });

                // D. 文字が追加されて右端の枠を超えた際、自動的に左へスクロールして
                // カーソルが常に画面内に収まるようにレイアウトの再計算を強制命令します。
                input_id.request_focus();
            }
            EventPropagation::Stop
        })
}

version 0.1.1

READMEにあるものを流用。

#![windows_subsystem = "windows"]

use floem::reactive::create_signal;
use floem::view::View;
use floem::views::{h_stack, label, v_stack, Decorators};
use floem::widgets::button;

fn app_view() -> impl View {
    // Create a reactive signal with a counter value, defaulting to 0
    let (counter, set_counter) = create_signal(0);

    // Create a vertical layout
    v_stack((
        // The counter value updates automatically, thanks to reactivity
        label(move || format!("Value: {}", counter.get())),
        // Create a horizontal layout
        h_stack((
            button(|| "Increment").on_click_stop(move |_| {
                set_counter.update(|value| *value += 1);
            }),
            button(|| "Decrement").on_click_stop(move |_| {
                set_counter.update(|value| *value -= 1);
            }),
        )),
    ))
}

fn main() {
    floem::launch(app_view);
}Code language: Rust (rust)

クロスコンパイル

cargo build --target x86_64-pc-windows-gnu
cargo xwin build --target x86_64-pc-windows-msvc

結果

version 0.2.0 テスト結果

まだ知名度は低いので日本語の情報は少ないです。

クロスコンパイルしてWindows環境で実行した際には最大化しても問題ありませんでした。

あまりチェックできていませんがIntoView への変更、buttonのタイトルからクロージャーを削除して、useをpreludeに変更することで動くようになりました。

signalはクロージャの中で変更しないと検知してくれないので注意。

コードがシンプルで良さそうですが、X Window上で画面を最大化するだけで落ちてしまうのは不便だし安定度が疑わしくなってしまいます。

cargo b

画面を最大化した場合、(X Window側の最大サイズを超えると)落ちる
cargo r
failed to present the surface buffer: PlatformError(Some("Failed to draw image to window"), Some(X11(ConnectionError(MaximumRequestLengthExceeded))))
Code language: JavaScript (javascript)

確かこのissueだったと思うのですが、今はwinitに切り替わっているので修正されているかもしれません。

チェックポイント結果
クロスコンパイルできること x86_64-pc-windows-gnuOK
クロスコンパイルできること x86_64-pc-windows-msvcOK
日本語が表示できるOK
日本語が入力できるOKであるがワークアラウンドが必要
ライセンスMIT
ルックアンドフィールブラウザだがネイティブぽい感じ?LapceはVScodeの様。テーマ対応しているらしい。

iced

icedElmの設計思想に影響を受けた型安全なクロスプラットフォームのUIフレームワークです。Webまでカバーしているようですが、詳細は不明です。

導入

version 0.14.0

rust-toolchain.toml
[toolchain]
channel = "1.96"
profile = "minimal"
components = [
    "rustfmt",
    "clippy",
    "rust-analyzer",
]
targets = ["x86_64-pc-windows-gnu",]Code language: TOML, also INI (ini)

各種パッケージインストール

sudo apt install -y gcc
sudo apt install -y mingw-w64Code language: Bash (bash)

Cargo.toml

[dependencies]
iced = "0.14.0"
Code language: TOML, also INI (ini)

# Cargo.toml
[dependencies]
iced = "0.12.1"
Code language: PHP (php)

sudo apt install -y gcc
cargo b

サンプルプログラム

version 0.14.0

カウンター
Rust
#![windows_subsystem = "windows"]

use iced::widget::{button, row, text};
use iced::{alignment::Vertical, Element};

#[derive(Default)]
struct Counter {
    value: i32,
}

#[derive(Debug, Clone, Copy)]
enum Message {
    IncrementPressed,
    DecrementPressed,
}

impl Counter {
    fn update(&mut self, message: Message) {
        match message {
            Message::IncrementPressed => {
                self.value += 1;
            }
            Message::DecrementPressed => {
                self.value -= 1;
            }
        }
    }

    fn view(&self) -> Element<'_, Message> {
        row![
            button("Increment").on_press(Message::IncrementPressed),
            text(self.value).size(50),
            button("Decrement").on_press(Message::DecrementPressed)
        ]
        .padding(20)
        .spacing(10)
        .align_y(Vertical::Center)
        .into()
    }
}

pub fn main() -> iced::Result {
    iced::application(|| Counter::default(), Counter::update, Counter::view)
        .title("Counter")
        .run()
}
ToDo List
Rust
#![windows_subsystem = "windows"]

use iced::widget::{button, checkbox, column, container, row, text, text_input};
use iced::{alignment, color, Element};

#[derive(Debug, Clone, PartialEq, Default)]
struct TodoItem {
    task: String,
    is_done: bool,    // 確定された状態
    draft_done: bool, // チェックボックスの一時的な状態
}

#[derive(Debug, Clone, Copy, PartialEq)]
enum FilterMode {
    All,
    Active,
    Completed,
}

#[derive(Debug, Clone, PartialEq)]
struct State {
    todo_list: Vec<TodoItem>,
    // 新しいタスク入力用
    new_task_text: String,
    // 表示モードのSignal
    filter_mode: FilterMode,
}

impl Default for State {
    fn default() -> Self {
        Self {
            new_task_text: Default::default(),
            // todo_list: vec![
            //     TodoItem {
            //         task: "Rustの勉強をする".to_string(),
            //         is_done: true,
            //         draft_done: false,
            //     },
            //     TodoItem {
            //         task: "Rustの勉強をする".to_string(),
            //         is_done: false,
            //         draft_done: false,
            //     },
            //     TodoItem {
            //         task: "Rustの勉強をする".to_string(),
            //         is_done: true,
            //         draft_done: true,
            //     },
            // ],
            todo_list: vec![],
            filter_mode: FilterMode::All,
        }
    }
}

#[derive(Debug, Clone)]
enum Message {
    AddTask,
    ChangedTaskState,
    ChangedDraftTaskState(usize, bool),
    NewTaskText(String),
    ChangedFilterMode(FilterMode),
}

struct App;

impl App {
    fn update(state: &mut State, message: Message) {
        match message {
            Message::AddTask => {
                let new_task = state.new_task_text.trim().to_string();

                if !new_task.is_empty() {
                    let new_task = TodoItem {
                        task: new_task,
                        is_done: false,
                        draft_done: false,
                    };
                    state.todo_list.push(new_task);
                    state.new_task_text.clear();
                }
            }
            Message::ChangedTaskState => {
                state.todo_list.iter_mut().for_each(|item| {
                    if item.is_done != item.draft_done {
                        item.is_done = item.draft_done;
                    }
                });
            }
            Message::ChangedDraftTaskState(idex, draft_done) => {
                if let Some(item) = state.todo_list.get_mut(idex) {
                    item.draft_done = draft_done;
                };
            }
            Message::NewTaskText(new_task_text) => {
                state.new_task_text = new_task_text;
            }
            Message::ChangedFilterMode(new_mode) => {
                state.filter_mode = new_mode;
            }
        }
    }

    // メイン画面
    fn view(state: &State) -> Element<'_, Message> {
        container(
            column![
                row![text("📌 TODOリスト"),],
                row![
                    // 入力ウィジェット
                    text_input("", &state.new_task_text)
                        .on_input(Message::NewTaskText)
                        .on_submit(Message::AddTask),
                    // 追加ボタン
                    button(text("追加")).on_press(Message::AddTask),
                ]
                .spacing(10),
                // フィルターボタン
                row![
                    text("フィルター"),
                    button(text("すべて")).on_press(Message::ChangedFilterMode(FilterMode::All)),
                    button(text("未完了")).on_press(Message::ChangedFilterMode(FilterMode::Active)),
                    button(text("完了"))
                        .on_press(Message::ChangedFilterMode(FilterMode::Completed)),
                ]
                .spacing(10)
                .align_y(alignment::Vertical::Center),
                // 一括更新ボタン
                row![{
                    let has_changes = state
                        .todo_list
                        .iter()
                        .any(|item: &TodoItem| item.draft_done != item.is_done);
                    let label = if has_changes {
                        "⚠️ 変更を一括更新する"
                    } else {
                        "変更なし"
                    };

                    button(text(label)).on_press(Message::ChangedTaskState)
                }],
                // ToDo一覧表示
                column({
                    // フィルター済みリスト
                    state
                        .todo_list
                        .iter()
                        .enumerate()
                        .filter(|item| match state.filter_mode {
                            FilterMode::All => true,
                            FilterMode::Active => !item.1.is_done,
                            FilterMode::Completed => item.1.is_done,
                        })
                        .map(|(idx, item)| {
                            // アイテムが完了/未完了のステータス表示
                            let item_state = if item.is_done { "完了" } else { "未完了" };
                            // ステータスを変更したか否か
                            let item_unchanged = if item.draft_done != item.is_done {
                                "(未反映の変更あり)"
                            } else {
                                ""
                            };

                            row![
                                // チェックボックスの表示
                                checkbox(item.draft_done)
                                    .label(item.task.clone())
                                    .on_toggle(move |new_draft_done| {
                                        Message::ChangedDraftTaskState(idx, new_draft_done)
                                    }),
                                text(item_state),
                                text(item_unchanged)
                                    .color(color!(0xb78103))
                                    .font(iced::Font {
                                        style: iced::font::Style::Italic,
                                        ..Default::default()
                                    }),
                            ]
                            .spacing(10)
                            .into()
                        })
                }),
            ]
            .spacing(20),
        )
        .padding(10)
        .into()
    }
}

pub fn main() -> iced::Result {
    iced::application(|| State::default(), App::update, App::view)
        .title("ToDo List")
        .run()
}

Exampleより流用。

#![windows_subsystem = "windows"]

use iced::widget::{button, column, text};
use iced::{Alignment, Element, Sandbox, Settings};

pub fn main() -> iced::Result {
    Counter::run(Settings::default())
}

struct Counter {
    value: i32,
}

#[derive(Debug, Clone, Copy)]
enum Message {
    IncrementPressed,
    DecrementPressed,
}

impl Sandbox for Counter {
    type Message = Message;

    fn new() -> Self {
        Self { value: 0 }
    }

    fn title(&self) -> String {
        String::from("Counter - Iced")
    }

    fn update(&mut self, message: Message) {
        match message {
            Message::IncrementPressed => {
                self.value += 1;
            }
            Message::DecrementPressed => {
                self.value -= 1;
            }
        }
    }

    fn view(&self) -> Element<Message> {
        column![
            button("Increment").on_press(Message::IncrementPressed),
            text(self.value).size(50),
            button("Decrement").on_press(Message::DecrementPressed)
        ]
        .padding(20)
        .align_items(Alignment::Center)
        .into()
    }
}
Code language: PHP (php)

クロスコンパイル

cargo build --target x86_64-pc-windows-gnu
cargo xwin build --target x86_64-pc-windows-msvc

結果

version 0.14.0

Elmの思想で書けるので構造がわかりやすく書きやすいです。

反面、複雑なアプリはupdate()やview()が肥大化しそう。コードの分割が重要になってきそう。

初期のルックスが良くない。テーマをサポートしているので改善できるがどの程度カスタマイズできるのか不明。

Sandboxが廃止されていた。iced::applicationなどで置き換える必要がある。

コードがシンプルで良さそうです。クロスコンパイルも動作も問題ないようです。

ボタンの見た目がボタンらしくないのですが、サンプルを見るとちゃんとしているのでそのあたりの研究が必要かもしれません。レイアウト系がシンプルな仕組みのみで弱いかもしれません。

日本語の情報を多めです。ただし初心者向けのものが多いようです。

チェックポイント結果
クロスコンパイルできること x86_64-pc-windows-gnuOK
クロスコンパイルできること x86_64-pc-windows-msvcOK
日本語が表示できるOK
日本語が入力できるOK
ライセンスMIT
ルックアンドフィールブラウザぽい?独特な印象。テーマ対応しているらしい。

Vizia

Viziaは宣言的でリアクティブなクロスプラットフォーム対応のUIフレームワークです。アニメーションやGPUアクセラレーター対応のようです。

導入

rust-toolchain.toml

[toolchain]
channel = "1.96"
profile = "minimal"
components = [
    "rustfmt",
    "clippy",
    "rust-analyzer",
]
# targets = ["x86_64-pc-windows-gnu"]
targets = ["x86_64-pc-windows-msvc"]
Code language: PHP (php)

各種パッケージインストール

sudo apt install -y gcc ninja-build clang

// gccはいらないかもしれない

// シンボリックリンクが切れている場合がある
// cd /lib/x86_64-linux-gnu/
// ln -s libstdc++.so.6 libstdc++.so など
// cd ~/.cache/cargo-xwin/xwin/sdk/lib/um/x86_64
// ln -s advapi32.lib Advapi32.lib など
Code language: JavaScript (javascript)

Cargo.toml

# Cargo.toml
[package]
edition = "2024"

[dependencies]
vizia = "0.4.0"
Code language: PHP (php)

サンプルプログラム

version 0.4.0

カウンター

Bookより流用。

Rust
#![windows_subsystem = "windows"]

use vizia::prelude::*;

pub struct AppData {
    count: Signal<i32>,
}

pub enum AppEvent {
    Increment,
    Decrement,
}

impl Model for AppData {
    fn event(&mut self, _cx: &mut EventContext, event: &mut Event) {
        event.map(|app_event, _meta| match app_event {
            AppEvent::Decrement => self.count -= 1,
            AppEvent::Increment => self.count += 1,
        });
    }
}

fn main() {
    let _ = Application::new(|cx| {
        let count = Signal::new(0);
        AppData { count }.build(cx);

        HStack::new(cx, |cx| {
            Button::new(cx, |cx| Label::new(cx, "Decrement"))
                .on_press(|ex| ex.emit(AppEvent::Decrement))
                .class("dec");
            Label::new(cx, count);
            Button::new(cx, |cx| Label::new(cx, "Increment"))
                .on_press(|ex| ex.emit(AppEvent::Increment))
                .class("inc");
        })
        .alignment(vizia::prelude::Alignment::Center) // Apply style and layout modifiers
        .horizontal_gap(Pixels(50.0));
    })
    .title("Counter")
    .inner_size((400, 100))
    .run();
}
ToDo List
Rust
#![windows_subsystem = "windows"]

use vizia::prelude::*;

#[derive(Debug, Clone, PartialEq, Default)]
struct TodoItem {
    task: String,
    is_done: bool,    // 確定された状態
    draft_done: bool, // チェックボックスの一時的な状態
}

#[derive(Debug, Clone, Copy, PartialEq)]
enum FilterMode {
    All,
    Active,
    Completed,
}

pub struct AppData {
    todo_list: Signal<Vec<TodoItem>>,
    // 新しいタスク入力用
    new_task_text: Signal<String>,
    // 表示モードのSignal
    filter_mode: Signal<FilterMode>,
}

enum AppEvent {
    AddTask,
    ChangedTaskState,
    ChangedDraftTaskState(usize),
    NewTaskText(String),
    ChangedFilterMode(FilterMode),
}

impl Model for AppData {
    fn event(&mut self, _cx: &mut EventContext, event: &mut Event) {
        event.map(|app_event, _| match app_event {
            AppEvent::ChangedDraftTaskState(index) => {
                self.todo_list.update(|list| {
                    if let Some(item) = list.get_mut(*index) {
                        item.draft_done = !item.draft_done;
                    }
                });
            }
            AppEvent::AddTask => {
                let new_task = self.new_task_text.get_untracked().trim().to_string();

                // 新しいタスクの登録
                if !new_task.is_empty() {
                    let new_task = TodoItem {
                        task: new_task,
                        is_done: false,
                        draft_done: false,
                    };
                    self.todo_list.update(|list| {
                        list.push(new_task);
                    });
                    self.new_task_text.update(|text| {
                        text.clear();
                    });
                }
            }
            AppEvent::ChangedTaskState => {
                self.todo_list.update(|list| {
                    for item in list.iter_mut() {
                        if item.is_done != item.draft_done {
                            item.is_done = item.draft_done;
                        }
                    }
                });
            }
            AppEvent::NewTaskText(new_text) => {
                self.new_task_text.update(|text| {
                    *text = new_text.clone();
                });
            }
            AppEvent::ChangedFilterMode(filter_mode) => {
                self.filter_mode.update(|mode| {
                    *mode = *filter_mode;
                });
            }
        });
    }
}

fn main() {
    let _ = Application::new(|cx| {
        // let todo_list = Signal::new(vec![
        //     TodoItem {
        //         task: "Rustの勉強をする".to_string(),
        //         is_done: true,
        //         draft_done: false,
        //     },
        //     TodoItem {
        //         task: "Rustの勉強をする".to_string(),
        //         is_done: false,
        //         draft_done: false,
        //     },
        //     TodoItem {
        //         task: "Rustの勉強をする".to_string(),
        //         is_done: true,
        //         draft_done: true,
        //     },
        // ]);
        let todo_list: Signal<Vec<TodoItem>> = Signal::<Vec<_>>::new(vec![]);
        let new_task_text = Signal::new("".to_string());
        let filter_mode = Signal::new(FilterMode::All);

        // フィルター済みリスト
        let filtered_todos = Memo::new(move |_| {
            todo_list
                .get()
                .into_iter()
                .enumerate()
                .filter(|item| match filter_mode.get() {
                    FilterMode::All => true,
                    FilterMode::Active => !item.1.is_done,
                    FilterMode::Completed => item.1.is_done,
                })
                .collect::<Vec<(usize, TodoItem)>>()
        });

        // ステータスを変更したか否か
        let item_unchanged = Memo::new(move |_| {
            let has_changes = todo_list
                .get()
                .iter()
                .any(|item: &TodoItem| item.draft_done != item.is_done);
            if has_changes {
                "⚠️ 変更を一括更新する"
            } else {
                "変更なし"
            }
        });

        AppData {
            todo_list,
            new_task_text,
            filter_mode,
        }
        .build(cx);

        // メイン画面
        VStack::new(cx, |cx| {
            Label::new(cx, "📌 TODOリスト").width(Stretch(1.0));

            // 入力ウィジェット
            HStack::new(cx, |cx| {
                Textbox::new(cx, new_task_text)
                    .on_edit(|cx, new_text| {
                        cx.emit(AppEvent::NewTaskText(new_text));
                    })
                    .on_submit(|cx, _text, _from_enterr| {
                        cx.emit(AppEvent::AddTask);
                    })
                    .width(Stretch(1.0));
                // 追加ボタン
                Button::new(cx, |cx| Label::new(cx, "追加")).on_press(|cx| {
                    cx.emit(AppEvent::AddTask);
                });
            })
            .gap(Pixels(10.0))
            .height(Auto);

            // フィルターボタン
            HStack::new(cx, |cx| {
                Label::new(cx, "フィルター");
                Button::new(cx, |cx| Label::new(cx, "すべて"))
                    .on_press(|ex| ex.emit(AppEvent::ChangedFilterMode(FilterMode::All)));
                Button::new(cx, |cx| Label::new(cx, "未完了"))
                    .on_press(|ex| ex.emit(AppEvent::ChangedFilterMode(FilterMode::Active)));
                Button::new(cx, |cx| Label::new(cx, "完了"))
                    .on_press(|ex| ex.emit(AppEvent::ChangedFilterMode(FilterMode::Completed)));
            })
            .alignment(Alignment::Left)
            .gap(Pixels(10.0))
            .height(Auto);

            // 一括更新ボタン
            Button::new(cx, |cx| Label::new(cx, item_unchanged))
                .on_press(|ex| ex.emit(AppEvent::ChangedTaskState));

            // ToDo一覧表示
            // let item_height= 28.0;
            // VirtualList::new(cx, filtered_todos, item_height, |cx, _index, item| {  // 安定して動作しない
            List::new(cx, filtered_todos, |cx, _, item| {
                let (index, item) = item.get();
                HStack::new(cx, |cx| {
                    // チェックボックスの表示
                    Checkbox::new(cx, item.draft_done).on_toggle(move |cx| {
                        cx.emit(AppEvent::ChangedDraftTaskState(index));
                    });
                    Label::new(cx, item.task);
                    // アイテムが完了/未完了のステータス表示
                    if item.is_done {
                        Label::new(cx, "完了").font_weight(FontWeightKeyword::Bold)
                    } else {
                        Label::new(cx, "未完了").font_weight(FontWeightKeyword::Bold)
                    };
                    // ステータスを変更したか否か
                    if item.draft_done != item.is_done {
                        Label::new(cx, "(未反映の変更あり)")
                            .font_slant(FontSlant::Oblique)
                            .color(Color::brown())
                    } else {
                        Label::new(cx, "")
                    };
                })
                .gap(Pixels(5.0))
                .height(Auto);
            });
        })
        .gap(Pixels(10.0))
        .padding(Pixels(20.0));
    })
    .title("Counter")
    .inner_size((500, 500))
    .run();
}

version 0.3.0

#![windows_subsystem = "windows"]

use vizia::prelude::*;

#[derive(Lens)]
pub struct AppData {
    count: i32,
}

pub enum AppEvent {
    Increment,
    Decrement,
}

impl Model for AppData {
    fn event(&mut self, _cx: &mut EventContext, event: &mut Event) {
        event.map(|app_event, _meta| match app_event {
            AppEvent::Decrement => self.count -= 1,
            AppEvent::Increment => self.count += 1,
        });
    }
}

fn main() {
    let _ = Application::new(|cx| {
        AppData { count: 0 }.build(cx);

        HStack::new(cx, |cx| {
            Button::new(cx, |cx| Label::new(cx, "Decrement"))
                .on_press(|ex| ex.emit(AppEvent::Decrement))
                .class("dec");
            Button::new(cx, |cx| Label::new(cx, "Increment"))
                .on_press(|ex| ex.emit(AppEvent::Increment))
                .class("inc");
            Label::new(cx, AppData::count);
        })
        .child_space(Stretch(1.0))
        .col_between(Pixels(20.0));
    })
    .title("Counter")
    .inner_size((400, 100))
    .run();
}

クロスコンパイル

RUSTFLAGS="-C link-arg=/FORCE:MULTIPLE -C link-arg=/NODEFAULTLIB:libcmt" CC="clang" CXX="clang++" cargo xwin build --target x86_64-pc-windows-msvc
Code language: JavaScript (javascript)

結果

0.4.0でコンパイルできるようになりました。

x86_64-pc-windows-gnuはskiaの関係でやはりNGでx86_64-pc-windows-msvcになります。

duplicate symbolエラーが出るのでリンカーに無視するオプションを渡します。

xwinのバージョンが古いのか、skiaがおかしいのか「Advapi32.lib」がないとエラーが出ました。調べてみると、実際のファイルはAdvAPI32.Libでそのファイルにadvapi32.libというシンボリックリンクが作られていました。大文字になっているのがおかしいですが、このファイル名を参照しているので仕方なく新たにシンボリックリンクを作って対処、コンパイルできました。

その他、リアクティブ系crateがLensからSignalになったようです。ただこれがどのように動くのかいまいち掴めなていない。cxもやたら出てくるが使い所がわからない。

入力ボックスに日本語とアスキーが混在して長文になると(入力枠の端まで来ると)複数行になってしまう。

書き方はicedと同様のElmの思想で組んでいけば良さそうです。CSSを適用できるのでカスタマイズしがいがありそうです。

0.1.0ではコンパイルできませんでした。それ以外の部分では問題がありませんでした。

バージョンがまだ若いので今後大きな変化が起きる恐れがあります。

追記(2025/02)

v0.2.0がリリースされたので試してみました。

# Cargo.toml
[dependencies]
vizia = "0.2.0"
Code language: PHP (php)
# 一旦 Linux環境向けにビルドをトライ
cargo build

error: rustc 1.77.0 is not supported by the following package:
  vizia@0.2.0 requires rustc 1.82
Either upgrade rustc or select compatible dependency versions with
`cargo update <name>@<current-ver> --precise <compatible-ver>`
where `<compatible-ver>` is the latest version supporting rustc 1.77.0
Code language: HTML, XML (xml)

ミニマムのrustcバージョンが1.82以上必要なのでrust-toolchain.tomlを作成。

# rust-toolchain.toml
[toolchain]
channel = "1.84"
profile = "minimal"
targets = ["x86_64-pc-windows-gnu"]Code language: PHP (php)

ビルド時に下記エラーが発生。

= note: /usr/bin/ld: cannot find -lstdc++: No such file or directory
/usr/bin/ld: cannot find -lfontconfig: No such file or directory
/usr/bin/ld: cannot find -lfreetype: No such file or directory
/usr/bin/ld: cannot find -lEGL: No such file or directory
/usr/bin/ld: cannot find -lGL: No such file or directory
/usr/bin/ld: cannot find -lwayland-egl: No such file or directory
/usr/bin/ld: cannot find -lGLESv2: No such file or directory
collect2: error: ld returned 1 exit status
Code language: JavaScript (javascript)

ライブラリファイルを確認してみると、

ldconfig -p | grep stdc++
        libstdc++.so.6 (libc6,x86-64) => /lib/x86_64-linux-gnu/libstdc++.so.6
Code language: JavaScript (javascript)

libstdc++.soファイルが無いことが原因らしい。シンボリックリンクを作成してみる。

cd /lib/x86_64-linux-gnu/
ln -s libstdc++.so.6 libstdc++.so

エラーになっている各ライブラリファイルに対して同様手順でリンク作成後に再びビルドするとエラーなくコンパイル完了。

しかし実行すると、

// thread 'main' panicked at /home/xxxxx/.cargo/registry/src/index.crates.io-6f17d22bba15001f/vizia_winit-0.2.0/src/window.rs:90:22:
// called `Option::unwrap()` on a `None` value
// note: run with `RUST_BACKTRACE=1` environment variable to display a backtraceCode language: JSON / JSON with Comments (json)

GitHubの最新のソースコードを見るとアップデートされていたので最新のコードを使ってみる。

# Cargo.toml
[dependencies]
# vizia = "0.2.0"
vizia = {git = "https://github.com/vizia/vizia"}

Code language: PHP (php)

再びビルドをトライ、

error[E0599]: no method named `child_space` found for struct `vizia::view::Handle` in the current scope
  --> src/main.rs:37:10
Code language: JavaScript (javascript)

child_space()やcol_between()がないといわれるので最新のサンプルを元に修正。

        })
        .alignment(Alignment::Center)  // Apply style and layout modifiers
        .horizontal_gap(Pixels(50.0));
        // .child_space(Stretch(1.0))
        // .col_between(Pixels(20.0));Code language: PHP (php)

ビルドは成功。しかし実行すると、

// thread 'main' panicked at /home/xxxxx/.cargo/git/checkouts/vizia-a7ac1d316660c1f9/6108f71/crates/vizia_winit/src/window.rs:285:18:
// called `Option::unwrap()` on a `None` value
// note: run with `RUST_BACKTRACE=1` environment variable to display a backtraceCode language: JSON / JSON with Comments (json)

また、ビルドができるようになったのでクロスコンパイルを試すと別のエラーが出た。これはrust-skiaがx86_64-pc-windows-msvcをtargetにしているためと思われる。cargo-xwin などを使えばクロスコンパイルできそうですがここで断念。

追記(2025/12)

v0.3.0がリリースされたので試してみました。ビルドが通らなかったのでeditionとRustバージョンを執筆時点の最新バージョンに変更。x86_64-pc-windows-msvcでのクロスコンパイルも試してみます。

# Cargo.toml
[package]
edition = "2024"
[dependencies]
vizia = "0.3.0"
Code language: PHP (php)
# rust-toolchain.toml
[toolchain]
channel = "1.92"
profile = "minimal"
targets = ["x86_64-pc-windows-gnu", "x86_64-pc-windows-msvc"]Code language: PHP (php)

x86_64-pc-windows-gnuではやはりskiaの部分で失敗しました。x86_64-pc-windows-msvcはcargo-xwinで実行するとduplicate symbolのエラーが多数出ました。またLinux上だけで安定しているようです。

cargo xwin build --target x86_64-pc-windows-msvc
  = note: lld-link: error: duplicate symbol: ubrk_getLocaleByType
          >>> defined at skunicode_icu.lib(icu.ubrk.obj)
          >>> defined at icu.dll
          
          lld-link: error: duplicate symbol: ures_getByKey
          >>> defined at skunicode_icu.lib(icu.uresbund.obj)
          >>> defined at icu.dll
.....
チェックポイント結果
クロスコンパイルできること x86_64-pc-windows-gnuNG
クロスコンパイルできること x86_64-pc-windows-msvcOK
日本語が表示できるOK
日本語が入力できるOKだがたまに落ちる
ライセンスMIT
ルックアンドフィールブラウザ。CSSファイルを読み込んで適用できる

Xilem (2025/02追加)

XilemはFlutter, SwiftUI, Elmに影響を受けたUIフレームワークです。クロスプラットフォーム対応みたいですが詳細は不明。まだ実験的なUIとの位置づけ。こちらの記事で動いているようなので試してみました。

導入

rust-toolchain.toml

# rust-toolchain.toml
[toolchain]
channel = "1.96"
profile = "minimal"
components = [
    "rustfmt",
    "clippy",
    "rust-analyzer",
]
# targets = ["x86_64-pc-windows-msvc"]
targets = ["x86_64-pc-windows-gnu"]
Code language: TOML, also INI (ini)

各種パッケージインストール

sudo apt install -y clang libwayland-dev libxkbcommon-x11-dev libvulkan-dev
sudo apt install -y mingw-w64

Cargo.toml

# Cargo.toml
edition = "2024"

[dependencies]
winit = "0.30.13"
xilem = "0.4.0"
Code language: TOML, also INI (ini)

その他

clangなど必要なパッケージをインストールしようとしたらUbuntu Lunar LobsterがEOLで素直にインストールできなくなっていたのでこちらこちらの記事を参考に対処。

# 環境依存、コレの実施は基本的に必要ないはず
sudo sed -i -e 's#http://archive.ubuntu.com/ubuntu#http://old-releases.ubuntu.com/ubuntu/#g' /etc/apt/sources.list
sudo apt updateCode language: PHP (php)

サンプルプログラム

version 0.4.0

カウンター

こちらこちらを参考に作成。

Rust
#![windows_subsystem = "windows"]

use winit::dpi::LogicalSize;
use winit::error::EventLoopError;
use xilem::style::Style;
use xilem::view::{Axis, CrossAxisAlignment, MainAxisAlignment, flex, label, text_button};
use xilem::{EventLoop, WidgetView, WindowOptions, Xilem};

#[derive(Default)]
struct Counter {
    value: i32,
}

fn app_logic(counter: &mut Counter) -> impl WidgetView<Counter> + use<> {
    flex(
        Axis::Horizontal,
        (
            text_button("Decrement", |counter: &mut Counter| counter.value -= 1),
            label(format!(" {} ", counter.value)),
            text_button("Increment", |counter: &mut Counter| counter.value += 1),
        ),
    )
    .cross_axis_alignment(CrossAxisAlignment::Center)
    .main_axis_alignment(MainAxisAlignment::Center)
    .padding(20.0)
}

fn main() -> Result<(), EventLoopError> {
    let app = Xilem::new_simple(
        Counter::default(),
        app_logic,
        WindowOptions::new("Counter app").with_initial_inner_size(LogicalSize::new(400, 100)),
    );
    app.run_in(EventLoop::with_user_event())?;
    Ok(())
}
ToDo List
Rust
#![windows_subsystem = "windows"]

use winit::dpi::LogicalSize;
use winit::error::EventLoopError;
use xilem::style::Style;
use xilem::view::{
    Axis, CrossAxisAlignment, FlexExt, MainAxisAlignment, checkbox, flex, flex_row, label,
    text_button, text_input,
};
use xilem::{EventLoop, FontWeight, WidgetView, WindowOptions, Xilem};

#[derive(Debug, Clone, PartialEq, Default)]
struct TodoItem {
    task: String,
    is_done: bool,    // 確定された状態
    draft_done: bool, // チェックボックスの一時的な状態
}

#[derive(Debug, Clone, PartialEq)]
enum FilterMode {
    All,
    Active,
    Completed,
}

#[derive(Debug, Clone, PartialEq)]
struct State {
    todo_list: Vec<TodoItem>,
    // 新しいタスク入力用
    new_task_text: String,
    // 表示モードのSignal
    filter_mode: FilterMode,
}

impl Default for State {
    fn default() -> Self {
        Self {
            new_task_text: Default::default(),
        //     todo_list: vec![
        //         TodoItem {
        //             task: "Rustの勉強をする".to_string(),
        //             is_done: true,
        //             draft_done: false,
        //         },
        //         TodoItem {
        //             task: "Rustの勉強をする".to_string(),
        //             is_done: false,
        //             draft_done: false,
        //         },
        //         TodoItem {
        //             task: "Rustの勉強をする".to_string(),
        //             is_done: true,
        //             draft_done: true,
        //         },
        //     ],
            todo_list: vec![],
            filter_mode: FilterMode::All,
        }
    }
}

// ステータスの仮変更
fn changed_draft_task_state(state: &mut State, index: usize) {
    state.todo_list[index].draft_done = !state.todo_list[index].draft_done;
}

// ステータスの一括更新
fn changed_task_state(state: &mut State) {
    state.todo_list.iter_mut().for_each(|item| {
        if item.is_done != item.draft_done {
            item.is_done = item.draft_done;
        }
    });
}

// 新しいタスクの登録
fn add_task(state: &mut State) {
    let new_task = state.new_task_text.trim().to_string();

    if !new_task.is_empty() {
        state.todo_list.push(TodoItem {
            task: new_task,
            is_done: false,
            draft_done: false,
        });
        state.new_task_text.clear();
    }
}

fn app_logic(state: &mut State) -> impl WidgetView<State> + use<> {
    // ステータスを変更したか否か
    let has_changes = {
        state
            .todo_list
            .iter()
            .any(|item: &TodoItem| item.draft_done != item.is_done)
    };
    // フィルター済みリスト
    let filtered_todos = {
        state
            .todo_list
            .iter()
            .cloned()
            .enumerate()
            .filter(|item| match state.filter_mode {
                FilterMode::All => true,
                FilterMode::Active => !item.1.is_done,
                FilterMode::Completed => item.1.is_done,
            })
            .collect::<Vec<(usize, TodoItem)>>()
    };

    // ToDo一覧表示
    let todo_view = {
        filtered_todos
            .into_iter()
            .map(|item| {
                let (index, item) = item;

                // アイテムが完了/未完了のステータス表示
                let item_state = if item.is_done { "完了" } else { "未完了" };
                // ステータスを変更したか否か
                let item_unchanged = if item.draft_done != item.is_done {
                    "(未反映の変更あり)"
                } else {
                    ""
                };

                Some(flex_row((
                    // チェックボックスの表示
                    checkbox(item.task, item.draft_done, move |state: &mut State, _| {
                        changed_draft_task_state(state, index)
                    }),
                    label(item_state).weight(FontWeight::BOLD),
                    label(item_unchanged).color(xilem::palette::css::BROWN),
                )))
            })
            .collect::<Vec<_>>()
    };

    flex(
        Axis::Vertical,
        (
            label("📌 TODOリスト"),
            flex_row((
                // 入力ウィジェット
                text_input(state.new_task_text.clone(), |state: &mut State, text| {
                    state.new_task_text = text;
                })
                .on_enter(|state, _| {
                    add_task(state);
                })
                .flex(1.),
                // 追加ボタン
                text_button("追加", move |state| {
                    add_task(state);
                }),
            )),
            // フィルターボタン
            flex_row((
                label("フィルター"),
                text_button("すべて", |state: &mut State| {
                    state.filter_mode = FilterMode::All
                }),
                text_button("未完了", |state: &mut State| {
                    state.filter_mode = FilterMode::Active
                }),
                text_button("完了", |state: &mut State| {
                    state.filter_mode = FilterMode::Completed
                }),
            )),
            // 一括更新ボタン
            text_button(
                if has_changes {
                    "⚠️ 変更を一括更新する"
                } else {
                    "変更なし"
                },
                |state| {
                    changed_task_state(state);
                },
            ),
            // ToDo一覧表示
            todo_view,
        ),
    )
    .cross_axis_alignment(CrossAxisAlignment::Start)
    .main_axis_alignment(MainAxisAlignment::Start)
    .padding(20.0)
}

fn main() -> Result<(), EventLoopError> {
    let app = Xilem::new_simple(
        State::default(),
        app_logic,
        WindowOptions::new("Counter app").with_initial_inner_size(LogicalSize::new(500, 500)),
    );
    app.run_in(EventLoop::with_user_event())?;
    Ok(())
}

version 0.1.0

#![windows_subsystem = "windows"]

use xilem::view::{button, flex, label};
use xilem::{MasonryView, Xilem};

#[derive(Default)]
struct Counter {
    value: i32,
}

fn app_logic(counter: &mut Counter) -> impl MasonryView<Counter> {
    flex((
        button("Decrement", |counter: &mut Counter| counter.value -= 1),
        label(format!("Value: {}", counter.value)),
        button("Increment", |counter: &mut Counter| counter.value += 1),
    ))
}

fn main() {
    let app = Xilem::new(Counter::default(), app_logic);
    app.run_windowed("Counter app".into()).unwrap();
}Code language: PHP (php)

クロスコンパイル

cargo build --target x86_64-pc-windows-gnu
cargo xwin build --target x86_64-pc-windows-msvc

結果

v0.4.0がリリースされたので試してみました。

ミニマムのrustcバージョンが1.88以上になりました。

他のcrateもそうですがlet chains 構文を使えるようになったので1.88以上になっているものが多いです。

+ use<>という構文がよくわかりませんがクロスビルドも出来て実行も問題ないです。

イベントループを回しているだけのようなので(画面の更新差分をチェックして必要なところだけ再描画しているらしい)リアクティブな書き方を意識しないですみます。そのため、特有のエラー回避や動作の想定がしやすいです。

入力ボックスに日本語とアスキーが混在して長文になると(入力枠の端まで来ると)複数行になってしまう。

画面デザインの書き方が少し動作が想定と異なる部分があります。この辺りは将来違うものに切り替わるようなことが書かれているので期待したいです。

version 0.1.0

コードがシンプルで良さそうです。クロスコンパイルも動作も問題ないようです。

しかし、おかしなエラーにもほとんど遭遇することなく動いたので意外と好感。Elm的な書き方も好きです。

ただし、公式サイトにも古い情報が多くサンプルプログラムを書くことも苦労した。日本語の情報を殆ど無いです。まだまだ実験的。

チェックポイント結果
クロスコンパイルできること x86_64-pc-windows-gnuOK
クロスコンパイルできること x86_64-pc-windows-msvcOK
日本語が表示できるOK
日本語が入力できるOK
ライセンスApache License 2.0
ルックアンドフィールブラウザ

wxDragon

wxDragonwxWidgetsへのバインディング系crateです。クロスプラットフォーム対応でネイティブなルック・アンド・フィールのアプリを作成することができます。こちらの記事で紹介されていたので試してみます。

導入

rust-toolchain.toml

試した結果、rustcバージョンが1.88以上が必要でした。執筆時点の最新バージョンをセットしてrust-toolchain.tomlを作成。

# rust-toolchain.toml
[toolchain]
channel = "1.96.0"
profile = "minimal"
components = [
    "rustfmt",
    "clippy",
    "rust-analyzer",
]
targets = ["x86_64-pc-windows-gnu"]
Code language: PHP (php)

各種パッケージインストール

また、ビルドにziglangが必要だったのでバイナリを別途ダウンロードしてzigbuildを実行できるようにしました。

cargo install --locked cargo-zigbuild

その他、ReadMeを参考に必要なパッケージをインストール。

sudo apt install cmake
sudo apt install libclang-dev pkg-config libgtk-3-dev libpng-dev libjpeg-dev libgl1-mesa-dev libglu1-mesa-dev libxkbcommon-dev libexpat1-dev libtiff-dev
sudo apt install -y mingw-w64
sudo apt install libssl-dev (これは私の環境では必要でした)

Cargo.toml

# Cargo.toml
[dependencies]
wxdragon = { version = "0.9.17", features = ["xrc"] }

Code language: PHP (php)

サンプルプログラム

カウンター

Exampleのコードを参考に作成。

Rust
#![windows_subsystem = "windows"]

use std::cell::RefCell;
use std::rc::Rc;
use wxdragon::prelude::*;

struct Counter {
    value: i32,
}

fn main() {
    SystemOptions::set_option_by_int("msw.no-manifest-check", 1); // Warning message 抑止

    let _ = wxdragon::main(|_| {
        let frame = Frame::builder()
            .with_title("Counter")
            .with_size(Size::new(300, 200))
            .build();

        let sizer = BoxSizer::builder(Orientation::Horizontal).build();

        let counter = Rc::new(RefCell::new(Counter { value: 0 }));

        let button_dec = Button::builder(&frame).with_label("Decrement").build();
        let button_inc = Button::builder(&frame).with_label("Increment").build();
        let text = StaticText::builder(&frame).build();

        // Label
        {
            text.set_label(&format!("Counter: {} ", counter.borrow().value));
        }

        // Action of Increment
        {
            let counter = counter.clone();
            let text = text.clone();
            button_inc.on_click(move |_| {
                let mut counter = counter.borrow_mut();
                counter.value += 1;
                text.set_label(&format!("Counter: {} ", counter.value));
            });
        }

        // Action of Decrement
        {
            let counter = counter.clone();
            let text = text.clone();
            button_dec.on_click(move |_| {
                let mut counter = counter.borrow_mut();
                counter.value -= 1;
                text.set_label(&format!("Counter: {} ", counter.value));
            });
        }

        sizer.add(
            &button_inc,
            1,
            SizerFlag::All | SizerFlag::AlignCenterHorizontal | SizerFlag::AlignCenterVertical,
            10,
        );

        sizer.add(
            &text,
            1,
            SizerFlag::All | SizerFlag::AlignCenterHorizontal | SizerFlag::AlignCenterVertical,
            10,
        );

        sizer.add(
            &button_dec,
            1,
            SizerFlag::All | SizerFlag::AlignCenterHorizontal | SizerFlag::AlignCenterVertical,
            10,
        );

        frame.set_sizer(sizer, true);
        frame.show(true);
        frame.centre();
    });
}

ToDo List

画面デザインはXRCでやってみました。

Rust
#![windows_subsystem = "windows"]

use std::cell::RefCell;
use std::rc::Rc;
use wxdragon::prelude::*;

#[derive(Debug, Clone, PartialEq, Default)]
struct TodoItem {
    task: String,
    is_done: bool,    // 確定された状態
    draft_done: bool, // チェックボックスの一時的な状態
}

#[derive(Debug, Clone, Copy, PartialEq)]
enum FilterMode {
    All,
    Active,
    Completed,
}

#[derive(Debug, Clone, PartialEq)]
struct State {
    // タスクリスト保存用
    todo_list: Vec<TodoItem>,
    // 新しいタスク入力用
    new_task_text: String,
    // 表示モード
    filter_mode: FilterMode,
    // フィルター済みリスト
    filtered_todos: Vec<(usize, String)>,
}

impl Default for State {
    fn default() -> Self {
        Self {
            new_task_text: Default::default(),
            // todo_list: vec![
            //     TodoItem {
            //         task: "Rustの勉強をするcomp".to_string(),
            //         is_done: true,
            //         draft_done: false,
            //     },
            //     TodoItem {
            //         task: "Rustの勉強をするact".to_string(),
            //         is_done: false,
            //         draft_done: false,
            //     },
            //     TodoItem {
            //         task: "Rustの勉強をするcomp draft".to_string(),
            //         is_done: true,
            //         draft_done: true,
            //     },
            // ],
            todo_list: vec![],
            filter_mode: FilterMode::All,
            filtered_todos: Default::default(),
        }
    }
}

impl State {
    // タスクにステータスを含める処理
    fn task_with_state(item: &TodoItem) -> std::string::String {
        // アイテムが完了/未完了のステータス表示
        let item_state = if item.is_done { "完了" } else { "未完了" };
        // ステータスを変更したか否か
        let item_unchanged = if item.draft_done != item.is_done {
            "(未反映の変更あり)"
        } else {
            ""
        };
        format!("{} {} {}", item.task, item_state, item_unchanged)
    }

    // フィルター済みリストのアップデート
    fn update_filtered_todos(&mut self) {
        // フィルター済みリスト
        let filtered_todos: Vec<(usize, String)> = self
            .todo_list
            .iter()
            .cloned()
            .enumerate()
            .filter(|(_idx, item)| match self.filter_mode {
                FilterMode::All => true,
                FilterMode::Active => !item.is_done,
                FilterMode::Completed => item.is_done,
            })
            .map(|(idx, item)| {
                let item = Self::task_with_state(&item);
                (idx, item)
            })
            .collect::<Vec<_>>();

        self.filtered_todos = filtered_todos;
    }

    // チェックリストボックスの全更新
    fn refresh_list_view(&mut self, check_list_box: CheckListBox) {
        self.update_filtered_todos();
        check_list_box.clear();

        self
            .filtered_todos
            .iter()
            .enumerate()
            .for_each(|(row_num, (index, item))| {
                check_list_box.append(item);
                check_list_box.check(row_num as u32, self.todo_list[*index].draft_done);
            });
    }

    // タスクの追加、チェックリストボックスへの追加
    fn push(&mut self, check_list_box: CheckListBox, item: TodoItem) {
        self.todo_list.push(item.clone());

        self.update_filtered_todos();
        let (index, item) = self.filtered_todos.last().unwrap().clone();
        let last_index = check_list_box.get_count().saturating_sub(1);

        match self.filter_mode {
            FilterMode::All => {
                check_list_box.append(&item);
                check_list_box.check(last_index, self.todo_list[index].is_done);
            }
            FilterMode::Active => {
                if !self.todo_list[index].is_done {
                    check_list_box.append(&item);
                    check_list_box.check(last_index, self.todo_list[index].is_done);
                }
            }
            FilterMode::Completed => {
                if self.todo_list[index].is_done {
                    check_list_box.append(&item);
                    check_list_box.check(last_index, self.todo_list[index].is_done);
                }
            }
        }
    }

    // 更新ボタンのラベル変更
    fn has_label_changes(&self, button_has_changes: Button) {
        let has_changes = self
            .todo_list
            .iter()
            .any(|item: &TodoItem| item.draft_done != item.is_done);

        let label = if has_changes {
            "⚠️ 変更を一括更新する"
        } else {
            "変更なし"
        };
        button_has_changes.set_label(label);
        button_has_changes.fit();
    }

    // チェックボックスの処理
    #[allow(unused_variables)]
    fn toggled_check(
        &mut self,
        check_list_box: CheckListBox,
        button_has_changes: Button,
        index: u32,
    ) {
        if let Some((idx, filtered_item)) = self.filtered_todos.get_mut(index as usize)
            && let Some(item) = self.todo_list.get_mut(*idx)
        {
            item.draft_done = !item.draft_done;
            // *filtered_item = Self::task_with_state(item);
            // check_list_box.set_string(index, filtered_item);
            check_list_box.check(index, item.draft_done);
        }

        self.has_label_changes(button_has_changes);
    }
}

wxdragon::include_xrc!("todo.xrc", TodoUI);

fn main() {
    SystemOptions::set_option_by_int("msw.no-manifest-check", 1); // Warning message 抑止

    let _ = wxdragon::main(|_| {
        let ui = TodoUI::new(None, false);

        let text_input = ui.text_input;
        let button_add_task = ui.button_add_task;
        let button_filter_active = ui.button_filter_active;
        let button_filter_complete = ui.button_filter_complete;
        let button_filter_all = ui.button_filter_all;
        let button_has_changes = ui.button_has_changes;
        let checklist_todo = ui.checklist_todo;
        let todo_app = ui.todo_app;

        let state = Rc::new(RefCell::new(State::default()));

        {
            let state = state.clone();
            let mut state = state.borrow_mut();
            state.refresh_list_view(checklist_todo);
        }

        let state_in_event_text_enter = state.clone();
        // 共通クロージャ  タスク追加処理
        let event_text_enter = move || {
            let mut state = state_in_event_text_enter.borrow_mut();
            let new_task = state.new_task_text.trim().to_string();

            if !new_task.is_empty() {
                let new_task = TodoItem {
                    task: new_task,
                    is_done: false,
                    draft_done: false,
                };
                state.push(checklist_todo, new_task);
                state.new_task_text.clear();
                let text_input = text_input;
                text_input.set_value("");
            }
        };

        // 新しいタスク入力用
        // Action on_text_enter of Text Input
        {
            let state = state.clone();
            text_input.on_text_changed(move |event| {
                let new_text = if let Some(new_task) = event.get_string() {
                    new_task
                } else {
                    "".to_string()
                };

                if let Ok(mut state) = state.try_borrow_mut() {
                    state.new_task_text = new_text;
                }
            });
        }

        // 新しいタスクの登録
        // Action on_text_enter of Text Input
        {
            let event_text_enter = event_text_enter.clone();
            text_input.on_text_enter(move |_| {
                event_text_enter();
            });
        }

        // 新しいタスクの登録
        // Action of Add Task
        {
            let event_text_enter = event_text_enter.clone();

            button_add_task.on_click(move |_| {
                event_text_enter();
            });
        }

        // フィルターボタン
        // Action of Filter All
        {
            let state = state.clone();
            button_filter_all.on_click(move |_| {
                let mut state = state.borrow_mut();
                state.filter_mode = FilterMode::All;

                state.refresh_list_view(checklist_todo);
            });
        }

        // フィルターボタン
        // Action of Filter Active
        {
            let state = state.clone();
            button_filter_active.on_click(move |_| {
                let mut state = state.borrow_mut();
                state.filter_mode = FilterMode::Active;

                state.refresh_list_view(checklist_todo);
            });
        }

        // フィルターボタン
        // Action of Filter Complete
        {
            let state = state.clone();
            button_filter_complete.on_click(move |_| {
                let mut state = state.borrow_mut();
                state.filter_mode = FilterMode::Completed;

                state.refresh_list_view(checklist_todo);
            });
        }

        // 一括更新ボタン
        // Action of Updating State
        {
            let state = state.clone();

            state.borrow().has_label_changes(button_has_changes);

            button_has_changes.on_click(move |_| {
                let mut state = state.borrow_mut();

                state.todo_list.iter_mut().for_each(|item| {
                    if item.is_done != item.draft_done {
                        item.is_done = item.draft_done;
                    }
                });
                state.refresh_list_view(checklist_todo);
                state.has_label_changes(button_has_changes);
            });
        }

        // ToDo一覧表示 チェックボックスの処理
        // Action of checklist_todo State
        {
            let state = state.clone();

            checklist_todo.on_toggled(move |event| {
                let index = event.get_selection();

                if let Some(index) = index {
                    let mut state = state.borrow_mut();
                    state.toggled_check(checklist_todo, button_has_changes, index);
                    state.refresh_list_view(checklist_todo);
                }
            });
        }

        todo_app.show(true);
        todo_app.centre();
    });
}
XML
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<resource xmlns="http://www.wxwidgets.org/wxxrc" version="2.5.3.0">
  <object class="wxFrame" name="todo_app">
    <size>500,300</size>
    <style>wxDEFAULT_FRAME_STYLE|wxTAB_TRAVERSAL</style>
    <title>wxDragon ToDoList</title>
    <centered>1</centered>
    <aui_managed>0</aui_managed>
    <object class="wxPanel" name="panel">
      <style>wxTAB_TRAVERSAL</style>
      <object class="wxBoxSizer" name="bSizer5">
        <orient>wxVERTICAL</orient>
        <object class="sizeritem">
          <flag>wxALL</flag>
          <border>5</border>
          <option>0</option>
          <object class="wxStaticText" name="label_todo_title">
            <label>📌 TODOリスト</label>
            <wrap>-1</wrap>
          </object>
        </object>
        <object class="sizeritem">
          <flag>wxEXPAND</flag>
          <border>5</border>
          <option>0</option>
          <object class="wxBoxSizer" name="bSizer6">
            <orient>wxHORIZONTAL</orient>
            <object class="sizeritem">
              <flag>wxALL</flag>
              <border>5</border>
              <option>1</option>
              <object class="wxTextCtrl" name="text_input">
                <style>wxTE_PROCESS_ENTER</style>
                <value></value>
                <maxlength>0</maxlength>
              </object>
            </object>
            <object class="sizeritem">
              <flag>wxALL</flag>
              <border>5</border>
              <option>0</option>
              <object class="wxButton" name="button_add_task">
                <label>追加</label>
                <default>0</default>
                <auth_needed>0</auth_needed>
                <markup>0</markup>
                <bitmap/>
              </object>
            </object>
          </object>
        </object>
        <object class="sizeritem">
          <flag>wxEXPAND</flag>
          <border>5</border>
          <option>0</option>
          <object class="wxBoxSizer" name="bSizer7">
            <orient>wxHORIZONTAL</orient>
            <object class="sizeritem">
              <flag>wxALL</flag>
              <border>5</border>
              <option>0</option>
              <object class="wxStaticText" name="label_filter">
                <label>フィルター</label>
                <wrap>-1</wrap>
              </object>
            </object>
            <object class="sizeritem">
              <flag>wxALL</flag>
              <border>5</border>
              <option>0</option>
              <object class="wxButton" name="button_filter_all">
                <label>すべて</label>
                <default>0</default>
                <auth_needed>0</auth_needed>
                <markup>0</markup>
                <bitmap/>
              </object>
            </object>
            <object class="sizeritem">
              <flag>wxALL</flag>
              <border>5</border>
              <option>0</option>
              <object class="wxButton" name="button_filter_active">
                <label>未完了</label>
                <default>0</default>
                <auth_needed>0</auth_needed>
                <markup>0</markup>
                <bitmap/>
              </object>
            </object>
            <object class="sizeritem">
              <flag>wxALL</flag>
              <border>5</border>
              <option>0</option>
              <object class="wxButton" name="button_filter_complete">
                <label>完了</label>
                <default>0</default>
                <auth_needed>0</auth_needed>
                <markup>0</markup>
                <bitmap/>
              </object>
            </object>
          </object>
        </object>
        <object class="sizeritem">
          <flag>wxEXPAND</flag>
          <border>5</border>
          <option>0</option>
          <object class="wxBoxSizer" name="bSizer51">
            <orient>wxVERTICAL</orient>
            <object class="sizeritem">
              <flag>wxALL</flag>
              <border>5</border>
              <option>0</option>
              <object class="wxButton" name="button_has_changes">
                <label>変更なし</label>
                <default>0</default>
                <auth_needed>0</auth_needed>
                <markup>0</markup>
                <bitmap/>
              </object>
            </object>
          </object>
        </object>
        <object class="sizeritem">
          <flag>wxALL|wxEXPAND</flag>
          <border>5</border>
          <option>1</option>
          <object class="wxCheckListBox" name="checklist_todo">
            <content/>
          </object>
        </object>
      </object>
    </object>
  </object>
</resource>

クロスコンパイル

cargo zigbuild --target x86_64-pc-windows-gnu

結果

To Do Listを作ってみました。XRCで作るのでかなり楽ですが、決まり事がわからないと面倒くさい部分もあります。しかしデザインだけで言えば圧倒的に楽です。

相変わらずコードの書き方がよくわからないです。Elm風に書くべきだったかな。CheckListBoxのコンテンツをいちいち全部書き換えていますが、まだ実装されていないメソッドが多いので部分削除や置き換えができなかったためです。もう少し熟成を待たなければならないかもしれません。

チェックポイント結果
クロスコンパイルできること x86_64-pc-windows-gnuOK
クロスコンパイルできること x86_64-pc-windows-msvcNG
日本語が表示できるOK
日本語が入力できるOK
ライセンスMIT or Apache License 2.0
ルックアンドフィールネイティブ

JS系の影響を受けていないコードの書き方が新鮮かもしれません。ただ単に書き方がわかっていないだけかもしれませんが。

昔、他の言語でwxWidgetsを使ったことがありましたが全く使い方を覚えていませんでした。

クロスコンパイルのやり方が一番嵌りました。macOSと動作のやり方で問題ないようなReadMeでしたがCMakeが想定どおりに動かず。コードを見るとLinux上ではcargo-zigbuildを呼び出しているようだったので試したらビルドできました。

長く続いているwxWidgetsを使用していますし、wxWidgetsバインディング系crateで一番勢いがありそうなものなので今後に期待したいです。また日本語の情報を殆ど無いですがExampleを見ればなんとかなりそうです。

画面デザインはwxFormBuilderを使えばわかりやすいでしょう。

ビルド以外はおかしなエラーにもほとんど遭遇することなく動いたので意外と好感。

GPUI(2025/12追加)

GPUIはRust製テキストエディタZed向けに開発されたGUIフレームワークです。クロスプラットフォーム対応ということで試してみます。

導入

# Cargo.toml
[dependencies]
gpui = "0.2.2"
Code language: PHP (php)

執筆時点の最新バージョンをセットしてrust-toolchain.tomlを作成。x86_64-pc-windows-gnuは公式サポートしていませんが試してみます。

# rust-toolchain.toml
[toolchain]
channel = "1.92.0"
components = ["rustc", "cargo", "rustfmt"]
profile = "minimal"
targets = ["x86_64-pc-windows-gnu", "x86_64-pc-windows-msvc"]
Code language: PHP (php)

一旦ReadMeにあるHello,worldをビルド、実行してみる。

cargo xwin build --target x86_64-pc-windows-msvc
cargo build --target x86_64-pc-windows-gnu

ビルドは成功したが実行してみるとEntry point TaskDialogIndirect could not be locatedのエラーが出てしまう。

どうやらmanifestファイルが必要らしいので作成して実行してみると今度はError creating DirectWriteTextSystemのエラーが出てしまう。

実行環境に問題があるようなので整備が必要だと思われる。その他、ビルドにかなり時間がかかる。

<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
 <dependency>
   <dependentAssembly>
     <assemblyIdentity
       type="win32"
       name="Microsoft.Windows.Common-Controls"
       version="6.0.0.0"
       publicKeyToken="6595b64144ccf1df"
       language="*"
       processorArchitecture="*"/>
   </dependentAssembly>
 </dependency>
 <trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
   <security>
     <requestedPrivileges>
       <requestedExecutionLevel
         level="asInvoker"
         uiAccess="false"/>
       </requestedPrivileges>
   </security>
 </trustInfo>
</assembly>Code language: HTML, XML (xml)

まとめ

各crateはほぼ何らかのJavaScriptフレームワークの影響を受けたものになっているので過去に使用したことがある場合は学習コストを抑えて使っていける可能性があります。

dioxusはエコシステムが充実しているので今後の期待度が高いですが、覚えることも多そうです。また、リモートに画面を表示することができなかったのが惜しいところです。

floemはシンプルに書けそうですが、最大化するだけで落ちるといった品質面に不安が残る結果となりました。IMEの扱いが惜しいのでアップデートしてほしいです。

icedはシンプルに書けそうですが、今回試した中でボタンの見た目が一番ネイティブ表示から遠いのでカスタマイズの可能性をもう少し調べる必要がありそうです。

Viziaはビルドはできますが、まだ公式バージョンが安定しておらず、バージョンも若いため今後大きな変化が起こる可能性があり、現時点で使用していくには勇気が必要です。

Xilemがリアクティブな動きを意識しないでもコードを書けるので現状一番良いかもしれません。ただ、まだ成熟されていないし、大きな変更もありそうですので様子見になってしまいます。

wxDragonはリアクティブなしくみを入れていないので別途crateを探してきたほうがよいかもしれません。他のcrateと書き方が異なるので移行は大変だと思います。

gpuiはクロスコンパイルはできるのですが、実環境で動いてくれませんでした。なにが問題なのか把握できていません。

ただ、この記事を書いている最中もアップデートが進んでおり、上記紹介したサンプルも次のリビジョンで書き方が異なっているものもあります。これらのcrateを採用するにしても、まだプロトタイプレベルの使用に抑えておいたほうがいいかもしれません。

追記(2025/02)

floem 0.2.0は意味不明なクロージャーを書かなくてすむようになったらしく、よりシンプルに書けそうですが、最大化するだけで落ちるといった問題は品質面に不安が残る結果となりました。公式のサンプルプログラムを見るとカッコが大幅に減った書き方になっていました。

Vizia 0.2.0はビルドはできますが、まだ公式バージョンが安定しておらず、バージョンも若いため今後大きな変化が起こる可能性があり、現時点で使用していくには勇気が必要です。実行時エラーが出て動作確認まではできませんでした。また、x86_64-pc-windows-gnuに対応していないのが残念な点です。

Xilemはシンプルに書けそうですが、まだ実験的UIという位置づけでこれからも変化していく可能性があり、現時点で使用していくには勇気が必要です。情報が少ない為、ドキュメントの整備を待ちたいです。

floemに期待していますがアップデートが遅いのが残念な点です。

追記(2025/12)

wxDragonは 0.9.5はwxWidgetsを使用している点が安定度が高そうで見た目もネイティブの画面になるので良さそうです。日本語の情報が少ないことが懸念でしょうか。過去の経験から他言語バインディングのwxWidgets情報が参考になると思います。

gpui 0.2.2は実際のエディターでも使用されているのでfloem同様期待したいですがまだWindows環境は優先順位が低そうなのでもう少し調べてから再トライしたいと思います。

参考

Are we GUI Yet?

Rust のGUIフレームワークを探していたら一度は読む場所

Are we game yet?

上のGame版。一応UIの項目がある。

管理人

Recent Posts

期間限定!書籍無料キャンペーン2026

書籍発売1.5周年を記念して2…

3週間 ago

情報セキュリティマネジメント試験取得への道

スキルアップを図るべく情報セキ…

4か月 ago

ファイナンシャルプランナー3級試験取得への道

スキルアップを図るべくファイナ…

4か月 ago

[rust] New Type Patternを使ってみる

DDDの考えを取り入れることで…

6か月 ago

RustでDDDの要素を取り入れてみる

前回SOLID原則というものを…

7か月 ago