Haskell メモ : Threepenny-gui とは その2

24 Days of Hackage: threepenny-gui

この記事は,24 Days of Hackage: threepenny-gui の抄訳です。


FRPの本を出版した Heinrich Apfelmus 氏Haskell 用の GUI ライブラリ threepenny-gui を発表した。

  • GTK や QT など既存の GUI ライブラリと組み合わせるタイプではなく,ブラウザ用の html ページを作成するタイプ。
  • 汎用のユーザーインターフェースを組み上げてページにしていく。もちろん,インターフェース要素同士の相互作用もあり。
  • websocket を使い,クライアントーサーバ間の緊密なフィードバックループも作成可能。

To Do リストアプリの作成事例

基本となる型

この事例で利用する型は以下の通り:

type Database = Map UserName ToDoList
type UserName = String
type ToDoList = Set String

Database(To-Do項目のデータベース)は, ユーザ名から to-do リストへの Map になっている。内部では,ユーザ名は単なる文字列,to-do リストは文字列のSet(集合)として格納されている。

threepenny-gui の起動

main :: IO()
main = startGUI defaultConfig setup

setup :: Window -> UI ()
setup rootWindow = undefined

startGUI は,HTTP サーバを起動している。ここでは初期設定をそのまま使っているので, 受信ポート番号は 10000 である。setup では,ユーザインタフェース要素によって組み立てられ,ユーザが目にするブラウザページとなる Window を引数にとる。

すべてのクライアントとの接続それぞれに setup が呼び出されるので,通信内容や状態はそれぞれ隔絶されている。従い,何らかの相互通信が必要な場合,共有変数などを使って明示的に共有しておく必要がある。そこで,STM を使ったミュータブル変数を使う。

main :: IO ()
main = do
  database <- atomically $ newTVar (Map.empty)
  startGUI defaultConfig (setup database)

setup :: TVar Database -> Window -> UI ()
setup database rootWindow = do

atomicallyTVarについてはSTMを学ぶと理解できる。魚野注

これで,UI を組み立てる準備ができた。まず,どの人のto-doリストを表示するかを決めるのに使うため,顧客のユーザ名の入力をしてもらう。

setup :: TVar Database -> Window -> UI ()
setup database rootWindow = void $ do
  userNameInput <- UI.input
    # set (attr "placeholder") "User name"

  loginButton <- UI.button #+ [ string "Login" ]

  getBody rootWindow #+
    map element [ userNameInput, loginButton ]

まず呼び出しているのが,UI.inputである。これは,新しい<input>要素の作成に使われる。演算子#は,関数と引数の順序を逆にする。

魚野注

(#) :: a -> (a->b) -> b
a # f = f a

ここでは新たに作成したテキスト欄の placeholder 属性を変更していることになる。

ログインボタンを作成するところでは, UI.button を使用している。また,ボタン要素の子要素として,テキスト内容を示す文字列を追加している。

これで,UI 要素の両方を作ることができた。これをrootWindowの UI 要素として追加しておこう。getBodyWindowをとってそのウィンドウに属する要素を返す。

魚野注 hackage での説明より

(#+) :: UI Element -> [UI Element] -> UI Element 

DOM 要素を与えられた要素に子要素として与える

次に,ユーザが"Login"ボタンを押したときに反応することを考える。クリックイベントを観測しなければいけない。

  on UI.click loginButton $ \_ -> do

誰かが"Login"ボタンをクリックしたら,該当するユーザ名をデータベースから探し,存在すれば,そのユーザが作成済みのto-doアイテムを表示する。また,新規項目追加用の入力欄も表記する必要があるだろう。

    userName <- get value userNameInput

    currentItems <- fmap Set.toList $ liftIO $ atomically $ do
      db <- readTVar database
      case Map.lookup userName db of
        Nothing -> do
           writeTVar database (Map.insert userName Set.empty db)
           return Set.empty

        Just items -> return items
  • userNameInput欄の値を受け取り,STMトランザクション処理に入って全てのto-do項目を入手する。
  • 入力されたユーザ名でデータベースを検索し,もし見つかれば格納されているそのユーザのto-do項目を返す。そうでなければ新しいユーザとしてその人を「登録」し,空のto-do項目を返す。

次に,ユーザのto-do項目を表示する部分である。

    let showItem item = UI.li #+ [ string item ]
    toDoContainer <- UI.ul #+ map showItem currentItems

showItemをto-doの項目として一つ一つ処理していき,<ul>コンテナの中に入れていく。新しい項目を追加する際にはこのリストに追加するので,<ul>の要素には名前をつけておかなければならない。

備えるべき機能の最後の駒は,新しいto-do項目を追加する機能だ。このためには,別の<input>要素を利用する。ユーザが,項目が入力された状態の時にリターンキーを押したら,リストにその項目を追加する。

    newItem <- UI.input

    on UI.sendValue newItem $ \input -> do
      liftIO $ atomically $ modifyTVar database $
        Map.adjust (Set.insert input) userName

      set UI.value "" (element newItem)
      element toDoContainer #+ [ showItem input ]

新しく入力された項目が作成されたら,sendValueイベントを待つことになる。このイベントは,クライアントがリターンキーを押した時に作成される。そして,小さなSTMトランザクションを実行し,新しい項目をto-doリストに付け加える。このトランザクションが完了したら,入力欄の記載内容を空にし,to-doリストに新たに付け加えられた項目を追加する。

最後に,全てをUIに組み込む。

    header <- UI.h1 #+ [ string $ userName ++ "'s To-Do List" ]
    set children
      [ header, toDoContainer, newItem ]
      (getBody rootWindow)

もう一度アクセス先(http://localhost:10000)に戻ろう。ユーザ名欄が表示されるはずだ。この欄に入力すれば,そのユーザのto-do項目が表示される。値を変えて同じユーザとして再度ログインしても,項目は残っているはずだ。

50行足らずのコードを書いただけで,立派に機能するアプリケーションが作成できた。完成したコードは,GitHubの現著者のリポジトリに置いてある