vty + StateT で画面のスクロールを実装する

この記事は CAMPHOR- Advent Calendar 2015 4日目の記事です.

こんにちは,@ryota-ka です.今年もアドベントカレンダーの記事がやって参りました.

今回の記事では,ncurses を使ってターミナル上でフルスクリーンアプリケーションを作成し,ユーザーのキー入力を受け取って画面のスクロール機能を実装します.スクロールのオフセットは State モナドで管理することにしましょう.

TL; DR

ncurses と vty について

ターミナル上でフルスクリーンで動作するアプリケーションはよくあります.例を挙げると,tig, sl, twterm, chikubeam などなど.こういった挙動を実現するために,curses および ncurses といったライブラリが存在します.

この ncurses を Haskell から扱うラッパーとして,vty というライブラリがあります.ncurses 自身の API は本当に貧弱で,描画位置のカーソルを所定の場所に動かしたり,文字色などを指定したあとに文字列を表示して,またその属性を手動で戻したり,なにかウィンドウ紛いのものを表示するといった程度のことしかできず,例えばまかり間違って Ruby なんかから Curses を叩くと,プリミティヴな操作の連続で,まるで C 言語を書いているかのような気分になります*1.しかしながら,この vty というライブラリは,画面の描画の処理が非常に高いレイヤーで抽象化されており,泥臭い単純作業をすることなく扱えるようになっています.

vty の使い方について詳らかに説明することは今回の記事の主題ではないので,詳細は割愛しますが,基本的には,Image という画面の断片を縦横に結合していき,最後に Picture という一枚絵に変換し,これをターミナル上に描画する,という手順になっています.

State と IO を組み合わせる - モナド変換子

キー入力の受け取りや,画面への出力を実現するためには,言わずもがな IO モナドの文脈の中で作業をする必要があります.しかしながら,冒頭でも述べたとおり,スクロールのオフセットは State モナドで管理したいので,複数の文脈を同時に扱う必要がありますが,これを実現するのがモナド変換子です.

モナド変換子については,個人的にはこちらの記事がわかりやすかったので,ご参照下さい.

モナドとモナド変換子のイメージを描いてみた - melpon日記 - HaskellもC++もまともに扱えないへたれのページ

今回の場合,StateT というモナドの中に IO モナドを入れた状態でプログラミングを進めていきます.上記の記事の説明に従えば,IO モナドの上に StateT のレイヤーが載っているというイメージでしょうか.StateT は transformers というライブラリによって提供されています.

実際に書いてみよう

今回のコードでは,インポートする関数やコンストラクタをすべて明示しておきました.学習の際に参考にしていただければと思います.

import Control.Monad (forever)
import Control.Monad.Trans.Class (lift)
import Control.Monad.Trans.State (
    evalStateT
  , get
  , StateT
  , put
  )
import Graphics.Vty (
    defAttr
  , Event ( EvKey )
  , Key ( KChar )
  , mkVty
  , Modifier ( MCtrl )
  , nextEvent
  , picForImage
  , Picture
  , shutdown
  , standardIOConfig
  , string
  , update
  )
import System.Exit (exitSuccess)

numberOfLines :: Int
numberOfLines = 100

type Offset = Int

scroll :: Int -> StateT Offset IO ()
scroll n = (+ n) <$> get >>= put . min (numberOfLines - 1) . max 0

picForCurrentOffset :: StateT Offset IO Picture
picForCurrentOffset = do
    offset <- get
    return . picForImage . mconcat . map imageForInt . drop offset $ [0..(numberOfLines - 1)]
    where imageForInt = string defAttr . show

main :: IO ()
main = do
    vty <- standardIOConfig >>= mkVty

    flip evalStateT 0 . forever $ do
        picForCurrentOffset >>= lift . update vty

        e <- lift $ nextEvent vty
        case e of
            EvKey (KChar 'c') [MCtrl] -> lift $ shutdown vty >> exitSuccess
            EvKey (KChar 'j') []      -> scroll 1
            EvKey (KChar 'k') []      -> scroll (-1)
            _                         -> return ()

まずは main 関数から見ていきましょう.

main :: IO ()
main = do  -- この中は IO の文脈
    vty <- standardIOConfig >>= mkVty

    flip evalStateT 0 . forever $ do  -- この中は StateT Offset IO の文脈
        picForCurrentOffset >>= lift . update vty

        e <- lift $ nextEvent vty
        case e of
            EvKey (KChar 'c') [MCtrl] -> lift $ shutdown vty >> exitSuccess
            EvKey (KChar 'j') []      -> scroll 1
            EvKey (KChar 'k') []      -> scroll (-1)
            _                         -> return ()

たったこれだけです!上から順に見ていきます.

    vty <- standardIOConfig >>= mkVty

まずは mkVty 関数を使って,画面操作の対象となる Vty 型の値を作成し,vty をこの値に束縛します.以降画面の操作はこの vty を通じて行います.

    flip evalStateT 0 . forever $ do

evalStateT に,オフセットの初期値である 0 を渡して,do 以下に forever を適用した関数を評価します.この do の中では IO ではなく StateT Offset IO の文脈で実行されます(Offset というのは上で宣言している通り Intエイリアスです).さて,それではこの do 以下の内容を見ていきます.

        picForCurrentOffset >>= lift . update vty

picForCurrentOffset は上の方で定義していますが,

picForCurrentOffset :: StateT Offset IO Picture
picForCurrentOffset = do
    offset <- get
    return . picForImage . mconcat . map imageForInt . drop offset $ [0..(numberOfLines - 1)]
    where imageForInt = string defAttr . show

といった具合です.string :: Attr -> String -> Image は,属性(e.g. 文字色,背景色,太字,下線)と文字列を受け取って Image を返す関数で,default の attribute を渡して show と合成することで,Int を受け取って Image を返す imageForInt 関数を定義しています.あとはおおよそ書いているとおりで,get で現在のオフセットを取得し,現在の状態に対応する Picture を返すようになっています.Image クラスは Monoid 型クラスのインスタンスになっており,

として定義されているので*2mconcat で気持ちよく Image どうしを縦方向に結合することができます.別に mconcat でなくて vertCat でも構いません.

さてさて,話を main 関数に戻しましょう.

        picForCurrentOffset >>= lift . update vty

update の型は Vty -> Picture -> IO () なのですが,我々は今や IO の文脈ではなく, StateT Offset IO という,ひとつ上のレイヤーでプログラミングを行っています.なので,こいつを上のレイヤーに持ち上げてやる必要があるのですが,これを行ってくれるのが lift です.

        e <- lift $ nextEvent vty
        case e of
            EvKey (KChar 'c') [MCtrl] -> lift $ shutdown vty >> exitSuccess
            EvKey (KChar 'j') []      -> scroll 1
            EvKey (KChar 'k') []      -> scroll (-1)
            _                         -> return ()

nextEvent vty で,キー入力や画面リサイズなどのイベントを受け取り,case 以下で場合分けをしています.<C-c> が入力された場合には vty を安全にシャットダウンし,プログラムを終了します.j / k キーが入力された場合には,画面をスクロールします.それ以外のイベントの場合は何も行いません.

scroll :: Int -> StateT Offset IO ()
scroll n = (+ n) <$> get >>= put . min (numberOfLines - 1) . max 0

scroll は非常に単純で,Int の値を受け取ると,表示行数からはみ出さないように調整して,オフセットの値を書き換えます.

このようにして do 以下が延々と実行されるので,現在の状態を書き換えつつ,画面の再描画を行いながら,どんどんとスクロールしていくことができます.やったね!

screenshot

まとめ

このように,モナド変換子を用いて複数モナドを同時に扱うことができるようになると,実現できることの幅が非常に拡がります.というかアプリケーション開発には必須だと思います.また,vty も非常に上手く抽象化されていて使いやすいライブラリとなっており,随分と簡単に画面の描画を行えるようになっています.

さてさて,今回書いたコードですが,GitHub に置いておりますので,git clone して cabal install して cabal run すれば実行することができます.必要であれば参考にしてみてください.

ryota-ka/scroller · GitHub

CAMPHOR- Advent Calendar 2015,明日5日目は,今年 Makuake にて募ったクラウドファンディングについて @ohmuraken が書いてくれるそうです.お楽しみに!

謝辞

今回の記事を執筆するにあたり,普段から Haskell 周りのメンタリングを親身にしてくださる @lotz84 氏の cli-rss-reader を多分に参考にさせていただきました.いつもお世話になり,ありがとうございます!

参考リンク

*1:ちなみに僕はゆとりなので C とか書けません

*2:ソースを読むと本当は EmptyImage というコンストラクタですが