🧊

Rust GUI / iced 入門

2022/03/28に公開

はじめに

Pure Rust な GUI を使いたいと思い、Web 含めたクロスプラットフォーム対応でメジャーなクレートを調査した結果、用途に応じて下記 2 つが候補にあがりました。

  • Immediate Mode: egui
  • Retained Mode: iced

今回は iced に入門し、基本的な機能と使い方をメモします。本稿で想定しているバージョンは下記となります。

  • Rust: 1.59.0
  • iced: 0.3.0

https://github.com/iced-rs/iced

本稿で使用している機能の使用例はこちらにまとめてあるので、ご参考まで。

https://github.com/hideakitai/rust_gui_iced_introduction

下記に関しては、気が向いたら追記します。

  • カスタムウィジェット
  • ウィジェットのスタイル設定

iced の基本構成

  • iced は Elm にインスパイアされたクロスプラットフォーム (Web 含む) な GUI ライブラリ
  • Retained Mode な GUI (Responsive)
  • The Elm Architecture なロジックで構成される
    • State: App の状態
    • Message: ユーザの操作やイベントの通知
    • Update: Message に応じて State を更新するロジック
    • View: State に応じて Widget を表示するロジック

iced における基本的なコードの構成方法

  • App の状態を管理する struct として State を定義する
  • App の状態を更新するために必要な Messageenum として定義する
  • App を実行可能にするため、State に対して下記のどちらかのトレイトを実装する
    • Sandbox: シンプルで必要最低限
    • Application: Sandbox に加え、非同期処理や Flag の使用などが可能
  • Sandbox / Application トレイトの update()Message に応じて State を更新
  • Sandbox / Application トレイトの view()State に応じて Widget を表示
  • Sandbox / Application トレイトの run() を呼ぶことで App を実行する

Sandbox トレイトを実装した App

  • シンプルな App を作成するためのトレイト (非同期処理などは使えない)
  • Sandbox を実装する struct State を用意する
  • 下記 1 つの関連型の指定が必要
    • type Message: State とやりとりする Message を指定
  • 下記 4 つのメソッドの実装が必要
    • fn new() -> Self: State の初期化
    • fn title(&self) -> String: ウィンドウタイトルを指定
    • fn update(&mut self, message: Self::Message): Message を受け取って State を更新
    • fn view(&mut self) -> Element<Self::Message>: State に応じて Widget を表示
[dependencies]
iced = "0.3"
use iced::{button, Button, Element, Sandbox, Settings, Text};

/// App の状態を保持する State を定義
/// この State に Sandbox を実装することで App として実行することが可能
#[derive(Default)]
struct MyButton {
    button_state: button::State, // Button の状態を保持する必要がある
}

/// ユーザの操作やイベントの通知に使う Message を定義
/// Debug, Clone の実装が必要
#[derive(Debug, Clone)]
enum Message {
    ButtonPressed,
}

/// Sandbox を State に実装することで、App として実行が可能になる
impl Sandbox for MyButton {
    /// State とやりとりする関連型 Message を定義
    type Message = Message;

    /// State を初期化 (iced 内部で使用される)
    fn new() -> Self {
        Self::default()
    }

    /// ウィンドウタイトルを設定
    fn title(&self) -> String {
        String::from("Button")
    }

    /// Message を受け取って State を更新する
    fn update(&mut self, message: Message) {
        match message {
            Message::ButtonPressed => println!("Button pressed"),
        }
    }

    /// State に応じて Widget を表示する
    /// Message を update() で処理した場合や Event 発生時のみ呼ばれる
    fn view(&mut self) -> Element<Message> {
        // Button Widget を生成し、
        // Button が押されたら Message を送信する
        // この Widget を `into()` で Element<Element> に変換して返すことで描画される
        Button::new(&mut self.button_state, Text::new("Button"))
            .on_press(Message::ButtonPressed)
            .into()
    }
}

fn main() -> iced::Result {
    // Sandbox を実装した State (Counter) を実行する
    // Settings を変更すれば、ウィンドウサイズ等の設定が変更可能
    MyButton::run(Settings::default())
}

Widget

Widget の基本

  • fn view() で Widget を生成し、Element<Message> として返すことで GUI を構築する
  • Widget は iced_native クレートによって提供される
fn view(&mut self) -> Element<Message> {
    // Button Widget を生成し、
    // Button が押されたら Message を送信する
    // この Widget を `into()` で Element<Element> に変換して返すことで描画される
    Button::new(&mut self.button_state, Text::new("Button"))
        .on_press(Message::ButtonPressed)
        .into()
}

複数の Widget を使う

  • Widget は下記の 2 種類に大別される (筆者による独自の命名)
    • コンポーネント
    • コンテナ (複数 Widget のレイアウト)
  • 複数の Widget を使用するためには、下記の手順に沿って view() を構成する
    • 複数の Widget を生成する
    • コンテナ系 Widget を使用してひとつの Widget にまとめる
    • into() メソッドによって Widget を Element<Message> に変換して返す
参考: iced 組込み Widget

コンポーネント

コンテナ (複数 Widget のレイアウト)

  • 様々な複数の GUI を入れ子にしてレイアウトができる
  • 複数の Widget をまとめて into() によって Element<Message> に変換する
  • 下記のような種類がある
fn view(&mut self) -> Element<Message> {
    // 3つの Widget を作成
    let count_text = Text::new(self.count.to_string()).size(50);
    let increment_button = Button::new(&mut self.increment_button_state, Text::new("+"))
        .on_press(Message::IncrementButtonPressed);
    let decrement_button = Button::new(&mut self.decrement_button_state, Text::new("-"))
        .on_press(Message::DecrementButtonPressed);

    // 上記の Widget のうち2つの Button をRow にまとめる
    let button_row = Row::new().push(increment_button).push(decrement_button);

    // Text と Row にまとめた Button を Column にまとめる
    // into() によって Widget は Element<Message> に変換される
    Column::new().push(count_text).push(button_row).into()
}

Widget の配置の調整

コンテナを使った Widget 配置の調整

  • 各 Widget の持つ color(), size(), padding() などを使って見た目を調整する
  • コンテナ系 Widget では align_items() などを使ってアラインメントを調整する
  • Container Widget ですべての要素をウィンドウに合わせて配置をする
fn view(&mut self) -> Element<Message> {
    // 3つの Widget を作成
    // color, size を指定
    let count_text = Text::new(self.count.to_string())
        .color(Color::from_rgb(0.0, 0.0, 1.0))
        .size(50);
    // padding を指定
    let increment_button = Button::new(&mut self.increment_button_state, Text::new("+"))
        .padding(10)
        .on_press(Message::IncrementButtonPressed);
    // padding を指定
    let decrement_button = Button::new(&mut self.decrement_button_state, Text::new("-"))
        .padding(10)
        .on_press(Message::DecrementButtonPressed);

    // 上記の Widget のうち2つの Button をRow にまとめる
    // padding, spacing, max_width, align を設定する
    let button_row = Row::new()
        .padding(20)
        .spacing(20)
        .max_width(500)
        .align_items(Align::Center)
        .push(increment_button)
        .push(decrement_button);

    // Text と Row にまとめた Button を Column にまとめる
    // padding, spacing, max_width, align を設定する
    let content = Column::new()
        .padding(20)
        .spacing(20)
        .max_width(500)
        .align_items(Align::Center)
        .push(count_text)
        .push(button_row);

    // Column にまとめたすべての要素を Container にまとめ、
    // Window の width, height を設定し、
    // Window の上下の中央に配置する
    Container::new(content)
        .width(Length::Fill)
        .height(Length::Fill)
        .center_x()
        .center_y()
        .into()
}

ウィンドウの設定

  • iced::Settings を変更することで、ウィンドウサイズ等を変更できる
fn main() -> iced::Result {
    // Settings を使って、ウィンドウサイズを設定する
    let mut settings = Settings::default();
    settings.window.size = (200, 200);
    Counter::run(settings)
}

Application トレイトを実装した App

  • Application トレイトを実装すると Sandbox に加えて下記ができるようになる
    • 非同期処理 の実行 (Command, Subscription)
    • Flags の指定による 初期化の分岐
  • 下記 3 つの関連型の指定が必要
    • type Executor (feature flag で executor::Default を指定可能)
      • tokio: executor::Tokio
      • async-std: executor::AsyncStd
      • smol: executor::Smol
      • 指定なし: iced_futures::executor::ThreadPool
    • type Flags (初期化処理を分岐させることが可能)
    • type Message
  • 下記 4 つのメソッドの実装が必要 (Sandbox とはシグネチャが異なる)
    • fn new(flags: Self::Flags) -> (Self, Command<Self::Message>)
    • fn title(&self) -> String
    • fn update(&mut self, message: Self::Message) -> Command<Self::Message>
    • fn view(&mut self) -> Element<Self::Message>
  • Command で単発の非同期処理を実行し、結果を Message で通知 (new() update())
[dependencies]
iced = { version = "0.3", features = ["async-std"] }
use iced::{button, executor, Application, Button, Clipboard, Command, Element, Settings, Text};

#[derive(Default)]
struct MyApplication {
    button_state: button::State,
}

#[derive(Debug, Clone)]
enum Message {
    ButtonClicked,  // ボタンが押されたら 1 秒スリープし、
    AwakeFromSleep, // スリープから復帰したら AwakeFromSleep を送る
}

/// Application を State (AsyncHello) に実装することで、App として実行が可能になる
/// Application には非同期処理関連の型や Flags が Sandbox に追加されている
impl Application for MyApplication {
    /// 非同期処理の Executor を指定
    /// executor::Default は feature flag によって切り替わる
    /// - "tokio": executor::Tokio
    /// - "async-std": executor::AsyncStd
    /// - "smol": executor::Smol
    /// - 指定なし: iced_futures::executor::ThreadPool
    type Executor = executor::Default;
    /// 初期化を分岐するために使用する Flags だが、ここでは未使用
    type Flags = ();
    type Message = Message;

    /// State(AsyncHello) を初期化、Sandbox との違いは下記 2 点
    /// - Flags によって処理を分岐させることが可能
    /// - Command を使うことで、単発の非同期処理を行うことが可能
    fn new(_flags: Self::Flags) -> (Self, Command<Self::Message>) {
        (Self::default(), Command::none())
    }

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

    /// Message を受け取って State (Counter) を更新する
    /// Command を使うことで、単発の非同期処理を行うことが可能
    fn update(&mut self, message: Message, _clipboard: &mut Clipboard) -> Command<Self::Message> {
        match message {
            Message::ButtonClicked => {
                println!("Clicked!");

                // Command::perform() によって単発の非同期処理を行うことが可能
                // 第一引数に Future を与える (ここでは async fn を指定)
                // 第二引数に Future の返り値を引数とし Message を返す closure を指定 (Future 完了後に呼ばれる)
                Command::perform(sleep_for_a_second(), |_| Message::AwakeFromSleep)
            }
            Message::AwakeFromSleep => Command::none(),
        }
    }

    fn view(&mut self) -> Element<Message> {
        // 押されたら Message::ButtonClick を送るだけの Button を設置
        Button::new(&mut self.button_state, Text::new("click"))
            .on_press(Message::ButtonClicked)
            .into()
    }
}

/// Button が押されたら呼ばれる非同期関数
/// 1 秒スリープして文字列をコンソールに表示する
async fn sleep_for_a_second() {
    use async_std::task::sleep;
    use std::time::Duration;

    sleep(Duration::from_secs(1)).await;
    println!("Hello, from 1 sec sleep!");
}

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

Subscription による非同期処理

  • Application で非同期な処理を行うには下記 2 つの方法がある
    • Command: 単発的な非同期処理を実行し、結果を Message で通知
      • Commandnew()update() で使用することが可能
    • Subscription: 定期的な非同期処理を実行し、結果を Message で通知
      • Subscription を使用するには subscription() をオーバーライドする
impl Application for ApplicationSubscription {
    // ...

    /// 定期的な非同期処理を行うために、デフォルト実装をオーバーライドする
    fn subscription(&self) -> Subscription<Message> {
        use std::time::Duration;

        // 1 秒ごとに Message::Tick を Subscription<Message> として返す
        iced::time::every(Duration::from_secs(1)).map(|_| Message::Tick)
    }

    // ...
}

Subscription の利用例

  • 下記に組み込みで使用可能な Subscription の例を示す
    • iced::time モジュールによる定期実行
    • iced_native::keyboard モジュールによるキーボードイベント
    • iced_native::mouse モジュールによるマウスイベント
参考: iced 組込みイベント

Event 一覧

mod iced_native {
    #[derive(Debug, Clone, PartialEq)]
    pub enum Event {
        Keyboard(keyboard::Event),
        Mouse(mouse::Event),
        Window(window::Event),
        Touch(touch::Event),
    }
}
mod iced_native::keyboard {
    #[derive(Debug, Clone, Copy, PartialEq)]
    pub enum Event {
        KeyPressed {
            key_code: KeyCode,
            modifiers: Modifiers,
        },
        KeyReleased {
            key_code: KeyCode,
            modifiers: Modifiers,
        },
        CharacterReceived(char),     // A unicode character was received.
        ModifiersChanged(Modifiers), // The keyboard modifiers have changed.
    }
}
mod iced_native::mouse {
    #[derive(Debug, Clone, Copy, PartialEq)]
    pub enum Event {
        CursorEntered, // The mouse cursor entered the window.
        CursorLeft,    // The mouse cursor left the window.
        CursorMoved {
            position: Point,
        },
        ButtonPressed(Button),
        ButtonReleased(Button),
        WheelScrolled {
            delta: ScrollDelta,
        },
    }
}
mod iced_native::window {
    #[derive(PartialEq, Clone, Debug)]
    pub enum Event {
        Resized {
            width: u32,
            height: u32,
        },
        CloseRequested,
        Focused,
        Unfocused,
        FileHovered(PathBuf),
        FileDropped(PathBuf),
        FilesHoveredLeft,
    }
}
mod iced_native::touch {
    #[derive(Debug, Clone, Copy, PartialEq)]
    #[allow(missing_docs)]
    pub enum Event {
        FingerPressed { id: Finger, position: Point },
        FingerMoved { id: Finger, position: Point },
        FingerLifted { id: Finger, position: Point },
        FingerLost { id: Finger, position: Point },
    }
}

iced::time モジュールによる定期実行

fn subscription(&self) -> Subscription<Message> {
    use std::time::Duration;

    // 1 秒ごとに Message::Tick を送る
    iced::time::every(Duration::from_secs(1)).map(|_| Message::Tick)
}

iced_native::keyboard モジュールによるキーボードイベント

[dependencies]
iced = { version = "0.3", features = ["async-std"] }
iced_native = "0.4"
fn subscription(&self) -> Subscription<Message> {
    use iced_native::{keyboard, subscription, Event};

    // キーボード入力があった場合、Message::KeyPressed(key_code) を送る
    subscription::events_with(|event, _status| match event {
        Event::Keyboard(keyboard::Event::KeyPressed {
            key_code,
            modifiers: _,
        }) => Some(Message::KeyPressed(key_code)),
        _ => None,
    })
}

iced_native::mouse モジュールによるマウスボタンイベント

[dependencies]
iced = { version = "0.3", features = ["async-std"] }
iced_native = "0.4"
fn subscription(&self) -> Subscription<Message> {
    use iced_native::{mouse, subscription, Event};

    // マウスボタン入力があった場合、Message::MouseButtonPressed を送る
    subscription::events_with(|event, _status| match event {
        Event::Mouse(mouse::Event::ButtonPressed(button)) => {
            Some(Message::MouseButtonPressed(button))
        }
        _ => None,
    })
}

Subscription::batch() で複数の Subscription を登録する

  • Subscription::batch() を使えば複数の Subscription を登録することができる
    fn subscription(&self) -> Subscription<Message> {
        // tick, key_pressed, mouse_pressed の subscription を作成
        // ...

        // Subscription::batch() で複数の非同期処理 (Subscription) をまとめて登録することができる
        Subscription::batch([tick, key_pressed, mouse_pressed])
    }

カスタム Subscription

  • Recipe トレイトを実装することで独自の Subscription を構築できる
  • 下記 1 つの関連型の指定が必要
    • type Output
  • 下記 2 つのメソッドの実装が必要
    • fn hash(&self, state: &mut Hasher)
    • fn stream(self: Box<Self>, input: BoxStream<Event>) -> BoxStream<Self::Output>
  • iced::Subscription::from_recipe() に独自の Subscription を渡すことで実行が可能
[dependencies]
iced = { version = "0.3", features = ["async-std"] }
iced_native = "0.4"
iced_futures = "0.3"
async-std = "1.11"
/// 独自の Subscription を実装する型
pub struct Timer;

/// Recipe<Hasher, Event> を実装することで、独自の Subscription が構築できる
impl<Hasher, Event> Recipe<Hasher, Event> for Timer
where
    Hasher: std::hash::Hasher,
{
    /// Subscription の Output 型を定義する
    type Output = Instant;

    /// Subscription を比較するための hash メソッド
    fn hash(&self, state: &mut Hasher) {
        use std::hash::Hash;
        std::any::TypeId::of::<Self>().hash(state);
    }

    /// Recipe を実行して Subscription Event を生成する
    /// Subscription Event は Stream として返される
    fn stream(
        self: Box<Self>,
        _input: futures::stream::BoxStream<'static, Event>,
    ) -> futures::stream::BoxStream<'static, Self::Output> {
        use futures::stream::StreamExt;
        async_std::stream::interval(Duration::from_secs(1))
            .map(|_| Instant::now())
            .boxed()
    }
}

// ...

impl Application for MyApplication {
    // ...

    fn subscription(&self) -> Subscription<Message> {
        // Recipe を実装した Timer を生成し、
        // Subscription::from_recipe で Subscription Stream を生成する
        iced::Subscription::from_recipe(Timer).map(Message::Tick)
    }

    // ...
}

参考

https://github.com/iced-rs/iced
https://docs.iced.rs/iced/index.html

Discussion