2014年12月14日日曜日

Rendering a Christmas Star in Rust

この記事はRust Advent Calendar 2014の12/14担当分である。Rustには前から関心はあったのにあまり大したものは作ったことがなかったので、Cargoの使い方やらも学びつつglutin使って簡単なOpenGL(GLSL)アプリを作ることにした。

できたもの

よく考えたらAdvent Calendarってクリスマスにちなんだイベントなのでクリスマスっぽいものをレンダリングすることにした。対象はクリスマスツリーの頂点にあるあの星だ。正式にはなんていうかよくわからない(ベツレヘムの星?)のでとりあえずChristmas Starと呼んでる。できたものがコレ。

3Dでレンダリングされている星だ。右上に表示されている赤丸が光源位置で、キーボードの矢印キーで動かすことができる。もちろん光源を動かすと星の照らされ具合も変わる。ただし基本的な光源処理しかしてないためかいまいち不自然な光り方になっている。

ビルド方法

標準的なCargoパッケージとして作ってるので、Githubに置いたソースを落としてきてcargo runすれば走らせられる。…という説明で終わらせてしまうと不親切なのでもう少し詳細に書く(ただし対象はWindows 8.1 x64環境)。

何も入ってないまっさらな状態からのビルド方法は下記の通り。

  1. Rustのインストール

    公式サイトからインストーラ落としてきて起動して手順に従う

  2. Cargoのインストール

    ビルド済みバイナリを落としてきて適当に解凍してbinにパスを通す

  3. mingw-w64のインストール

    mingw-w64-install.exeを落としてきて下図の設定でインストールする。

    インストール終わったらC:\Program Files\mingw-w64\x86_64-4.9.2-posix-seh-rt_v3-rev0\mingw64\binにパスを通す。

  4. Cargoでビルド

    ここまで来たらあとはソースを落としてきてcargo buildでビルド、cargo runで実行できる

なお、現在動作を確認した環境はOSがWindows 8.1 x64で、使ったrustcは0.13.0-nightly (6f4c11be3 2014-12-05 20:23:10 +0000)、cargoは0.0.1-pre-nightly (da789a6 2014-11-30 08:14:16 +0000)となっている。Rustは正式リリース前でまだ色々変化してるため全く同じ環境でないとビルド通らないかも。その場合はcargo updateで依存crateをアップデートするとビルドが通る場合が多い。

Crate構成

今回作成したchristmas_starの構成は下記の通り。

  • main

    メイン関数のあるモジュール。

  • game

    操作可能でかつ描画されるオブジェクトのtraitが含まれるモジュール。ゲームじゃないから別の名前にしたいけど他の適切な名前が思いつかなかった。

  • glutil

    シェーダを読んだりコンパイル・リンクしたりといった各種関数が含まれているモジュール。

  • control

    ユーザーのキー操作やらをハンドルするモジュール。

  • christmas_star

    クリスマスっぽい星を描画するオブジェクトのモジュール。モデル生成時にスパイクの長さやらが変更できるようになっている。

  • light

    光源オブジェクトのモジュール。

    • directional

      平行光源のモジュール。今回は平行光源自体も描画したいので描画用のシェーダがモジュールに含まれている。

今回はCargoがどんなもんか調べる意図もあったのでわざと細かくモジュールを分けている。とはいえ、Rustはアクセス制御がモジュール間でpublicかprivateかしか選択肢が無いため、モジュールは比較的細かく分割した方が内部構造の隠蔽がはかどると思う。

コード解説

思った以上にコードの量が多くなったのでここでは一部だけ解説する。ソース全体はGithubにあるのでそっちを見てもらえれば。

  • main

    mainではウィンドウを生成した後ウィンドウイベントを処理しつつゲームループを回している。

    fn process_main_loop(window: &glutin::Window, obj_list: &mut Vec<&mut game::Object>) {
        let mut cs = control::State::new(); 
        while !window.is_closed() {
            // process window evets
            for ev in window.poll_events() {
                match ev {
                    glutin::Event::KeyboardInput(elem_state, _, key_code) => cs.handle_key_input(elem_state, key_code),
                    _ => (),
                }
            }
            // if cs.moving() {
            //     println!("control state: {}", cs);
            // }
            // free CPU.
            // We should be checking the elapsed time to see how long we can wait here.
            std::io::timer::sleep(std::time::duration::Duration::milliseconds(8));
    
            // update all
            for o in obj_list.iter_mut() {
                o.update(&cs)
                    .unwrap_or_else(|e| panic!("Error when updating: {}", e));
            }
    
            // draw all
            clear_screen();
            for o in obj_list.iter() {
                o.draw()
                    .unwrap_or_else(|e| panic!("Error when drawing: {}", e));
            }
            unsafe { gl::Flush(); }
            window.swap_buffers();
        }
    }
    
    fn main() {
        let builder = glutin::WindowBuilder::new();
        let r = builder.with_dimensions(300, 300)
            .with_title("rust glsl sample".to_string())
            .build();
        let window = r.unwrap_or_else(|e| panic!("Error while building window: {}", e));
        unsafe { window.make_current() };
        gl::load_with(|symbol| window.get_proc_address(symbol));
    
        let mut obj = christmas_star::ChristmasStar::new();
        obj.init()
            .unwrap_or_else(|e| panic!("ChristmasStar init failed: {}", e));
        // need an indent here because obj_list will own the obj
        {
            let mut obj_list : Vec<&mut game::Object> = Vec::new();
            obj_list.push(&mut obj);
            process_main_loop(&window, &mut obj_list);
        }
        obj.close();
    }

    この中で注目したいのはmain関数内でobj_listを生成しているあたりだ。他の言語であればobj_listを生成してobjを追加するところでブロックを分ける必要ないのにRustの場合は必要になっている。

    この理由はobjをobj_listに渡した瞬間obj_listがobjのownerとなるからだ。obj_listがobjのownerになると、obj_listが完全に消えたことが保証できないかぎりobj.close()が呼べなくなってしまう(close関数はobjをmutateするため)。実際ここで中括弧を外すと下のコンパイルエラーが出る。

       Compiling christmas_star v0.0.1 (file:///C:/.../christmas_star)
    C:\...\christmas_star\src\main.rs:72:5: 72:8 error: cannot borrow `obj` as mutable more than o
    nce at a time
    C:\...\christmas_star\src\main.rs:72     obj.close();
                                             ^~~
    C:\...\christmas_star\src\main.rs:69:28: 69:31 note: previous borrow of `obj` occurs here; the
     mutable borrow prevents subsequent moves, borrows, or modification of `obj` until the borrow ends
    C:\...\christmas_star\src\main.rs:69         obj_list.push(&mut obj);
                                                                        ^~~
    C:\...\christmas_star\src\main.rs:73:2: 73:2 note: previous borrow ends here
    C:\...\christmas_star\src\main.rs:54 fn main() {
    ...
    C:\...\christmas_star\src\main.rs:73 }

    スコープを限定すればobj_listの生存期間は保証できるのでここではブロックを分けて対処した。他にもしかしたらうまい方法があるかも。

  • christmas_star

    christmas_starは初期化をしている部分だけを解説する。ここではシェーダソースを読みこんでコンパイル・リンクしたあと、GPU側のリソースを初期化している。

    impl ChristmasStar {
    ...
        pub fn init(&mut self) -> Result<(), String> {
            let vss = include_str!("vertex.glsl");
            let fss = include_str!("fragment.glsl");
            let vs = try!(glutil::compile_shader(vss.as_slice(), gl::VERTEX_SHADER));
            let fs = try!(glutil::compile_shader(fss.as_slice(), gl::FRAGMENT_SHADER));
            let prog = try!(glutil::link_program(vs, fs));
    
            // remove shaders since we've finished linking it
            glutil::remove_shader(prog, vs);
            glutil::remove_shader(prog, fs);
     
            let (vao, vbo, ind_num) = try!(init_buffers(&self.geometry));
    
            let r = &mut self.resource;
            r.shader_program = prog;
            r.vao = vao;
            r.vbo = vbo;
            r.indice_num = ind_num;
    
            try!(self.directional.init());
    
            Ok(())
        }
    ...
    }
    

    注目したいのはinclude_strというマクロだ。GLSLはソースのプリコンパイルができないので実行時にソースを渡してコンパイルする必要があるんだけど、include_strマクロを使えば外部のテキストファイルを簡単に埋め込んでコンパイルすることができる。こういう機能は他の言語だとあまり見たことがなかった(知らないだけ?)ので素直に感心した。

雑感

今回Rustで書いてみて色々思ったことを連ねていく。

  • エラー処理の方法が色々あってよくわからない

    RustのライブラリをつかうとResultという型が返ってくることがよくあって、こいつをどう扱えばいいのかがちょっとわかりづらい。ドキュメントを見るとエラー処理用の関数が大量に用意されているものの推奨するエラー処理方法が書かれていないのだ。

    今回書いてみた感じ自分の中では以下のルールがぼんやりとできている。

    1. Resultを返す関数を使うときはすべてtryマクロで囲って上位に返す(tryマクロ使えばmatch書かずに済む)
    2. 自作関数で失敗しうるものは全てResultを返す
    3. トップレベル(main関数)ではtryマクロが使えないので、返ってきたResultはunwrap_or_elseを呼んでもしエラーがあればpanicでエラー出力する

    基本的には「いちいち自分でmatch処理を書くのツラいのでできるだけmatchせずに済むように」が方針になっている。try-catchが無くて関数から多値を返すことができるあたりGoと似ているので、Goと同じようにエラーを上位に返すやり方が適切に感じられる。

  • キャストの方法がよくわからない

    ループ変数(i32)を浮動小数点(f32)にキャストするときにto_f32という関数を使ったんだけど、こいつがOptionを返してくるのでキャスト失敗に備えなきゃいけないの? という疑問がわいてしまった。あとは「let stride = vertice_size as i32」みたいな書き方もできてしまうためasを使ったほうがいいのかto_**関数を使った方がいいのかよくわからない。現状は混在させて書いている。

  • ダウンキャストできない?

    当初はgame::ObjectをDrawableとMovableという2つのtraitに分けて管理しようとしていたのだけど、ダウンキャストがうまく行かないことがわかって断念した。Algebraic Data Type(というかenum)を上手く使えばできる気はしてて、ただドキュメントが少なく茨の道っぽかったのでできていない。

  • lifetimeはハマる

    ダウンキャストとの絡みでenumとtraitまわりを色々頑張ってみたものの最終的にはコンパイルを通すことすらできなかった。大体がlifetimeにまつわるエラーで、エラーの内容で検索しても残念ながら情報がほとんど無い。lifetimeまわりの書き方はもうちょっとドキュメントが充実してこないとハマると思う。

総じてドキュメントがあればどうにかなることが多い中、lifetimeまわりだけは難易度が高く初見殺しになりそうだと思った。

昔のRustはポインタが数種類あったり(関連する記号が'@'、'~'、'&'、'*'と4つあった)ビルドもMakefileでやってたりと、正直このままじゃ流行らねえわ…と思いながら追っていた。ところが最近は1.0に向けて言語仕様もシンプルになり、Cargoのおかげでビルドが簡単になりといい感じである。個人的にRustはC/C++を駆逐すべく頑張って欲しいので今後もちょくちょく書いていきたい。

2014年7月1日火曜日

Internationalization with Polymer.dart and intl

2014/7/6追記

もともとintl 0.9.10向けにかかれていたのを0.11.0向けに更新

GUIアプリを作る上で国際化をどうするか、というのは意外と面倒な問題で、後からやろうとすると大変な目に遭うことが多い。Polymer.dartを使ってウェブアプリを作る場合でも同様で、本格的に作りこむ前にうまい方法を確立しておきたい。

幸いDartには公式な国際化ライブラリ(intl)が存在していて基本的にはそれを使えば良さそうな状況になっている。ただこの国際化ライブラリ使ってみると手順が複雑で、案外ハマりどころがあったのでここでまとめておく。

サンプルプロジェクト

今回も動作するサンプルプロジェクトをGithubに置いといた。Github Pagesで動作するウェブサイトも作ろうとしたら何かが原因でコンパイラが落ちるため、今回はそこまでやらない(ちなみにIssueは上げてある)。とりあえずローカルで動かした時の画像だけは貼っておく。上が英語、下が日本語で、ロケールのselectorを変えることで言語を動的に変えられる。

国際化の流れ

簡単に国際化の流れを説明しておくと下記のようになる。intlのドキュメントにも色々書いてあるのでそちらも参照するとわかりやすい。

  1. pubspec.yamlでpolymer、intl、petitparserの使用を宣言する
  2. 各Componentの中で国際化したい文字列のidとデフォルトの表記を記述する。このときIntl.message関数を使う
  3. intlパッケージに含まれているextract_to_arb.dartを使い、Intl.message関数で指定した内容をarbファイルに吸い出す
  4. 各文字列idに対応する言語ごとの表記を言語ごとのarbファイルに記載する
  5. intlパッケージに含まれているgenerate_from_arb.dartに対して下記の項目を渡し、国際化用のクラスを自動生成する
    • 元のComponentのソースコード
    • 吸いだされたarbファイル
    • 作成した各言語ごとのarbファイル
  6. 生成された国際化用のクラスを使い、ロケールに応じた文字列が表示されるようpolymerのバインディングを書く

pubspec.yaml

pubspec.yamlは今回はこんな感じ。重要なのはpolymer、intl、とあとpetitparserが記載されていることで、最後のtransformersはdart2js用のものだからあまり本質的ではない。petitparserはあとで使うgenerate_from_arb.dartが依存しているためここで追加しておく。

name: polymer_intl_sample
dependencies:
  logging: any
  polymer: any
  intl: any
  serialization: any
  petitparser: any
transformers:
- polymer:
   entry_points:
   - web/index.html

Intl.message関数

Intl.message関数は文字列の翻訳処理を行うとともに、あとで使う各種スクリプトにとっての目印にもなる重要な関数だ。ドキュメントを読むと指定できる項目が色々あるようなんだけど、今回は最小限で行く。

下記のコードは今回のサンプルアプリの本体であるmain-appコンポーネントの一部で、下の方にIntl.message関数が使われている。ここでは最低限指定する必要があるデフォルトの表記"Hello"と文字列自体のID"hello"だけを指定している。

CustomTag("main-app")
class MainApp extends PolymerElement {
  ...
  /// Locale of language
  @observable String currentLocale;
  @observable String helloStr;
  @observable String userStr;
  @observable String localeStr;
  @observable String japaneseStr;
  @observable String englishStr;
  @observable int selectedLocale;

  ...

  void currentLocaleChanged(String old) {
    try {
      initializeMessages(currentLocale).then((succeed) => _updateLocale(currentLocale));
    } catch (e) {
      Logger.root.warning("Language $currentLocale is not supported. Defaulting to en_US");
      _updateLocale("en_US");
    }
  }

  ...
  
  void _updateLocale(String locale) {
    Intl.defaultLocale = locale;
    helloStr = hello();
    userStr = msg.user();
    localeStr = msg.locale();
    japaneseStr = msg.japanese();
    englishStr = msg.english();
    _menu.updateLocale();
  }

  hello() => Intl.message("Hello",name:"hello",args:[]);

  MainMenu _menu;
}

公式のドキュメントを読む限りIntl.message関数は上記の"hello() => Intl.Message(...)"のように関数にラップして使うんだけど、どうもその関数名(今回で言えばhello)と文字列のID("hello")は一致していないとあとのスクリプトが上手く動かないため注意が必要。

extract_to_arb.dartによる吸い出し

Intl.message関数で翻訳したい文字列をコンポーネント内に記述したあとは、そのソースコードをextract_to_arb.dartというスクリプトに渡すことで翻訳用のarbファイルを抽出することができる。

このarb(App Resource Bundle)ファイルは文字列リソースを格納するための独自フォーマットらしく、公式サイトの説明を読むと使用するプログラミング言語に非依存なようだ。

extract_to_arb.dartはintlパッケージに含まれているので、pubのキャッシュディレクトリから持ってくることができる。Windowsの場合pubのキャッシュは"C:\Users\ユーザー名\AppData\Roaming\Pub\Cache\hosted\pub.dartlang.org"の下にあるので、そこの"intl-*.*.*\bin"ディレクトリ内から持ってくることができる(ちなみに他のOSはどこにキャッシュが置いてあるか知らない)。

extract_to_arb.dartは下記のように実行する。

dart --package-root=packages tool/extract_to_arb.dart --output-dir=lib/components/messages_all lib/components/main_app.dart lib/components/main_menu.dart lib/components/common_messages.dart

これでlib/components/messages_allの下にintl_messages.arbというファイルが生成される。intl_messages.arbの中身は下記の通り。Intl.message関数で指定したことをそのまま持ってきたような内容だ。

{
  "hello": "Hello",
  "@hello": {
    "type": "text",
    "placeholders": {
      
    }
  },
  "setting": "Setting",
  "@setting": {
    "type": "text",
    "placeholders": {
      
    }
  }
  ...

翻訳作業

ここに来てようやく翻訳作業に入れる。先ほどのintl_messages.arbが出力されているlib/components/messages_allの中にtranslation_jp.arbというファイルを作り、そのファイルに翻訳された単語を書いていく。書き方は下記の通り。

{
  "_locale":"ja",
  "user":"ユーザー",
  "hello":"こんにちは",
  "setting":"設定",
  "locale":"ロケール",
  "japanese":"日本語",
  "english":"英語"
}

最初にロケールを指定して、あとは単語のIDとロケールに応じた翻訳を記載していく形だ。このarbファイルは翻訳したい言語に応じて増やすことができるみたいなんだけど、今回は日本語のみにしてる。

generate_from_arb.dart

翻訳作業が終わったら、次にgenerate_from_arb.dartを使って翻訳用のクラスを生成する。generate_from_arb.dartはextract_to_arb.dartと同じようにintlパッケージに含まれているのでそこから持ってくることができる。

generate_from_arb.dartは下記のように実行する。

dart --package-root=packages tool/generate_from_arb.dart --output-dir=lib/components/messages_all lib/components/main_app.dart lib/components/main_menu.dart lib/components/common_messages.dart lib/components/messages_all/translation_jp.arb

これを実行すると今度はmessages_all.dartとmessages_ja.dartというファイルが生成される。このクラスの中身に関しては何も知らなくても使えるのでここでは特に紹介しない。ちなみに今回のサンプルでは先ほどのextract_to_arb.dartとgenerate_from_arb.dartを一発で実行するPythonスクリプトtranslate.pyを用意してるので、それを実行すれば比較的楽に翻訳できる。

polymerによるバインディング

翻訳用のクラスが生成されたので、ついにUIに翻訳した文字列が表示できる。最初のmain-appに対応するhtmlを用意し、生成したクラスをimportして初期化処理を書いた後、翻訳した文字列とバインドすればおしまいだ。今回はhtmlを下記のようにした。

<link rel="import" href="../../../packages/polymer/polymer.html">
<link rel="import" href="main_menu.html">
<polymer-element name="main-app">
  <template>
    <link rel="stylesheet" href="//netdna.bootstrapcdn.com/bootstrap/3.1.1/css/bootstrap.min.css" >
    <main-menu id="mainMenu" ></main-menu>
    <div class="container">
      <h2>{{helloStr}} {{userStr}}</h2>
      <label>{{localeStr}}</label>
      <select selectedIndex={{selectedLocale}}>
        <option value="jp">{{japaneseStr}}</option>
        <option value="en_US">{{englishStr}}</option>
      </select>
    </div>
  </template>
  <script type="application/dart" src="main_app.dart"></script>
</polymer-element>

初期化はintlライブラリと生成されたmessages_allライブラリをインポートしてからinitializeMessages関数を呼ぶだけ。initializeMessages関数を呼ぶときは有効なロケール(今回だと"ja"かデフォルトの"en_US")を指定する必要がある点に注意。

import 'package:intl/intl.dart';
import 'package:polymer_intl_sample/components/messages_all/messages_all.dart';
  ...
  void currentLocaleChanged(String old) {
    try {
      initializeMessages(currentLocale).then((succeed) => _updateLocale(currentLocale));
    } catch (e) {
      Logger.root.warning("Language $currentLocale is not supported. Defaulting to en_US");
      _updateLocale("en_US");
    }
  }
  ...  
  void _updateLocale(String locale) {
    Intl.defaultLocale = locale;
    helloStr = hello();
    userStr = msg.user();
    localeStr = msg.locale();
    japaneseStr = msg.japanese();
    englishStr = msg.english();
    _menu.updateLocale();
  }

あとは上記のupdateLocale関数のようにIntl.defaultLocaleを変更してからIntl.messageで対応する文字列を取り出して、UIとバインドした文字列を更新してやれば動的に言語を変えることができる。

複数コンポーネントで翻訳を共有したい場合

複数のコンポーネントで文字列を共有したい場合というのは良くあって、今回のサンプルではそれの例も示している。やり方は単純で、Intl.message関数のみを含むlibraryを作ってそれを各コンポーネントで共有する、というものだ。今回でいえばcommon_messages.dartがその共有されるライブラリとなる。

library common_messages;

import 'package:intl/intl.dart';

user() => Intl.message("User",name:"user",args:[]);
locale() => Intl.message("Locale",name:"locale",args:[]);
japanese() => Intl.message("Japanese",name:"japanese",args:[]);
english() => Intl.message("English",name:"english",args:[]);

これを他のコンポーネントと同じようにextract_to_arb.dartとgenerate_from_arb.dartに食わせて、あとはさきほどのupdateLocale関数のようにライブラリの関数を呼び出してやればいい。

import 'package:polymer_intl_sample/components/common_messages.dart' as msg;
  ...
  void _updateLocale(String locale) {
    Intl.defaultLocale = locale;
    helloStr = hello();
    userStr = msg.user();
    localeStr = msg.locale();
    japaneseStr = msg.japanese();
    englishStr = msg.english();
    _menu.updateLocale();
  }

なおこれは公式に用意されている方法ではなく、なんとなくやってみたら出来たものなのでそのうち上手くいかなくなるかも。

感想

長々と説明してきたようにDartでの国際化は手順が多くて少し複雑だ。実際、Issueとしてintlパッケージが使いづらいから何とかしてくれ、というのが提出されていたこともある。

まだそんなにヘビーに使ってるわけではないのでメリット・デメリットを示せるわけではないけど、ある程度慣れてしまえばそんなに大変では無い気がする。それより混沌としがちな国際化方法をきっちりライブラリとして用意してくれている点はありがたいと思う。

2014年5月26日月曜日

Using Bootstrap dropdown from Dart

自分で何かウェブサイトをでっち上げる場合、最近はもっぱらBootstrapを使うことが増えてる。普段はCSSで出来る範囲しかやらないんだけど、今回dropdownも含めてDartからやろうとして少しひっかかったのでここにメモを残しておく。

Bootstrap

Bootstrapは使うだけでなんとなくそれっぽいウェブサイトがデザインできる素敵CSSとJavascriptのライブラリで最近はなにかと良く使ってる。

ただBootstrapはDartと組み合わせようとすると少し面倒なことになってしまう。CSSだけを使ってる分にはいいけど、dropdownみたいに何らかのJavascriptが走るようなのは途端にうまく動かなくなるからだ。というか実際色々試してみたところ動かなかった。

たとえばBootstrapのdropdownに関するドキュメントではdata-toggleにdropdownって書けばいいよーと書いてあるけど、polymer.dart使って定義したWeb Component内でそれをやっても動かない。jQueryとdart:jsを駆使してむりくりJavascript側のdropdown関数を呼んでみても何か動かない。

しょうがないからBootjackとか使ってみるか…? いやでも最新のBootstrap使えないんじゃちょっとイケてないしな…という感じで困って色々探したところ、公式のDart API ReferenceってDartで書かれてるけどdropdown使ってる(Optionsのところクリックするとdropdownする)じゃん、と気づいた。

それでソースをちらっと見たところDart API ReferenceもまさにBootstrap使ってて完全に渡りに船だった。

Javascriptは不要

結論から言うと、dropdownでもjavascriptを使う必要はなかった。Dart API Referenceのソースはここで見られて、dropdownに関する部分を転載すると以下のようになる。

  <li class="dropdown">
    <a class="dropdown-toggle" on-click="{{toggleOptionsMenu}}">
      Options <b class="caret"></b>
    </a>
    <ul class="dropdown-menu">
      <li class="visible-lg"><a on-click="{{togglePanel}}">
        {{showOrHideLibraries}} Libraries
      </a></li>
      <li class="visible-lg"><a on-click="{{toggleMinimap}}">
        {{showOrHideMinimap}} Minimap
      </a></li>
      <li><a on-click="{{toggleInherited}}">
        {{showOrHideInherited}} Inherited
      </a></li>
      <li><a on-click="{{toggleObjectMembers}}">
        {{showOrHideObjectMembers}} Object Members
      </a></li>
      <li><a on-click="{{togglePkg}}">
        {{showOrHidePackages}} Packages
      </a></li>
    </ul>
  </li>

上記に対応するコードが次の部分。

  void enteredView() {
    super.enteredView();
    ...
    registerObserver('onclick',
        onClick.listen(hideOptionsMenuWhenClickedOutside));

    ...
  }

  void toggleOptionsMenu(MouseEvent event, detail, target) {
    var list = shadowRoot.querySelector(".dropdown-menu").parent;
    if (list.classes.contains("open")) {
      list.classes.remove("open");
    } else {
      _openedAt = event.timeStamp;
      list.classes.add("open");
    }
  }

  void hideOptionsMenuWhenClickedOutside(MouseEvent e) {
    if (_openedAt != null && _openedAt == e.timeStamp) {
      _openedAt == null;
      return;
    }
    hideOptionsMenu();
  }

  void hideOptionsMenu() {
    var list = shadowRoot.querySelector(".dropdown-menu").parent;
    list.classes.remove("open");
  }
  

内容としてはdropdownクラスなliに対してopenっていうクラスを付加してやればdropdownするし、openを取り除けば閉じる、っていう極めて単純なものだった。

よりPolymerらしく

上のコードをそのまま流用しても使えるんだけど、せっかくなのでよりPolymerらしくしたのが次のバージョン。自分用に書き換えてるのでさっきのとは少し内容が違うけど、雰囲気は大体一緒。

  <li class="{{ {'dropdown':true, 'open':userDropdownOpen} }}">
    <a class="dropdown-toggle" on-click="{{toggleUserDropdown}}" >
    <span class="glyphicon glyphicon-user"></span> {{authState.user.email}} <b class="caret"></b>
    </a>
    <ul class="dropdown-menu">
      <li><a href="setting/"><span class="glyphicon glyphicon-wrench"></span> {{setting}}</a></li>
      <li class="divider"></li>
      <li><a href="{{authState.url}}"><span class="glyphicon glyphicon-log-out"></span> {{logout}}</a></li>
    </ul>
  </li>

どの辺がPolymerっぽいかというとclassのenable/disableをコードからいじるんでなくバインディングで解決してるところ。このhtmlで言えばopenクラスの有効・無効がuserDropdownOpenフラグにバインドされているので、あとはフラグの値を書き換えるだけで自動的にopenが付与される。

なお、上のhtmlに対応するコードがこちら。

  @observable bool userDropdownOpen = false;

  @override
  void enteredView() {
    super.enteredView();

    // Apply style from parent in order to use bootstrap
    shadowRoot.applyAuthorStyles = true;

    // Register to the root window so we can receive the onClick
    // even if it was clicked outside of this component
    window.onClick.listen((e) => hideOptionsMenu(e.timeStamp));
  }

  void toggleUserDropdown(MouseEvent e, var detail, Node target) {
    if (userDropdownOpen) {
      userDropdownOpen = false;
    } else {
      _dropdownOpenTime = e.timeStamp;
      userDropdownOpen = true;
    }
  }

  void hideOptionsMenu(int timestamp) {
    if (_dropdownOpenTime != null && _dropdownOpenTime == timestamp) {
      _dropdownOpenTime == null;
      return;
    }
    userDropdownOpen = false;
  }

元のコードではregisterObserverでonclickイベントに接続していたんだけど、こっちではwindowのonClickに直接つないでる。

こういう風にした理由はregisterObserverだと同一Component内のonclickしか拾えなかったから。たとえば全く別のcomponentの上でマウスクリックした場合、onclickが検知できなくてdropdownが閉じない現象が発生していた。そのため、全componentの親に相当するwindowのonClickに直接つないでどこをクリックしても適切にdropdownが閉じられるようにした。

2014年4月6日日曜日

Applying <dialog> polyfill from Dart

<dialog>要素をDartから使おうとしたときにちょっとハマったのでここでまとめておく。

<dialog>

dialog要素はhtml要素の一つで、名前の通り何らかのダイアログを示すものだ。今はclass定義やらで擬似的にダイアログを表示するのが主流(例:Bootstrap)ななか、汎用的に使われてるしいっそ専用のタグを用意すればいいんじゃない(ついでにブラウザ側でダイアログを特別扱いできればいろんな問題も解決するし)、という流れででてきたのがdialog。実際に動作する様子はここのデモサイトで見られる。

現時点でdialogを実装しているのはChromeのみで、そのChromeも明示的にフラグ(enable-experimental-web-platform-features)を有効にしないと使うことができない。ちなみにcaniuse.comで調べてみたらマイナーすぎて登録すらされてなかった

<dialog> polyfill from Dart

そんな状況のdialog要素をまともに使おうとすると当然他のブラウザ対応が問題になってくる。Googleの人もそのへんはちゃんとわかっていて、他ブラウザにも擬似的にdialogを表示できるようにするpolyfillを用意してある

このpolyfillをDartから適用しようとしたときに少しハマって、色々試した末なんとか上手く行ったのでここにサンプルを置いておく。

dialog_sample

Github上に置いたサンプルプロジェクトはここで、実際にDartiumから動作してる様子が下のキャプチャ。dart2jsでJavascriptに変換してFirefoxからも動くことは確認している。

中身は単にダイアログを出してるだけのなんてこと無いサンプルで、主要なコンポーネントのうちコード的に重要なのは次の部分。

  @override
  void enteredView() {
    super.enteredView();

    // Apply style from parent in order to use bootstrap
    shadowRoot.applyAuthorStyles = true;

    _dialog = shadowRoot.querySelector("#sampleDialog");
    if (_dialog is UnknownElement) {
      print("<dialog> not supported on this browser. Applying polyfill");
      var pf = context['dialogPolyfill'];
      pf.callMethod('registerDialog',[_dialog]);
    }
    _dialog.on["close"].listen((data) {
      print("Dialog closed");
    });
    _dialog.on["cancel"].listen((data) {
      print("Dialog cancelled");
    });
  }

  void openDialog(Event e, var detail, Node target) {
    if (_dialog is DialogElement) {
      var dlg = _dialog as DialogElement;
      dlg.showModal();
    } else {
      // Native dialog not available.
      // Call polyfilled function
      var obj = new JsObject.fromBrowserObject(_dialog);
      obj.callMethod("showModal",[]);
    }
  }

Dart自体にはDialogElementが既に定義されていて直接DialogのAPIが叩けるんだけど、ブラウザ側がDialogに対応していない場合はUnknownElementとして返ってくるようになっている(少なくとも今は)。なので最初にDialogElementが取得できるかを試すことでDialog対応しているかを確認している。

もしDialogに対応してる場合はそのまま使えばいいし、対応してない場合はdialogPolyfill.registerDialog関数を適用してdialogのpolyfillをすればいい(必要なスクリプトやスタイルは事前にindex.htmlで読み込み済みという前提)。polyfill自体はdialogPolyfill.registerDialogをdart:jsライブラリ経由で呼べばできる。あとdialog専用のcancel/closeイベントハンドラもいまあるAPIからそのままハンドラを登録できる。

最後にちょっとハマったのがpolyfillされた版のshowModal関数を呼ぶところ。UnknownElementとして見えている要素に対してshowModal関数を呼ぼうとすると「そんな関数知らない」と蹴られてしまう。これはpolyfillによってJavascript側へ追加された関数やらはDartからは見えないことに起因する。なので、やはりこちらもdart:jsライブラリのJsObject.fromBrowserObjectでJavascript側のオブジェクトへ取得し、直接showModal関数を呼び出すことで解決している。

2014年3月11日火曜日

"listChanges" Event Stream of ObservableList

DartのobserveパッケージにあるObservableListクラスにはlistChangesというEvent Streamがあって、このStreamはObservableListに加えられた変化を外部に通知する役割を担ってる。このlistChangesは仕組みがちょっとわかりづらいのでここにまとめておく。

listChanges

listChangesのドキュメントに記載されているコードを下記に転載する。

var model = new ObservableList.from(['a', 'b']);
model.listChanges.listen((records) => records.forEach(print));
model.removeAt(1);
model.insertAll(0, ['c', 'd', 'e']);
model.removeRange(1, 3);
model.insert(1, 'f');

で、これを実行すると次のように表示される。

#<ListChangeRecord index: 0, removed: [], addedCount: 2>
#<ListChangeRecord index: 3, removed: [b], addedCount: 0>

この出力、見るとちょっと良くわからない点が多い。例えばコード上ではもっとremoveしてるはずなのにbしかremoveされたことになってないところとか。

最初と最後の差分

実はさっきの出力はリストの最初の状態(['a','b'])と最後の状態(['c','f','a'])の差分を一回のイベント通知で知らせていて、ListChangeRecord間の状態を書き足すとと次のようになる。

['a','b']
#<ListChangeRecord index: 0, removed: [], addedCount: 2>
['??','??','a','b']
#<ListChangeRecord index: 3, removed: [b], addedCount: 0>
['??','??','a']

この'??'に当てはまるのが何かは、イベント通知で送られてくる情報と現在のリストの状態から逆算して実はcとfだった、ということがわかるようになっている。そのほかに追加してすぐ消したdとeかはどうなるかというと、このデフォルトの動作だとそもそも追加されたことすら(listChangesからでは)検知できない。

非同期なバースト型イベント

更に注意しなきゃいけないのは、このイベントが非同期に、しかもバーストでまとめて送られてくるという点にある。例えば元のコードを下記のように書き換えてみる。

var model = new ObservableList.from(['a', 'b']);
model.listChanges.listen((records) {
  print("got ${records.length} changes");
  for (var r in records) {
    print(r);
    print(r.object);
  }
});
model.removeAt(1);
model.insertAll(0, ['c', 'd', 'e']);
model.removeRange(1, 3);
model.insert(1, 'f');
print("end state: $model");

これの出力は次のようになる。

end state: [c, f, a]
got 2 changes
#<ListChangeRecord index: 0, removed: [], addedCount: 2>
[c, f, a]
#<ListChangeRecord index: 3, removed: [b], addedCount: 0>
[c, f, a]

最初にend state...って表示されているところを見ると、リストに対する操作の都度ではなく全部操作が終わってからlistChangesへイベントが一回にまとめて送られてきてるのがわかる。

scheduleMicrotask()

この辺の仕組みがどうなってるかを調べるためにobservable_list.dartのソースを直接見たところ、内部ではscheduleMicrotask()という関数を使っていた。このscheduleMicrotaskの説明は長くなるので公式の立派な文書に任せるとして、ObservableList的には次のような動作となっている。

  1. addやremoveのようにObservableListの状態が変わる関数を呼ぶと、その変更を通知する非同期タスク(microtask)がエンキューされる
  2. microtaskが実行されるまで以降のaddやremoveで追加・削除された項目は全て「変更された項目リスト」に追加されていく
  3. microtaskが実行される段階で「変更された項目リスト」は最小化される。例えばaddで追加された項目が直後にremoveされた場合は相殺されてそもそも最初から追加されなかったことにされる
  4. 最小化された「変更された項目リスト」はlistChangesへ通知されて、ObservableList内では消える

同期的な通知

ちなみに同期的に通知する方法ももちろん用意されていて、それをやっているのが次のコード。

var model = new ObservableList.from(['a', 'b']);
model.listChanges.listen((records) {
  print("got ${records.length} changes");
  for (var r in records) {
    print(r);
    print(r.object);
  }
});
model.removeAt(1);
model.deliverListChanges();
model.insertAll(0, ['c', 'd', 'e']);
model.deliverListChanges();
model.removeRange(1, 3);
model.deliverListChanges();
model.insert(1, 'f');
model.deliverListChanges();
print("end state: $model");

やっているのはリストへの操作の度にdeliverListChangesという通知用の関数を呼んでいること。これを実行すると次のように表示される。これは本当に手順通りの結果なのでわかりやすいと思う。

got 1 changes
#<ListChangeRecord index: 1, removed: [b], addedCount: 0>
[a]
got 1 changes
#<ListChangeRecord index: 0, removed: [], addedCount: 3>
[c, d, e, a]
got 1 changes
#<ListChangeRecord index: 1, removed: [d, e], addedCount: 0>
[c, a]
got 1 changes
#<ListChangeRecord index: 1, removed: [], addedCount: 1>
[c, f, a]
end state: [c, f, a]

感想

最初このlistChangesのドキュメントを読んだときは全然理解できなくて、サンプルを書き換えていてやっと理解できた。

ただこのようにObservableListが非同期かつバーストなイベント通知をしている理由は推測できて、おそらく何らかのリスト操作の度にイベントが飛ぶのはパフォーマンス的に避けたかった、ということなのだと思う。 例えば数万の要素をObservableListに追加するようなループを書いたとして、要素が追加されるたびにイベントが飛んでたらさすがにパフォーマンスへの影響は大きいはず。なので、可能な限り少ないイベントで大量の差分を通知できる今の方式になっているのだと考えられる。

改めて考えると多分因果が逆で、Streamていう"非同期で発行されることが前提な通知インフラ"でObservableListの変化を通知するには前記のような仕組みにするのが最適だった、というのが本当のところかもしれない。それが結果的に"変化の度に通知されてしまうよパフォーマンス問題"も解消してる、と。

ちなみにDartでのObservableListに相当するWPFのObservableCollectionはまさに操作の度にイベントを通知するようになっている。これはこれでわかりやすくていいと思うんだけど、ただ今度は大量に追加するときのパフォーマンス問題が出てくるので、一度に大量の要素を追加する可能性の高いチャート用のライブラリとかは一時的にイベント通知を切る仕組みが用意されていたりする。知る限りではDynamicDataDisplayのObservableDataSourceとか。

2014年2月1日土曜日

Unordered list with a custom layout algorithm

Polymer.dartを使って作ってるウェブアプリで次のようなカスタム<ul>が必要になった。

  • 通常の<ul>と同じように<li>を子要素として追加できる
  • <li>のレンダリングされたときのサイズに応じてそれぞれの表示位置を決定する

例えば1280x720ピクセルの矩形内にそれぞれのli要素がオーバーラップしないよう自動的に配置してくれるようなul、てところ。それで色々と調べたり実験した結果なんとかできたので、ここでサンプルを公開しておく。

何をつくったか

今回作ったのは、追加されたli要素が階段上に表示されるよう配置するweb component。各要素のレンダリングサイズに応じて位置を決めているのでキレイな階段になってるはず(でもAndroid版Chromeで試したら表示崩れてた…)。

要素を追加したり折り返しの幅を変えたりといったこともできる。

コードそのものはGithub上のリポジトリに置いてある。今回は更にdart2jsを試す実験も兼ねてjavascriptに変換してGithub Pagesにおいてみたので、実際に動かしてみることもできる。現状FirefoxとChromeで動作確認した。

MutationObsever

コードに関してはGithubを直接みてもらうとして、このサンプルで重要な役割を果たしたMutationObserverのあたりだけ少し解説しておく。

MutationObserverを使うと指定されたElementにDOM的な変更が加えられたときにイベントとして受け取ることができる。今回は子要素が追加・削除されたかのみを監視してるけど、設定次第では別のイベントも受け取れるらしい。

今回なぜMutationObserverを使ったかというと、li要素をレンダリングサイズに応じて配置する必要があったから。調べたかぎりレンダリングサイズが決定するのは実際にElementがDOMに挿入されたあとなので、サイズが決定した段階で適切な位置へ移動するために使ってる。

今回はulをcustom-listというweb componentでラップしてて、そのcustom-listが表示された(enteredViewが呼ばれた)タイミングで直下にいるulをMutationObserverへ登録している。

    @override
    void enteredView() {
        super.enteredView();
        _root = shadowRoot.querySelector("#root");

        // observe change of child elements for a 2 pass layout approach
        var mo = new MutationObserver(onChildMutation);
        mo.observe(_root, childList:true);
        _observer = mo;
    }

上のコードでMutationObserverのコールバックとしてonChildMutationという関数を登録してて、その中身は以下の通り。

    /// Called when a mutation occurs to the target element
    void onChildMutation(List<MutationRecord> changes, MutationObserver observer) {
        // layout children depending on its rendering size

        int itemCount = 0;
        int bounceBackDelta = bouncebacknum - 1;
        int nextBounceBack = bounceBackDelta;
        int xOffset = 0;
        int yOffset = 0;
        bool offsetRight = true;
        for (var record in changes) {
            var added = record.addedNodes;
            var removed = record.removedNodes;
            for (var a in added) {
                if (!(a.nodeType == Node.ELEMENT_NODE && a is CustomListItem)) {
                    // ignore nodes expect for the one we want
                    continue;
                }
                var elem = a as CustomListItem;
                var width = elem.clientWidth;
                var height = elem.clientHeight;
                // print("${width}x${height}");

                var unit = "px";
                if (elem.item.entered) {
                    // This item is already displayed
                    // so place the view at the same position

                    var l = "${elem.item.rect.left}${unit}";
                    var t = "${elem.item.rect.top}${unit}";
                    elem.style.left = l;
                    elem.style.top = t;
                } else {
                    // This item is a new one.
                    //  Layout the remark at a sufficient position
                    Rectangle<int> posRect;
                    if (offsetRight) {
                        posRect = new Rectangle<int>(xOffset,yOffset,width, height);
                    } else {
                        posRect = new Rectangle<int>(xOffset-width, yOffset, width, height);
                    }
                    var l = "${posRect.left}${unit}";
                    var t = "${posRect.top}${unit}";
                    elem.style.left = l;
                    elem.style.top = t;
                    elem.item.rect = posRect;
                    applyAnimation(elem, "fadein", 250);
                    elem.item.entered = true;
                }

                // update offset depending on direction
                if (offsetRight) {
                    xOffset += width;
                } else {
                    xOffset -= width;
                }

                // update bounceback state
                if (offsetRight && itemCount == nextBounceBack) {
                    offsetRight = false;
                    xOffset -= width;
                    nextBounceBack += bounceBackDelta;
                } else if (!offsetRight && itemCount == nextBounceBack) {
                    offsetRight = true;
                    xOffset += width;
                    nextBounceBack += bounceBackDelta;
                }

                yOffset += height;
                itemCount++;
            }
        }
    }

    /// Apply specified animation to the given element
    static void applyAnimation(Element element, String name, int msecDuration) {
        element.style.animationName = name;
        element.style.animationDuration = "${msecDuration}ms";
        element.style.animationTimingFunction = "linear";
        element.style.animationFillMode = "forwards";
    }

onChildMutationでやってることは大体次のこと。

  1. 追加された子要素のうち、新規に追加されたCustomListItemのサイズを取得して設定に応じて階段状に配置する。ついでにアニメーションも適用する。
  2. 既に表示されているCustomListItemが来た場合はそのまま現在の位置に表示する

注意しなきゃいけないのは既に表示されている項目と新規に追加された項目の処理をわけるところ。今回試した限りonChildMutationは新しい子要素を一つ追加したときでも全子要素がaddされた要素としてよばれてくるため、既に表示されてる項目の位置が変わらないようenteredというフラグで処理を切り替えている。

加えてなぜか空のTextElementも追加された子要素として送られてくるため、対象のElement以外は弾くようにしないといけない。空のTextElementが来る理由はよくわからない。バグ? Polymer.dartの仕様?

ちなみにcustom-listのhtmlは下の通りで、中身はほぼulをラップしているだけ。

<link rel="import" href="custom-list-item.html">
<polymer-element name="custom-list">
    <template>
        <style>
        ul#root {
            list-style-type: none;
            position: relative;
        }
        li {
            position: absolute;
        }
        @-moz-keyframes fadein {
            0% {opacity: 0;}
            100% {opacity: 1;}
        }
        @-webkit-keyframes fadein {
            0% {opacity: 0;}
            100% {opacity: 1;}
        }

        </style>
        <ul id="root">
            <template repeat="{{item in itemlist}}">
                <li is="custom-list-item" item="{{item}}"></li>
            </template>
        </ul>
    </template>
    <script type="application/dart" src="custom-list.dart"></script>
</polymer-element>

さきほどちらっと出てきたCustomListItem(custom-list-item)は下記のように定義されてる。これもほぼテキストを表示しているだけ。

<polymer-element name="custom-list-item" extends="li">
    <template>
        <style>
        .container {
            padding: 15px;
            background-color: ghostwhite;
            border:solid 1px #dedede;
        }
        .content {
            overflow:hidden;
            padding:5px;
            display:block;
        }
        .text {
            font-family:'Georgia', Times New Roman, Times, serif
        }

        </style>
        <div id="root" class="container">
            <div class="content text">{{item.text}}</div>
        </div>
    </template>
    <script type="application/dart" src="custom-list-item.dart"></script>
</polymer-element>

感想

当初はMutationObserverの存在を知らずにやろうとしてたため、なんか無駄に苦労してた。具体的には下のような流れで詰まってた。

  1. 新しいliを追加するにあたって、その配置を決めるためにレンダリング時のサイズが知りたい!
  2. でもサイズを知るためには一旦liを追加してみるしかない…
  3. 本番環境にliを追加するまえに、仮の環境(visibilityをhiddenにして隠してあるcustom-list)で追加してみてサイズを計ろう!
  4. 仮の環境に追加してもすぐにはDOMに反映されない(おそらくPolymer.dartがそういう動きになってる)から、本番環境に突っ込むタイミングではやっぱりサイズがわからない…

それでどうすればいいかよくわからなくなったので、Stackoverflowで質問してみたところMutationObserverなるものを教えてもらった。結果的にこのMutationObserverのおかげで色々と解決したので、似たようなことをやりたい人のためにサンプルとしてまとめておいた。