🧊

RustのIcedを使って簡単な画像ビューア

6 min read

RustのGUIクレートにはまだ決定版がない状況で、選択肢の1つとしてIcedがある。

https://github.com/hecrj/iced

Icedも他のGUIクレートと同様に機能的にはまだまだ不足しているものの、とりあえず簡単なGUIが欲しいときにはまぁ使えるレベルくらいにはなっている感じ。

開発は着実に進んでおり、つい先日、バージョンが0.3に上がった。

そこで今回は、バージョンが上がったこととは何の関係もなく、簡単な画像ビューアを作ってみた。動作としては、ウィンドウに画像ファイルをドラッグ・アンド・ドロップするとファイルパスを表示しつつ当該画像を表示する、というもの。

自分の環境は以下の通り。Icedはクロスプラットフォームらしいので、MacやLinuxでも大丈夫なのではないかと思う。

  • Windows 10
  • Rust 1.50.0

なおIcedの基本的な部分については、使ってみた的な記事が検索でいくつか見付かるので、そちらを参照。

キモ的な部分

今回は、ファイルのドロップ処理と画像表示について試すのがメイン。

ファイルのドロップのようなイベントについては、icedのリポジトリのexamplesにあるeventsが参考になる。

https://github.com/hecrj/iced/tree/master/examples/events

ビルドして実行してみると、操作によりどんなイベントが発生するかが分かる。なお今回のソースは、これをベースにして表示処理を加えただけとも言える。

作業手順

プロジェクトの作成

まずは適当なフォルダで以下のコマンドを実行し、プロジェクトを作成する。ごく普通のcargo。名前はおまかせ。

cargo new image-viewer

フォントファイルの配置

Icedのデフォルトでは英字しか表示できないので、日本語を表示するために、フォントのファイルをダウンロードして配置する。

フォントは何でもいいけど、とりあえずド定番であるNoto SansのRegularを使用する方法を書いておく。

https://fonts.google.com/specimen/Noto+Sans+JP

ここの「Regular 400」の「Select this style」を選択して、「Download all」でダウンロードし、zipファイルを解凍する。

今回のプロジェクトディレクトリ(Cargo.tomlがあるディレクトリ)に「fonts」フォルダを作って、解凍結果の.otfファイルをその中に配置する。とは言っても配置はどこでもよくて、後のコードで出てくる読み込みパスをそれに応じて変更すれば問題ない。

Cargo.tomlの編集

dependenciesにIced関係を記載する。

Cargo.toml
[dependencies]
iced = {version = "0.3", features = ["image"]}
iced_native = "0.4"

icedにfeaturesを指定しているのは、画像の表示にImageウィジェットを使用するため。これがないとコンパイルエラーとなる。

main.rsの編集

メインのソースコードを、以下のように記述する。参考として軽くコメントを入れている。

main.rs
use iced::{
    executor, Align, Application, Clipboard, Column, Command, Container, Element, Image, Length,
    Settings, Subscription, Text,
};
use std::path::PathBuf;

pub fn main() -> iced::Result {
    // フォントを指定しつつ実行する。
    Events::run(Settings {
        default_font: Some(include_bytes!("../fonts/NotoSansCJKjp-Regular.otf")),
        ..Settings::default()
    })
}

// メインとなる構造体。アプリで保持する状態を変数にする。
#[derive(Debug, Default)]
struct Events {
    path: PathBuf,
}

// 何らかの変更があったときに飛ぶメッセージ。今回はイベント発生のみ。
#[derive(Debug, Clone)]
enum Message {
    EventOccurred(iced_native::Event),
}

impl Application for Events {
    type Executor = executor::Default;
    type Message = Message;
    type Flags = ();

    fn new(_flags: ()) -> (Events, Command<Message>) {
        (Events::default(), Command::none())
    }

    // ウィンドウのタイトル。状態に合わせた動的な生成も可。
    fn title(&self) -> String {
        String::from("Image Viewer")
    }

    // 何らかの変更があったときに呼び出される。
    // 発生した事柄はenum(今回の場合はMessage)として伝えられる。
    // Icedのバージョン0.3から引数にClipboardが増えたが、使わないので無視。
    fn update(&mut self, message: Message, _clipboard: &mut Clipboard) -> Command<Message> {
        // ファイルがドロップされたときに、アプリの状態を変更する。
        // Eventのenumの中に、イベントの内容(別のEventのenum)とか、
        // 今回のFileDroppedではファイルパスが含まれたりする。
        match message {
            Message::EventOccurred(event) => {
                if let iced_native::event::Event::Window(we) = event {
                    if let iced_native::window::Event::FileDropped(path) = we {
                        self.path = path;
                    }
                }
            }
        };

        Command::none()
    }

    // イベントが発生したときに呼び出される。マウス操作、ウィンドウ関係、キーボード操作等。
    // 何らかのSubscriptionを返すことで、update()が実行される。
    fn subscription(&self) -> Subscription<Message> {
        iced_native::subscription::events().map(Message::EventOccurred)
    }

    // 表示されるGUIを生成する。
    fn view(&mut self) -> Element<Message> {
        // ファイルパス表示部
        let mut p = self.path.to_str().unwrap_or("").to_string();
        if p.is_empty() {
            p = String::from("画像ファイルをウィンドウにドロップしてね。");
        }
        let path = Container::new(Text::new(p).size(20)).padding(4);

        // 画像表示部
        let image = Container::new(
            Image::new(self.path.clone())
                .width(Length::Fill)
                .height(Length::Fill),
        )
        .height(Length::Fill)
        .width(Length::Fill)
        .align_x(Align::Center)
        .align_y(Align::Center);

        let content = Column::new()
            .width(Length::Fill)
            .align_items(Align::Start)
            .push(path)
            .push(image);

        Container::new(content)
            .width(Length::Fill)
            .height(Length::Fill)
            .into()
    }
}

実行

プロジェクトディレクトリで以下のコマンドによりアプリを実行する。

cargo run --release

--releaseオプションを付けて実行するのは、動作速度の関係。よくあるやつ。

普通のデバッグビルドだとIcedの動作が遅く、画像が表示されるまでかなり時間がかかってしまう。特に大きな画像ファイルを表示しようとすると顕著。

リリースビルドはビルドに3倍くらい時間がかかるので、開発中はデバッグビルドでいいと思う。

コードの補足

基本的な動き等については上記のソースコード中に記しているので、その補足。

Application

Icedでは基本的なアプリの作りとして、SandboxApplicationのいずれかを選べる。ApplicationSandboxと比べると少し作りが複雑になる。

今回はApplicationとした。なぜなら、ファイルのドロップのようなパッシブのイベントを受け取るためにはApplicationにする必要があるから。他にもタイマー等の非同期的な動きをする場合も同様。

Commandで色々できるみたいだけど、まだあまり理解していない。

複数のEvent

Icedには複数のenum Eventがあってややこしい。

今回は基本のイベントであるiced_native::event::Eventと、その中身であるウィンドウ用のiced_native::window::Eventを使用したけど、他にもキーボード用、マウス用、タッチ用がある。

上記のコードのようにフルパス的に指定するのが正攻法だと思うけど、自分で別名を付けるという方法もあるので、お好みで。

その他思うこと

表示する画像の加工とウィジェットのサイズ

Imageウィジェットで画像を表示しているけど、これは画像を自動的にスケーリングして表示してくれるのでとても楽。

でも逆に言えば、凝ったことをやりたいなら自分で画像を加工する必要があるってこと。例えば画像の一部だけを表示したり、スケーリングのアルゴリズムを変えたりとか。あと今回のアプリだと、ファイルのホバー中に枠を表示したかった。

画像を加工するクレートを使えば何とでもなりそうではあるものの、どうやら画面に表示されているImageウィジェットのサイズ(=最終的な表示サイズ)を取得できないっぽいので、上手く加工できない気がしている。

というか今回のコードではImageをレスポンシブレイアウトで可変サイズにしているので、それのせいなのだろうと思っている。固定サイズにすれば迷わなくてよいことだし。恐らく状態に応じて、可変サイズのImageと、スクロールエリアに入れた固定サイズのImageを切り替えたりするべきなのだろう。

ファイルを指定するダイアログ

ファイルを開く方法としてはファイルダイアログもあるけど、これをRustで実装するのはかなり微妙という認識。特にIcedのようなPure Rustでクロスプラットフォームな環境のことを考えると、どうするのが良いのかよく分からない。

今回、ファイルドロップで画像を表示するという仕様にしたのは、そこの回避策でもある。Icedだけで完結するので。

最後に

RustのGUIはまだまだ未成熟なので、きちんとしたプロダクトレベルのものを作ろうとするとイバラの道。将来的には時間(と誰か)が解決してくれるはず。
ただGUIみたいな巨大にならざるを得ないクレートについては、財団が主導していくのもアリだと思ったりもする。文化・思想的にやらないだろうけど。

というかGUIのようなレベルだとRustは大仰すぎる気がしている。メモリ管理や型の厳密性といったメリットはもちろん大きいけど、ちょっとしたアプリみたいな気楽に書きたいジャンルだとRustが適しているとは言い難い。
まぁRustはシステムプログラミング言語を謳っているので当然といえば当然ではある。適材適所。