uehaj's blog

Grな日々 - GroovyとかGrailsとかElmとかRustとかHaskellとかReactとかFregeとかJavaとか -

Elmでやってみるシリーズ11:お絵描きツール

Elmでやってみるシリーズ11:お絵描きツール

今回は、マウスボタンをクリックすると、半径4の赤いドットが描画され、押下したままドラッグすると線が描けるというもの。以下が実行イメージ。

f:id:uehaj:20140805235544p:plain

しかし今回は、いきなり完成させるのではなく、2ステップでやってみましょう。

ステップ1 マウスカーソルにドットが追随する


とりあえず、マウスカーソルにドットが追随するというもの。

上記を編集しての実行はこちらから。
ソースは以下のとおり。

import Mouse
import Window

-- マウスカーソル位置をElmのcollageの座標に変換する。collageの座標系は中心が原点でx軸は右が大きくy軸は上が大きい。マウスカーソル位置は左上が原点で、x軸は右に大きくy軸は下に大きい。
mousePosition : Int -> Int -> Int -> Int -> (Float, Float)
mousePosition x y w h = (toFloat x-(toFloat w/2), -(toFloat y)+(toFloat h/2))

-- ウィンドウサイズと同じ大きさのcollageの中に次のようなFormを表示: circleで作ったShapeをfilledしたFormをさらにmoveでマウスカーソル位置に移動したもの。
main : Signal Element
main = let disp x y w h = collage w h [move (mousePosition x y w h) <| filled red (circle 4) ]
       in disp <~ Mouse.x ~ Mouse.y ~ Window.width ~ Window.height

解説

これは本質的に、「マウスカーソルのx座標を表示しつづける」プログラム(→編集)

import Mouse

main : Signal Element
main = let disp x = asText x
       in disp <~ Mouse.x    -- この2行は main = asText <~ Mouse.x と等価

と同型のコードです。Signal引数が増えて、あとElementの構成が複雑になっただけ。Elementの構成は、純粋なコード呼び出しと組立ての地道な問題です。

ポイントは「記憶すべき状態がないプログラム」の例だということです。マウスカーソル位置を保持する主体はElmコードの外にあります。Elmコードはシグナルの変化に対して受動的にその場でリアクションだけすればいい。

さらに、暗黙のループがあると想像するのも正しいです。Elmプログラム一般に、mainはシグナルが更新されるたび、実際に何回も呼ばれます*1

ステップ2 軌跡を残す

さてステップ2として、マウスボタンが押下されたとき、画面にドットが残るようにします。
Elmのライブラリには「書いたらその情報がそのまま残るCanvas」という描画命令やデータ型は存在しません。ドット列全体の描画命令を再実行する必要があります。ドット列の情報は、マウスドラック操作によって追加されていくわけですから、プログラムは状態を保持しなくてはいけません。

そして、Elmでプログラムに過去からの状態を保持させる方法は、foldp、これ一択です*2。ドット列の情報は、座標位置x,yのタプルのリストとして保持するようにしましょう。

能書きは置いといて、とりあえず以下はshare-elmで動作中のコード。左ボタンを押してドラッグしてみてください。ソースや編集しての実行はこちらから。

コメントつきソースは以下です。

import Mouse
import Window

-- プログラムへの入力となる「イベント」を表現する代数的データ型Eventの定義。代数的データ型はとりあえずはenumのようなものと思っておけばよろし。
data Event = MouseUp | MouseDown | MouseMove Int Int

-- プログラムが保持するすべての「状態」を表現するレコード型に型名Statusをつける。Haskellでは型シノニムと呼ぶが気にしなくていい。mouseDownはボタンが押されているときTrue。pointsは座標列。
type Status = { mouseDown:Bool, points:[(Int, Int)] }

-- 初期状態(ボタンは押されてない、点は描画されてない)
initialState : Status
initialState = Status False []

-- 現状態stateからeventを受けて次状態を計算する。foldpの第一引数になることを想定。入力であるEventの中身を見て、対応する処理を行い、返り値のStatusを作ってリターンする。
nextState : Event -> Status -> Status
nextState event state = case event of
                          MouseUp -> { state | mouseDown <- False }
                          MouseDown -> { state | mouseDown <- True }
                          MouseMove x y -> if state.mouseDown
                                           then { state | points <- (x,y)::state.points }
                                           else state
-- 入力列をシグナル化。mergeはホモジニアスなリスト、ここでは「EventのSignalのリスト」を要求するので、リストの要素の型をSignal Eventにそろえる。MouseMove、MouseDown, MouseUpは代数的データ型のデータ構成子だが、これは関数のようなものなので、直接リフト(<~)ができる。
inputSignal : Signal Event
inputSignal = merges [ MouseMove <~ Mouse.x ~ Mouse.y
                     , (\b -> if b then MouseDown else MouseUp) <~ Mouse.isDown ]

-- foldpで過去を現在まで折り畳む。っていうか実質的にはイベントのシグナルの更新1回ごとにnextStateが呼ばれるように仕掛ける。この場合、nextStateはイベントハンドラだと思えばよろしい。状態はinitStateから始まり、nextStateの第二引数として、そしてそのnextStateのリターン値として持ち回る形で保持されていく。
currentState : Signal Status
currentState = foldp nextState initialState inputSignal

-- 先行のコードと同じ
mousePosition : Int -> Int -> Int -> Int -> (Float, Float)
mousePosition x y w h = (toFloat x-(toFloat w/2), -(toFloat y)+(toFloat h/2))

-- currentStateが返す「Signal Status」のStatus部分をとりだし、pointsを取り出してプロット。
main : Signal Element
main = let disp state w h =
            collage w h (map (\(x,y) -> move (mousePosition x y w h) <| filled red (circle 4)) state.points )
       in disp <~ currentState ~ Window.width ~ Window.height

気付いたことなど

  • 結構長いが、「currentState = foldp nextState initialState inputSignal」は、ほぼ完全に定型コード。あとはここを起点として呼ばれている、nextState,initialState,inputSignal、そして使われている型Event,Statusをしかるべく定義すれば良い。
  • MVCへの対応付けは以下のとおり。
    • Statusが全てのモデルに相当する。nextStateは全モデルの更新(純粋関数型なので、実際には更新せずに新規にデータを作ってる)を賄う。
    • currentStateが返す、シグナルでラップされたモデル(Status)をビューで表示するのは、main中のdispの役割り
    • コントローラにはInput,Field,Buttonなどの入力部品(今回はない)と、シグナルの依存関係の構築(上ではinputSignal)が対応するであろう。
  • Signal更新のたびに全ドット書いてるはずだが、点が多くなったときの速度は大丈夫だろうか*3。ビューポート指定して部分書き換えとかはできてないし、それがうまくできるかはわからない。
  • UI構築のために、Graphics.Collage,Graphics.Elementまわりの型をひととおりは覚えておく必要がある。最終的にはmainの型であるElementを作らないといけないのだが、以下の流れ:
    • 基本図形(丸とか四角とか多角形とか)
      • Shapeを(rect/circle/..)で作って、filledでFormにして、(move/rotate/..)で変形させて、collageでElementへ
    • 点列から
      • (segment/path)でPathを作って、LineStyleを与えてtraceでFormにして、(同上)
    • 文字列から
      • (plainText/asText)でStringから直接Element作る
      • Textを(toText)でStringから作り、(leftAligned/rightAligned/centered/..)でElement作る
      • Textを(link/monospace/..)で変換してTextを作り、同上
      • Textを(style)でStyleを与えて変換してTextを作り、同上
    • Elementから
      • Element同士をflow (right/down)..や`beside`などで繋げてElementにする。
    • Markdownから
      • [markdown|..|]でElement作る
    • :

関連エントリ


「Elmでやってみる」シリーズのまとめエントリ - uehaj's blog


すごいHaskellたのしく学ぼう!

すごいHaskellたのしく学ぼう!

すごいHaskellたのしく学ぼう!

すごいHaskellたのしく学ぼう!

プログラミングHaskell

プログラミングHaskell

*1:正確に言うと、mainが返すシグナルが保持するリフトされた純粋関数(ここではdisp)が何回も呼ばれる。どのタイミングで呼ばれるか? それは論理的には「すべてのシグナルのうちいずれか一つが更新したとき」なのだが、ある種の最適化のおかげで「常に呼ばれる」わけではなく、引数が変化しなければ引数としてキャッシュされた値が使用され、関数呼び出しはスキップされます。これはElmの関数が純粋だからできることです。純粋関数型万歳!!

*2:Singal.countもあるが、foldpで実現できる

*3:点の「追加」なので、差分更新が賢ければ速いはず。そしてelm-htmlのVirtual DOMはまさにそれをやってるはず。しかし、collageにそれができるか??