🚀

Rust・GUIフレームワークicedでランチャーを作った

2021/12/14に公開

はじめに

こんにちは。前回に引き続き、自分の普段使い用のユーティリティアプリケーションを実装してみたのでその紹介と、使用したRust製フレームワークのicedについて書いてみます。
今回作ったのは簡易アプリケーションランチャーです。

https://github.com/kyoheiu/feu

起動回数でバイナリをソートすることで、よく使うアプリケーションをすぐ立ち上げられる他、読み込むバイナリ置き場も/usr/bin以外に設定ファイルで追加できます。

特にi3wmなどのLinuxタイル型ウィンドウマネージャー[1]と合わせて使うことを想定しています。

以下、自分が使っているからという理由でi3を例として書いていきます。

タイル型には「スタートメニュー」という概念がないので、アプリケーションを立ち上げる際はまず軽量ランチャーをキーボードショートカットで起動し、そこから各アプリケーションを立ち上げるのが普通です。

※もちろんランチャーがなくてもターミナル経由で起動することは可能ですが、ひと手間増えますし、起動のためのターミナルを消すという作業も発生します。

i3のデフォルトのランチャーはdmenuですが、rofiのほうが流通している情報量も多く、デファクトスタンダードになっていると思います。僕もこれまでrofiを使ってきましたが、オレオレRIIR(Rewrite it in Rust)の一環としてシンプルなランチャーを実装してみました。

フレームワーク選定

RustのGUIフレームワーク状況はまだ混沌としています(cf: Rust GUI: Introduction, a.k.a. the state of Rust GUI libraries (As of January 2021) - DEV Community)が、現在継続されているプロジェクトの中で特にコミュニティも注力しているものとしては、

  • egui
  • iced
  • conrod

このあたりかなと感じます。(cf: eguiで作るRustのGUI(基本的な使い方と日本語表示)
今回の選定にあたり、重視したポイントは以下の2つです。

  • always on topが可能
  • なるべくシンプルに実装できる

2つめに関しては好みといえば好みですが、1つめは重要です。タイル型での使用とはいえ、ランチャー自身もタイル型に敷き詰められてしまうと、作業スペースのレイアウトが都度変わってしまい不便です。

ドキュメントを掘り下げきれていないこともあり、eguiはalways on topが可能かどうか判断がつきませんでした。また、conrodはどうもかなり難しそう。一方icedは可能だという情報がすぐに出てきたのと、Elmライクな文法ということでとっつきやすいのでは、と期待もできたので、今回はicedを選択しました。

icedの仕様

icedは関数型プログラミング言語ElmのThe Elm Architectureに強くインスパイアされた…というかほぼそのもののフレームワークです。表にするとこんな感じです。

Elm iced
Model Struct State
init fn new
view fn view
update fn update
Msg Enum Message

icedの場合、まず構造体としての状態と、updateのための列挙型を設定した上で、viewやupdateを実装していきます。この構造を把握さえできれば、画面の更新自体は難しいことを考えなくてもよしなにやってくれる、というのがicedの良いところです。

今回作ったランチャーの実装はシンプルです。

  • new -> バイナリを配列に読み込み、起動回数で並べ替える
  • view -> icedの書式にならい、Elementにウィジェットをpushする
  • update -> キー入力でリストをフィルターしたり起動したりExitしたりする

Model / State

まず、状態を設定します。

pub struct State {
    input: text_input::State, //テキスト入力ボックスの状態
    input_value: String, //入力された文字列。フィルターに使う
    cursor: usize, //カーソル位置
    page_number: usize, //ページ位置
    bins: Vec<(String, usize)>, //読み込んだ全バイナリ名の配列
    filtered: Vec<(String, usize)>, //フィルターされたバイナリ名の配列
    history: HashMap<String, usize>, //起動回数記録
    path: std::path::PathBuf, //起動回数を記録する外部ファイルのパス
}

見ての通りですが、バイナリ名の配列であるbinsとfilteredについて説明しておきます。
まずこの二つを両方保持する理由について。入力されたテキストによってfilteredを変更し、出力していくわけですが、時には入力したものを消してリストの状態を戻していく、ということもあります。その際、もともとの配列を保持していたほうが素直にfilteredを更新していける/アプリケーションの起動もスムーズ、というのが理由です。
また、単なるVec<String>ではなく、Vec<(String, usize)>となっていますが、このusizeは各アプリケーションの起動回数を表しています。起動回数を外部ファイルに保存しておき、起動時に読み込んでVec<(String, usize)>を生成。これをsort_byして、よく使うアプリケーションを常に上位に持ってくることで、たとえば$mod+d+Enterで文字入力なしにトップのアプリケーションを即時起動できるようになります(rofi仕様)。

init / new

次にDefaultをStateに実装し、newに備えます。ここで初期表示のための様々な処理、具体的には履歴や設定ファイルの読み込み、アプリケーションリストの並び替え等を行い、Stateにまとめて返します。

impl Default for State {
    fn default() -> Self {
	//中略。rayonのpar_iter()を使ったりしてなるべく高速に初期化
        State {
            input: text_input::State::focused(),
            input_value: "".to_string(),
            cursor: 0,
            page_number: 0,
            bins: vec.clone(),
            filtered: vec,
            history: map,
            path: history_path,
        }
    }
}

text_input::State::focused()で起動時にテキスト入力エリアがフォーカスされている状態になります。

そしてviewとupdateを組んでいくわけですが、これらはv0.3.0現在、ApplicationトレイトかSandboxトレイトのいずれかをStateに実装する形で進めていきます。Applicationは基本的に全ての機能が揃っている一方、Sandboxはいくつかの機能が使えません。少しだけicedを試してみたいという用途にはSandboxを使ってください、と公式も書いています。
ただ今回は、Sandboxにはないsubscriptionロジックが必要だったので、Applicationを実装します。

impl Application for State { ...

view

まずはviewです。

    fn view(&mut self) -> Element<Message> {
        //テキスト入力ボックスを宣言。更新時にMessage::InputChangedを送る。
        let text_input = TextInput::new(
            &mut self.input,
            "",
            &self.input_value,
            Message::InputChanged,
        )
        .style(super::style::TextInput);
        
        //バイナリ名の配列を宣言。選択されているバイナリは色を変えて出力。
        let bins_list: Element<Message> = {
            self.filtered
                .iter()
                .skip(self.page_number * 7)
                .take(7)
                .enumerate()
                .fold(Column::new(), |column, (i, item)| {
                    if (i + (self.page_number * 7)) == self.cursor {
                        column.push(Element::new(Text::new(&item.0).color([1.0, 0.5, 0.0])))
                    } else {
                        column.push(Element::new(Text::new(&item.0)))
                    }
                })
                .into()
        };

        // 上記2つのウィジェットを内包するウィジェットを設定。
        // Columnは縦並べのシンプルなウィジェット
        let content = Column::new()
            .padding(17)
            .spacing(5)
            .push(text_input)
            .push(bins_list);

        // 上記のウィジェットを.into()でElement<Message>として返す
        Container::new(content)
            .width(Length::Fill)
            .height(Length::Fill)
            .style(super::style::Container)
            .into()
    }

各ウィジェットの名称や、必要となるfieldなどのお作法はそれぞれにありますが、全体としてかなりシンプルに記述できます。

update

次に、画面を更新するupdateです。Messageの各variantにパターンマッチさせる形で、更新の挙動を記述していきます。

    fn update(
        &mut self,
        message: Message,
        _clipboard: &mut iced::Clipboard,
    ) -> iced::Command<Message> {
        match message {
            Message::InputChanged(words) => {
                self.input_value = words;
                self.filtered = self
                    .bins
                    .par_iter()
                    .filter(|&item| (*item.0).contains(&self.input_value))
                    .cloned()
                    .collect();
                self.cursor = 0;
                self.page_number = 0;
            }
            Message::MoveCursor(mv) => match mv {
                Move::Up => {
                    if self.cursor > 0 {
                        if self.cursor % 7 == 0 {
                            self.page_number -= 1;
                        }
                        self.cursor -= 1;
                    } else {
                    }
                }
                Move::Down => {
                    if self.cursor < self.filtered.len - 1 {
                        if (self.cursor + 1) % 7 == 0 {
                            self.page_number += 1;
                        }
                        self.cursor += 1;
                    } else {
                    }
                }
            },
            Message::Execute => {
                let bin = self.filtered.get(self.cursor);
                if let Some(bin) = bin {
                    std::process::exit(match launch_app(&bin.0) {
                        Ok(_) => {
                            let x = self.history.entry(bin.0.clone()).or_insert(0);
                            *x += 1;
                            update_history(&self.history, &self.path).unwrap();
                            0
                        }
                        Err(e) => {
                            eprintln!("error: {:?}", e);
                            1
                        }
                    });
                }
            }
            Message::Exit => {
                std::process::exit(0);
            }
        }
        iced::Command::none()
    }

このうち、MoveCursor(mv)とExecute, Exitについては、以下のsubscription経由で呼び出すことになります。

subscription - キーボード入力の常時受け付け

ウィジェットとしてtext_inputを組み入れれば、そこに文字入力をすることができるようになります。
ただし注意したいのは、フォーカスされたウィジェットとは関係なくキー入力やマウス操作を受け付けたい、というケース。キーボードショートカットなどがそれにあたると思いますが、この場合にsubscriptionメソッドが必要です。

    fn subscription(&self) -> Subscription<Message> {
        subscription::events_with(|event, _status| match event {
            Event::Keyboard(keyboard::Event::KeyPressed {
                modifiers: _,
                key_code,
            }) => handle_key(key_code),
            _ => None,
        })
    }

	//中略

fn handle_key(key_code: keyboard::KeyCode) -> Option<Message> {
    match key_code {
        keyboard::KeyCode::Up => Some(Message::MoveCursor(Move::Up)),
        keyboard::KeyCode::Down | keyboard::KeyCode::Tab => Some(Message::MoveCursor(Move::Down)),
        keyboard::KeyCode::Enter => Some(Message::Execute),
        keyboard::KeyCode::Escape => Some(Message::Exit),
        _ => None,
    }
}

これで、Up/Down(Tab)/Enter/Escでそれぞれ選択の移動・実行・Exitを行えるようになります。このあたりがきちんと用意されているのもicedのナイスポイントですね。

起動回数の記録

new内とupdate内では、起動回数を記録した外部ファイルのそれぞれ読み出し・書き出しを行っています。
外部ファイルのフォーマットはRONを使いました。

(history_map:{"spotify":1,"firefox":15,"vmware-view":1,"code":8,"zellij":1,"inkscape":1})

特に強い理由はないのですが、もしかすると少し起動が速くなるかも、という期待はありました。実際には小さなファイルなので読み出し・書き出し速度が変わるとしてもごくごくわずかでしょうが…。

iced雑感

とにかくシンプルに、思った通りの画面の初期化・表示・更新を記述できる、というのがElm…じゃなくicedの一番のストロングポイントですね。今回のランチャーも、履歴と設定ファイルそれぞれの読み出し・書き出し、それにスタイル実装のコードを含めてもわずか400行弱で実装できたので、相当の表現力ではないでしょうか。
また、バックのデータ処理をRustでそのまま書けるというのも、Rust製フレームワークならではのメリットです。

とはいえ気になるところもないではありません。

立ち上がりが引っかかる

個人的に気になったのは、アプリケーション自体の立ち上がりが若干遅い点。ランチャーはなるべく早く起動してなるべく早くexitしたい類のアプリなのですが、起動時に少し引っかかりを感じる、というのが正直なところです(exitは全く問題ありません)。実際rofiと比べてみると、

# /bin/bash time
➜ time feu
real	0m0.447s //今回realは無関係
user	0m0.061s
sys	0m0.132s
	
➜ time rofi -show run
real	0m0.267s
user	0m0.031s
sys	0m0.017s

となり、惨敗です。他のフレームワークだとまた違う結果が出るのかもしれませんが、このへんの速度をしっかり求めたい向きにはやはりまだC/C++なのかなという気もします。

スタイリングが難しい

ウィンドウ内の背景やら文字色やら…をどう設定すればいいのか、最初は完全に五里霧中でした。公式のサンプルコードを読んでもなかなか頭に入ってこない。
これはドキュメントが薄いから、と言えばそれまでですが、icedに限らず現状RustのOSSはだいたいそういうものなので、頑張るということで一つなんとかしましょう。

モジュール関係が複雑

icedがもちろんメインのライブラリなわけですが、実はさらにiced_nativeやiced_styleやらがあり、どれをいじればいいのかまだまだわかりにくいです。基本的にはicedにインポートされているのですが、たとえばsubscriptionは実はiced_nativeに記述されており、そのためiced_nativeを別途dependenciesに入れなくてはいけなかったりします。

と、色々書きましたが、まだまだWIPなので今後が楽しみです。どこかで内部コードを読んでみたいですね。

脚注
  1. ウィンドウマネージャーには大まかに「フロート型」と「タイル型」があります。フロート型は多くの方がWindowsやMacで使っている、ウィンドウのサイズや位置をマウス等で自由に動かすことを前提とした方式で、各ウィンドウは重なっていることもしばしばです。
    それに対しタイル型は、画面全体を使ってウィンドウが重ならないよう敷き詰めていく方式をとります。きれいに等分に分割していくか、比率を変えて大小をつけるかは個人の好みですが、共通しているのは「なるべくキーボードで操作する」という哲学です。アプリケーションの起動、ウィンドウの位置の入れ替え、ワークスペースの切り替えなどをすべてキーボードショートカットで行うことで生産性が高まる、というのがタイル型ウィンドウマネージャーの考え方です。 ↩︎

Discussion