🧊
Rust GUI / iced 入門
はじめに
Pure Rust な GUI を使いたいと思い、Web 含めたクロスプラットフォーム対応でメジャーなクレートを調査した結果、用途に応じて下記 2 つが候補にあがりました。
今回は iced に入門し、基本的な機能と使い方をメモします。本稿で想定しているバージョンは下記となります。
- Rust: 1.59.0
- iced: 0.3.0
本稿で使用している機能の使用例はこちらにまとめてあるので、ご参考まで。
下記に関しては、気が向いたら追記します。
- カスタムウィジェット
- ウィジェットのスタイル設定
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 の状態を更新するために必要な Message を
enum
として定義する - 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
コンポーネント
- 実際に GUI として使用される
- 下記のような種類がある
コンテナ (複数 Widget のレイアウト)
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 で通知
-
Command
はnew()
とupdate()
で使用することが可能
-
-
Subscription: 定期的な非同期処理を実行し、結果を Message で通知
-
Subscription
を使用するにはsubscription()
をオーバーライドする
-
-
Command: 単発的な非同期処理を実行し、結果を Message で通知
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)
}
// ...
}
参考
Discussion