rustのGUIライブラリconrodの使い方

所用でrustのGUIライブラリについて調べる機会がありました.特にこれといったGUIライブラリはまだないような気がします.

普段自分がネイティブなGUIアプリケーションを作成する場合はQtを使います.rustからQtを呼び出すのはいくつか試みがあって,disassemblerのpanopticonGUIとしてQtを使ってますし,rustからQtを呼び出すためのcpp_to_rustというプロジェクトもあります.ただどうもまだいろいろと開発中のようです.

今回はconrodというGUIライブラリを使ってみたので,簡単にそれの使い方について書こうと思います.

conrod

conrodは純rust製のGUIライブラリです.conrodが担当するのはウィジェッ ト(ボタンやテキストボックスなど)の構成の管理やウィジェットに対するイベントの伝播などです.pistonというゲームエンジンを開発しているところが作っているようです.実際の描画やOSからのイベントの受け取りは何か別のライブラリが担当することになります.conrod自体に描画用としてglium (OpenGL), イベント管理用としてwinitのバックエンドが用意されているので,普通はそれを使うことになると思います*1.自分はmacでしか試していませんが,マルチプラットフォームで動作するはずです.ちなみに,まだバージョン1.0にもなってないので仕様が大きく変わる可能性は十分あると思います.

conrodのチュートリアルはまだ未完成のようですが,rustdocは比較的丁寧に書いてあると思います.また,実際にプログラムを作成する場合は,リポジトリにある

あたりを参考にするのが良いかと思います.

exampleはリポジトリをクローンして

$ cargo run --release --features "winit glium" --example hello_world

とかやれば動きます.

簡単な例

ものすごく簡単な例として,以下のようにフィボナッチ数を計算するアプリケーションを作成しようと思います.

f:id:mm_i:20170709234547p:plain

ソース全体はここにあります: https://github.com/mmisono/conrod-examples/tree/master/fibonacci

これから説明するコードの大部分(初期化部分とイベントループ部分)はexamplesのものとほぼ同じです.

Cargo.toml

前述した通り,描画にglium, イベント取得にwinitを使うので,Cargo.tomlには以下のように書いてあげます.また,フォントを登録する際にフォントが格納されているフォルダを探す必要があるので,find_folderもdependenciesに追加しています.

[dependencies.conrod]
version = "0.53.0"
features = ["glium", "winit"]

[dependencies]
find_folder = "*"

初期化

conrodを使うための初期化のコードは以下のようになります.WindowやUiといったコンポーネントを作成したり,必要なフォントや画像を読み込んだりします.

    const TITLE: &'static str = "Fibonacci";
    let width = 300;
    let height = 100;

    // Build the window.
    let display = glium::glutin::WindowBuilder::new()
        .with_vsync()
        .with_dimensions(width, height)
        .with_title(TITLE)
        .with_multisampling(4)
        .build_glium()
        .unwrap();

    // construct our `Ui`.
    let mut ui = conrod::UiBuilder::new([width as f64, height as f64]).build();

    // Add a `Font` to the `Ui`'s `font::Map` from file.
    let assets = find_folder::Search::KidsThenParents(3, 5)
        .for_folder("assets")
        .unwrap();
    let font_path = assets.join("fonts/NotoSans/NotoSans-Regular.ttf");
    ui.fonts.insert_from_file(font_path).unwrap();

    // Generate the widget identifiers.
    let ids = &mut Ids::new(ui.widget_id_generator());

    // A type used for converting `conrod::render::Primitives` into `Command`s that can be used
    // for drawing to the glium `Surface`.
    let mut renderer = conrod::backend::glium::Renderer::new(&display).unwrap();

    // The image map describing each of our widget->image mappings (in our case, none).
    let image_map = conrod::image::Map::<glium::texture::Texture2d>::new();

    let mut text = "0".to_string();
    let mut answer = "0".to_string();

ここで,widget identifierを生成していますが,これはウィジェット管理に必要なIDで,ウィジェットごとにIDが必要です.Idsは以下のようにマクロで生成します.

widget_ids!(
    struct Ids {
        canvas,
        title,
        text_box,
        button,
        result,
    });

イベントループ

conrodではイベントループベースで処理をおこないます.ループ処理は以下のようになります.

    let mut event_loop = EventLoop::new();
    'main: loop {
        // Handle all events.
        for event in event_loop.next(&display) {
            // Use the `winit` backend feature to convert the winit event to a conrod one.
            if let Some(event) = conrod::backend::winit::convert(event.clone(), &display) {
                ui.handle_event(event);
                event_loop.needs_update();
            }

            match event {
                // Break from the loop upon `Escape`.
                glium::glutin::Event::KeyboardInput(
                    _,
                    _,
                    Some(glium::glutin::VirtualKeyCode::Escape),
                ) |
                glium::glutin::Event::Closed => break 'main,
                _ => {}
            }
        }

        set_widgets(ui.set_widgets(), ids, &mut text, &mut answer);

        // Render the `Ui` and then display it on the screen.
        if let Some(primitives) = ui.draw_if_changed() {
            renderer.fill(&display, primitives, &image_map);
            let mut target = display.draw();
            target.clear_color(0.0, 0.0, 0.0, 1.0);
            renderer.draw(&display, &mut target, &image_map).unwrap();
            target.finish().unwrap();
        }
    }

match eventの部分でイベントを補足し,必要な処理をします.通常ここで処理するイベントは終了処理といった大域的なイベントのみで,それ以外の処理(ウィジェットに対するマウスイベントとか)は後でおこないます.その後,set_widgets()という関数でGUIを設定し,必要なら描画をおこないます.

イベントループの本体は以下のようになっています.基本的にはウィンドウが受け取ったイベントを取得してそれを返すだけですが,60fpsを超えないように適当にスリープします.

struct EventLoop {
    ui_needs_update: bool,
    last_update: std::time::Instant,
}

impl EventLoop {
    pub fn new() -> Self {
        EventLoop {
            last_update: std::time::Instant::now(),
            ui_needs_update: true,
        }
    }

    /// Produce an iterator yielding all available events.
    pub fn next(&mut self, display: &glium::Display) -> Vec<glium::glutin::Event> {
        // We don't want to loop any faster than 60 FPS, so wait until it has been at least 16ms
        // since the last yield.
        let last_update = self.last_update;
        let sixteen_ms = std::time::Duration::from_millis(16);
        let duration_since_last_update = std::time::Instant::now().duration_since(last_update);
        if duration_since_last_update < sixteen_ms {
            std::thread::sleep(sixteen_ms - duration_since_last_update);
        }

        // Collect all pending events.
        let mut events = Vec::new();
        events.extend(display.poll_events());

        // If there are no events and the `Ui` does not need updating, wait for the next event.
        if events.is_empty() && !self.ui_needs_update {
            events.extend(display.wait_events().next());
        }

        self.ui_needs_update = false;
        self.last_update = std::time::Instant::now();

        events
    }

    /// Notifies the event loop that the `Ui` requires another update whether or not there are any
    /// pending events.
    ///
    /// This is primarily used on the occasion that some part of the `Ui` is still animating and
    /// requires further updates to do so.
    pub fn needs_update(&mut self) {
        self.ui_needs_update = true;
    }
}

GUI設定の本体

set_widgets()関数が実際にGUIの設定をする箇所です.conrodでアプリケーションを作成する場合は基本的にこのset_widgets()の部分を適当にカスタマイズすることになると思います.

conrodではボタンやテキストボックスなどのウィジェットを内部で木構造として管理しています.wiget::Text::New("aaa").set(ids.title, ui) のようにすると,指定したIDでそのウィジェットが登録されます.set()をしたとき,指定したidのウィジェットに対してもし何かイベントが発生していたらそれが返ってきます.若干分かりにくいと思うのでコードを見てもらった方が早いと思います.

fn set_widgets(ref mut ui: conrod::UiCell, ids: &mut Ids, text: &mut String, answer: &mut String) {
    widget::Canvas::new()
        .pad(0.0)
        .color(conrod::color::rgb(0.2, 0.35, 0.45))
        .set(ids.canvas, ui);

    let canvas_wh = ui.wh_of(ids.canvas).unwrap();

    // title
    widget::Text::new("Fibonacci Calculuator")
        .mid_top_with_margin_on(ids.canvas, 5.0)
        .font_size(20)
        .color(color::WHITE)
        .set(ids.title, ui);

    // textbox
    for event in widget::TextBox::new(text)
        .font_size(15)
        .w_h((canvas_wh[0] - 90.) / 2., 30.0)
        .mid_left_with_margin_on(ids.canvas, 30.0)
        .border(2.0)
        .border_color(color::BLUE)
        .color(color::WHITE)
        .set(ids.text_box, ui)
    {
        match event {
            widget::text_box::Event::Enter => println!("TextBox {:?}", text),
            widget::text_box::Event::Update(string) => *text = string,
        }
    }

    // button
    if widget::Button::new()
        .w_h((canvas_wh[0] - 90.) / 2., 30.0)
        .right_from(ids.text_box, 30.0)
        .rgb(0.4, 0.75, 0.6)
        .border(2.0)
        .label("calc!")
        .set(ids.button, ui)
        .was_clicked()
    {
        if let Ok(num) = text.parse::<u64>() {
            *answer = fib(num).to_string();
        } else {
            println!("invalid number");
        }
    }

    // result
    widget::Text::new(answer)
        .mid_bottom_with_margin_on(ids.canvas, 10.0)
        .font_size(20)
        .color(color::WHITE)
        .set(ids.result, ui);
}

例えば,TextBoxにユーザが何か入力すると,それがset()したときに返ってくるので,入力した内容を変数textに保存しています.また,Buttonの場合はwas_clicked()を呼ぶとボタンが押されたかどうか分かるので,ボタンが押された場合は計算を実行します.

ウィジェットに位置の指定ですが,各ウィジェットPositonableというトレイトを実装していて,これを利用することになります.

例えばタイトルテキストは .mid_top_with_margin_on(ids.canvas, 5.0)としていますが,こうするとids.canvasで指定したウィジェットの中央上に指定したマージン幅を開けてウィジェットを配置することになります.また,.down()を使えば直前にset()したウィジェットの下に指定したマージンだけ空けてウィジェットが配置されます.他にもいろいろ配置用の関数が用意されています.個人的には一番上に適当なウィジェットを配置したあと,.down().align_middle_x()を使ってどんどん下にウィジェットを配置していくのが楽なんじゃないかなと思います.

三目並べ

conrodの特徴はidに紐づけてウィジェットを登録し,そのidを利用してウィジェットが配置できるというところだと思います.こうすることであまり深くレイアウトを考えなくても簡単にウィジェットが配置できます.一方で,各ウィジェットに対して一意なIDが必要なので,ウィジェットが多い場合はその管理が面倒になることもあります.

例として,以下のような三目並べのアプリケーションを考えます.

f:id:mm_i:20170709234602p:plain

(ソース: https://github.com/mmisono/conrod-examples/tree/master/tic_tac_toe)

conrodでは直線や円も一つのウィジェットです.したがって,それぞれに対してIDを振って管理する必要があります.IDは以下のようにして配列で持つことができます.

widget_ids!(
    struct Ids {
        canvas,
        grids[],
        circles[],
        xs[],
    });
let ids = &mut Ids::new(ui.widget_id_generator());
ids.grids
    .resize((rows - 1) * (cols - 1), &mut ui.widget_id_generator());
ids.circles
    .resize(rows * cols, &mut ui.widget_id_generator());
ids.xs
    .resize(rows * cols * 2, &mut ui.widget_id_generator());

丸とバツを描画するために,ここではあらかじめ各セルに対応するIDを生成させています(バツは直線二本なので2倍のIDを用意しています).

グリッドの描画

グリッドの描画は以下のようにしておこないます.注意点としては座標はデカルト座標(中央が(0,0))です.

fn set_widgets(
    ref mut ui: conrod::UiCell,
    ids: &mut Ids,
    board: &mut [&mut [BoardState]],
    turn: &mut Turn,
) {
    widget::Canvas::new()
        .pad(0.0)
        .color(color::WHITE)
        .set(ids.canvas, ui);

    let rows = board.len();
    let cols = board[0].len();
    let canvas_wh = ui.wh_of(ids.canvas).unwrap();
    let tl = [-canvas_wh[0] / 2., canvas_wh[1] / 2.];
    let sw = canvas_wh[0] / (cols as f64);
    let sh = canvas_wh[1] / (rows as f64);

    // draw grid line
    for x in 1..cols {
        widget::Line::abs(
            [tl[0] + (x as f64) * sw, tl[1]],
            [tl[0] + (x as f64) * sw, tl[1] - canvas_wh[1] + 1.],
        ).color(color::BLACK)
            .set(ids.grids[x - 1], ui);
    }
    for y in 1..rows {
        widget::Line::abs(
            [tl[0], tl[1] - (y as f64) * sh],
            [tl[0] + canvas_wh[1] - 1., tl[1] - (y as f64) * sh],
        ).color(color::BLACK)
            .set(ids.grids[y - 1 + cols - 1], ui);
    }

マウスイベントの検知

マウスイベントの検知は,ui.widget_input().mouse()を使います.

    if let Some(mouse) = ui.widget_input(ids.canvas).mouse() {
        if mouse.buttons.left().is_down() {
            let mouse_abs_xy = mouse.abs_xy();
            let x = ((mouse_abs_xy[0] + (canvas_wh[0] / 2.)) / (canvas_wh[0] / (cols as f64))) as
                usize;
            let y = ((canvas_wh[1] / 2. - mouse_abs_xy[1]) / (canvas_wh[1] / (rows as f64))) as
                usize;
            // println!("{:?}, {}, {}", mouse_abs_xy, x, y);

            // when resizing, x can be greater than cols (so do y)
            if x < cols && y < rows {
                if board[y][x] == BoardState::Empty {
                    match *turn {
                        Turn::White => {
                            board[y][x] = BoardState::Circle;
                            *turn = Turn::Black;
                        }
                        Turn::Black => {
                            board[y][x] = BoardState::X;
                            *turn = Turn::White;
                        }
                    }
                }
            }
        }
    }

盤面の描画

盤面の描画をする際は,IDが他のウィジェットと被らないように注意しながらウィジェットを作成します.うっかりIDが被ると上書きされてしまって古いウィジェットが消えてしまいます.

    // draw circle or x
    for y in 0..rows {
        for x in 0..cols {
            match board[y][x] {
                BoardState::Circle => {
                    widget::Circle::outline_styled(
                        sw / 3.,
                        widget::line::Style::new().thickness(2.),
                    ).x(tl[0] + sw * (x as f64) + sw / 2.)
                        .y(tl[1] - sh * (y as f64) - sh / 2.)
                        .color(color::RED)
                        .set(ids.circles[y * cols + x], ui);
                }
                BoardState::X => {
                    widget::Line::abs(
                        [
                            tl[0] + sw * (x as f64) + sw / 5.,
                            tl[1] - sh * (y as f64) - sh / 5.,
                        ],
                        [
                            tl[0] + sw * ((x + 1) as f64) - sw / 5.,
                            tl[1] - sh * ((y + 1) as f64) + sh / 5.,
                        ],
                    ).color(color::BLACK)
                        .thickness(2.)
                        .set(ids.xs[y * cols + x], ui);

                    widget::Line::abs(
                        [
                            tl[0] + sw * ((x + 1) as f64) - sw / 5.,
                            tl[1] - sh * (y as f64) - sh / 5.,
                        ],
                        [
                            tl[0] + sw * (x as f64) + sw / 5.,
                            tl[1] - sh * ((y + 1) as f64) + sh / 5.,
                        ],
                    ).color(color::BLACK)
                        .thickness(2.)
                        .set(ids.xs[y * cols + x + rows * cols], ui);
                }
                _ => {}
            }
        }
    }
}

少々面倒ですね.まぁ,そもそもこういうことをしたいのなら,使ったことないですがゲームエンジンであるpistonとかを使うべきなんだと思います.

まとめ

ということでconrodの基礎的な使い方について書きました.メニューやダイアログといった機能はまだないようですし,ウィジェット自体の数も決して多くはないので複雑なGUIアプリケーションを作成するのはちょっと大変かなという気がしますが,簡単で静的なものを作るにはいいかもしれません.

*1:gliumはoriginal authorがもう積極的にサポートしないことを表明していますが,コミュニティによってメンテナンスされていくようです(https://github.com/PistonDevelopers/conrod/issues/989).今original authorはvulkanのラッパーを作ってるので将来的にはそれに移行していくのかもしれません