SWEの松本(@matsu7874)です。普段はWebアプリケーションやCLIツールを開発していますが、GUIを持ったデスクトップアプリ(以下、GUIアプリ)に対して漠然とした憧れがありました。夏休みの自由研究としてRustのicedというクレートを使ってGUIアプリを作ってみました。
他のGUIアプリ用のライブラリと比較したわけではありませんが、icedはデータの流れが分かりやすく、さまざまな既存ライブラリとの接続も整備されていて使いやすいと感じました。
何を作るの?
室温観測アプリを作ります。機能は2つです。
機能1: 「暑いボタン」「寒いボタン」で不快感を感じたタイミングを記録できる機能です。自分がいつ・どのような環境で不快感を感じたか記録することで、快適な作業環境の構築に役立ちます。
機能2: これまでの室温推移をグラフで表示する機能です。時系列で表現されるため温度の推移が見やすいです。(今回はグラフ部分の技術検証に絞るため、Nature RemoのAPIから取得したデータをCSVファイルに書いた物を使います。こちらからダウンロードできます。)
どうやって作るの
GUIはicedというクレートを使います。
iced
icedはRustのUIライブラリです。クロスプラットフォーム対応です。仕事ではMacを使いつつ、Windowsでゲームもする私にはぴったりです。
依存crateを含め、先に全てCargo.tomlに記入しておきます。
[package] name = "temp_humid_dash" version = "0.1.0" edition = "2021" [dependencies] chrono = "0.4.38" csv = "1.3.0" iced = { version = "0.12.1", features = ["canvas", "tokio"] } serde = { version = "1.0.204", features = ["derive"] } serde_json = "1.0.121" tokio = { version = "1.39.2", features = ["full"] } plotters = "0.3" plotters-iced = "0.10" plotters-backend = "0.3.6" anyhow = "1.0.86"
以降のコードは全てmain.rsです。
実装STEP1: ウィンドウを表示する
まずはGUIアプリの主役であるウィンドウを描画したいです。
必要なコンポーネントを宣言します。Text
と Font
が画面に文字を表示するために必要な型で、Settings
がアプリの起動に必要な型、それ以外はApplication
の関連型に必要な型です。
use iced::{ executor, widget::{Text, Theme}, Application, Command, Element, Font, Settings, Subscription, };
次に struct MyApp
を定義し、trait Application
を実装します。これがicedの肝でして、Application
を実装するとそれぞれのメソッドの挙動に従ってGUIアプリとして動いてくれるのです。
struct MyApp {} impl Application for MyApp { type Message = (); type Executor = executor::Default; type Flags = (); type Theme = Theme; fn new(_flags: Self::Flags) -> (Self, Command<Self::Message>) { (Self {}, Command::none()) } fn title(&self) -> String { "Windowのサンプルアプリ".to_owned() } fn update(&mut self, _message: Self::Message) -> Command<Self::Message> { Command::none() } fn view(&self) -> Element<'_, Self::Message> { Text::new("Window内のタイトル").into() } fn subscription(&self) -> Subscription<Self::Message> { Subscription::none() } }
- fn titleはウィンドウのタイトルを定義しているメソッドです。この値を変化させるとウィンドウのタイトルが変わることが確認できます。
- fn viewが画面を描画内容を定義しています。今回はWindow内のタイトルという文字列を表示しています。
- fn update , fn subscription の解説はSTEP2で行います。
最後に定義したアプリケーションを実行するためのmain関数です。
fn main() { MyApp::run(Settings { antialiasing: true, // trueのほうが見やすい気がする default_font: Font::with_name("ヒラギノ角ゴシック"), ..Settings::default() }) .unwrap(); }
- trait Applicationにデフォルト実装されている fn runでアプリケーションを起動します。
ではcargo runでアプリケーションを起動しましょう。ウィンドウが立ち上がりましたね!
Tips: 日本語のフォントを指定する
GUIアプリ起動時に日本語のフォントを指定しないと、下記画像のように日本語の文字が■のような見た目で表示されてしまいます。MyApp::run
に default_font
を設定した Settings
を渡すことで選択したフォントで表示されるようになっています。
実装STEP2: ボタンを表示する
次のステップとしてボタンを実装し、押した時刻のログが一定時間画面に表示され、ファイルにも書き込まれる機能を作ります。
イベント処理
ここでウィンドウとユーザーのインタラクション・および画面の更新処理が出てきました。icedがどのようにウィンドウを更新していくか説明します。
struct MyApp
が関連型のMessage
の形で各ウィジェットからの更新通知を受け取り、fn update
で更新処理を振り分けて内部状態を更新し、 fn view
で画面の描画内容を更新するという処理の流れになります。 Message
を発するのは各ウィジェットと、定期的に実行されるように fn subscription
で定義されている Subscription
の2箇所です。
STEP2のコード
use chrono::{DateTime, Local}; use iced::{ executor, time::every, widget::{button, Column, Container, Text, Theme}, Application, Command, Element, Font, Settings, Subscription, }; use std::{collections::VecDeque, fs::OpenOptions, io::Write, time::Duration}; const SAMPLE_EVERY: Duration = Duration::from_secs(1);
chrono::{DateTime, Local}
,iced::time::every
,iced:widget::{button, Column, Container, }
,std::{collections::VecDeque, fs::OpenOptions, io::Write, time::Duration}
を追加しました。- 定期的な更新処理を設定するための1秒という定数を設定しています。
struct MyApp { action_log: VecDeque<(DateTime<Local>, String)>, } impl MyApp { /// ログを記録する fn add_action_log(&mut self, action: &str) { let now = Local::now(); let timestamp: String = now.format("%Y-%m-%d %H:%M:%S").to_string(); self.action_log .push_back((now, format!("{}:{}", timestamp, action.to_string()))); let mut file = OpenOptions::new() .append(true) .create(true) .open("actions.csv") // カレントディレクトリに作成される .expect("Failed to open file"); writeln!(file, "{},{}", timestamp, action).expect("Failed to write to file"); } /// 画面上の古いログを削除する fn clear_action_log(&mut self) { let now = Local::now(); while let Some((timestamp, _)) = self.action_log.front() { if now.signed_duration_since(*timestamp).num_seconds() > 10 { self.action_log.pop_front(); } else { break; } } } }
ボタンが押されたログを記録する構造体を追加し、ログを記録する構造体への書き込み・削除処理を実装しました。
-
n add_action_log
はファイルへの書き込みも行います。 -
fn clear_action_log
は10秒以上前のログを構造体から削除します。
-
#[derive(Debug, Clone)] enum Message { HotPressed, ColdPressed, Tick, } impl Application for MyApp { type Message = Message; type Executor = executor::Default; type Flags = (); type Theme = Theme; fn new(_flags: Self::Flags) -> (Self, Command<Self::Message>) { ( Self { action_log: VecDeque::new(), }, Command::none(), ) } fn title(&self) -> String { "ボタンのサンプルアプリ".to_owned() } fn update(&mut self, message: Self::Message) -> Command<Self::Message> { match message { Message::Tick => self.clear_action_log(), Message::HotPressed => { self.add_action_log("Hot"); } Message::ColdPressed => { self.add_action_log("Cold"); } } Command::none() } fn view(&self) -> Element<'_, Self::Message> { // 「暑い」「寒い」ボタンを押した時刻を表示する let log_contents = self .action_log .iter() .map(|(_timestamp, action)| format!("{}", action)) .collect::<Vec<_>>(); let content = Column::new() .push(Text::new("押せ!!!")) .push(button("暑い").on_press(Message::HotPressed)) .push(button("寒い").on_press(Message::ColdPressed)); let content = log_contents.iter().fold(content, |content, log_content| { content.push(Text::new(format!("{}", log_content))) }); Container::new(content).into() } fn subscription(&self) -> Subscription<Self::Message> { every(SAMPLE_EVERY).map(|_| Message::Tick) } } // fn mainは変更なし
- アプリケーション上で必要な更新指示を
enum Message
として定義しました。 fn subscription
でSAMPLE_EVERY
ごと(1秒ごと)にMessage::Tick
が発行されるようにしました。- 「暑い」「寒い」ボタンを追加し、それぞれに対応したメッセージを発行するようにしました。
- 「暑い」「寒い」ボタンを押した時刻が画面に表示されるようにしました。
- 複数の要素を描画するため
Container
、Column
を追加しました。
cargo run
を実行するとGUIアプリが立ち上がり、ボタンを押すとカレントディレクトリにactions.csvが作成されることを確認できます。
実装STEP3: グラフを表示する
いよいよグラフを実装しましょう。グラフはplottersというグラフ描画ライブラリをicedから使えるようにするplotters-icedというcrateを使います。ちなみにcrates.ioをicedで検索してみると多くのiced上で何かをできるようにするcrateがいくつも作られています。
main.rsを修正していきましょう。
use anyhow::Result; use chrono::{DateTime, Local, Utc}; use iced::{ executor, time::every, widget::{ button, canvas::{Cache, Frame, Geometry}, Column, Container, Text, Theme, }, Alignment, Application, Command, Element, Font, Length, Settings, Size, Subscription, }; use plotters::prelude::ChartBuilder; use plotters_backend::DrawingBackend; use plotters_iced::{Chart, ChartWidget, Renderer}; use std::{collections::VecDeque, fs::OpenOptions, io::Write, time::Duration}; const PLOT_MINUTES: usize = 5; // 5 minutes const SAMPLE_EVERY: Duration = Duration::from_secs(1);
plotters
関連で必要なものが増えています。PLOT_MINUTES
は描画する時間幅です。CSVからデータを読み込むため毎秒1レコード換算で、5分=300レコードを上限としていますが、実際にAPI経由でリアルタイムのデータを使う場合はもっと長い時間を設定します。
#[derive(Debug, Clone, Copy)] struct Weather { temperature: f32, humidity: i32, } static mut COUNTER: usize = 0; async fn fetch_api_data() -> Result<Weather> { // カレントディレクトリからdata.csvを読み込む // 1行目はヘッダーなのでスキップ // 2行目以降は日時と気温、湿度のデータがカンマ区切りで格納されている let mut rdr = csv::Reader::from_path("data.csv")?; let mut data = vec![]; for result in rdr.records() { let record = result?; let temperature = record.get(1).unwrap().parse::<f32>()?; let humidity = record.get(2).unwrap().parse::<i32>()?; data.push(Weather { temperature, humidity, }); } let w = unsafe { COUNTER += 1; data[COUNTER] }; Ok(w) }
今回は実際のAPIを使うのではなく、開発用にダウンロード済みのデータを使います。時間経過の中で読む値が変わっていくことを再現するためのコードを書いています。毎回CSVファイルを全部読み、何も確かめずに「global変数の値」列目をパースするというやばい処理をしていますが、すぐ使わなくなるコードなので問題ありません。(本番のコードとしては使わないでください。)
グラフ描画の部分はplotters-icedのexamplesを参考にします
グラフ描画部分はplotters-icedのexampleに複数のCpu使用率の推移グラフを描画するサンプル表示が載っています。ほとんどそれをベースに自分で定義したデータ型とデータ範囲に修正してそのまま使います。
struct ApiDataChart { data_points: VecDeque<(DateTime<Utc>, Weather)>, limit: Duration, cache: Cache, } impl Default for ApiDataChart { fn default() -> Self { Self { data_points: VecDeque::new(), limit: Duration::from_secs((PLOT_MINUTES * 60) as u64), cache: Cache::new(), } } } impl ApiDataChart { fn push_data(&mut self, time: DateTime<Utc>, value: Weather) { let cur_ms = time.timestamp_millis(); self.data_points.push_front((time, value)); loop { if let Some((time, _)) = self.data_points.back() { let diff = Duration::from_millis((cur_ms - time.timestamp_millis()) as u64); if diff > self.limit { self.data_points.pop_back(); continue; } } break; } self.cache.clear(); } fn view(&self) -> Element<Message> { Column::new() .width(Length::Fill) .height(Length::Shrink) .spacing(5) .align_items(Alignment::Center) .push(ChartWidget::new(self).height(Length::Fixed(300.0))) .into() } }
struct ApiDataChart
はグラフ用のデータを扱う構造体です。exampleのコードではCpuUsageChart
にあたる構造体です。ApiDataChart::view
はMyApp::view
から呼び出され、ChartWidget::new
に渡されたself
から次のコードのfn build_chart
が呼び出される関係です。
impl Chart<Message> for ApiDataChart { type State = (); #[inline] fn draw<R: Renderer, F: Fn(&mut Frame)>( &self, renderer: &R, bounds: Size, draw_fn: F, ) -> Geometry { renderer.draw_cache(&self.cache, bounds, draw_fn) } fn build_chart<DB: DrawingBackend>(&self, _state: &Self::State, mut chart: ChartBuilder<DB>) { use plotters::prelude::*; const PLOT_LINE_COLOR: RGBColor = RGBColor(0, 175, 255); // 描画するデータ範囲を計算します let newest_time = self .data_points .front() .unwrap_or(&( DateTime::<Utc>::from_timestamp(0, 0).unwrap(), Weather { temperature: 20.0, humidity: 0, }, )) .0; let oldest_time = newest_time - chrono::Duration::minutes(PLOT_MINUTES as i64); // 2次元の座標平面を作ります // データ範囲を気温の範囲に限定しています let mut chart = chart .x_label_area_size(0) .y_label_area_size(28) .margin(20) .build_cartesian_2d(oldest_time..newest_time, 18..40) .expect("failed to build chart"); // グリッドの見た目を整えます chart .configure_mesh() .bold_line_style(plotters::style::colors::BLUE.mix(0.1)) .light_line_style(plotters::style::colors::BLUE.mix(0.05)) .axis_style(ShapeStyle::from(plotters::style::colors::BLUE.mix(0.45)).stroke_width(1)) .y_labels(10) .y_label_style( ("sans-serif", 15) .into_font() .color(&plotters::style::colors::BLUE.mix(0.65)) .transform(FontTransform::Rotate90), ) .y_label_formatter(&|y| format!("{}℃", y)) .draw() .expect("failed to draw chart mesh"); // データを描画します chart .draw_series( AreaSeries::new( self.data_points .iter() .map(|x| (x.0, x.1.temperature.floor() as i32)), 16, PLOT_LINE_COLOR.mix(0.175), ) .border_style(ShapeStyle::from(PLOT_LINE_COLOR).stroke_width(2)), ) .expect("failed to draw chart data"); } }
- 描画するデータ範囲を指定してグラフを描画します。
以上でグラフの描画コンポーネントが用意できたので、大元のGUIアプリとの接続部分を実装していきます。
#[derive(Debug, Clone)] enum Message { DataFetched(Weather), HotPressed, ColdPressed, Tick, None, } struct MyApp { action_log: VecDeque<(DateTime<Local>, String)>, chart: ApiDataChart, } // impl MyApp は変更なし
データ取得APIの結果をGUIアプリに伝えるための
DataFetched(Weather)
を定義しました。グラフを描画する
ApiDataChart
をstruct MyApp
に追加しました。
impl Application for MyApp { // 変更のあるメソッドのみ fn new(_flags: Self::Flags) -> (Self, Command<Self::Message>) { let mut chart = ApiDataChart::default(); let initial_data = vec![]; let now = Utc::now(); for value in initial_data { chart.push_data(now, value); } ( Self { action_log: VecDeque::new(), chart, }, Command::none(), ) } fn update(&mut self, message: Self::Message) -> Command<Self::Message> { match message { Message::Tick => { self.clear_action_log(); return Command::perform(fetch_api_data(), |w| match w { Ok(w) => Message::DataFetched(w), Err(e) => { eprintln!("Error: {}", e); Message::None } }); } Message::DataFetched(data) => { self.chart.push_data(Utc::now(), data); } Message::HotPressed => { self.add_action_log("Hot"); } Message::ColdPressed => { self.add_action_log("Cold"); } _ => {} } Command::none() } fn view(&self) -> Element<'_, Self::Message> { let log_contents = self .action_log .iter() .map(|(_timestamp, action)| format!("{}", action)) .collect::<Vec<_>>(); let content = Column::new() .push(Text::new("グラフを出せるよ")) .push(self.chart.view()) // 追加 .push(button("暑い").on_press(Message::HotPressed)) .push(button("寒い").on_press(Message::ColdPressed)); let content = log_contents.iter().fold(content, |content, log_content| { content.push(Text::new(format!("{}", log_content))) }); Container::new(content).into() } }
fn new
にchart
の初期化処理を追加fn update
にデータ取得APIを呼び出して返り値に応じたmessageを発行する処理を追加fn view
にグラフコンポーネントを追加
完成
cargo run --release
で実行しましょう。こんな感じの画面が表示され、ボタンを押すとカレントディレクトリのactions.csvにログが表示されるはずです。
あとはリアルタイムの室温・湿度データを取り込めば、自分が快適な温度・湿度を外れた瞬間を特定する事ができるようになるでしょう。
現在、estie社内での本格的なデータ分析にはSnowflakeやStreamlitを使っていますが、ローカルでのデータ入力やリアルタイムの処理が伴うデータの確認をしたい状況ではicedは便利に使えそうに感じました。普段はGUIアプリを開発する機会はまだありませんが、RustでのWebアプリ開発はある職場です。ご興味を持っていただいた方はぜひカジュアル面談をお申し込みください。