vty + StateT で画面のスクロールを実装する
この記事は CAMPHOR- Advent Calendar 2015 4日目の記事です.
こんにちは,@ryota-ka です.今年もアドベントカレンダーの記事がやって参りました.
今回の記事では,ncurses を使ってターミナル上でフルスクリーンアプリケーションを作成し,ユーザーのキー入力を受け取って画面のスクロール機能を実装します.スクロールのオフセットは State モナドで管理することにしましょう.
TL; DR
- Haskell で ncurses を触る際には vty が便利
- 複数のモナドを同時に扱いたい時にはモナド変換子を使おう
- ソース GitHub に上がってます
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
型クラスのインスタンスになっており,
mempty
=emptyImage
mappend
=vertJoin
として定義されているので*2,mconcat
で気持ちよく 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
以下が延々と実行されるので,現在の状態を書き換えつつ,画面の再描画を行いながら,どんどんとスクロールしていくことができます.やったね!
まとめ
このように,モナド変換子を用いて複数のモナドを同時に扱うことができるようになると,実現できることの幅が非常に拡がります.というかアプリケーション開発には必須だと思います.また,vty も非常に上手く抽象化されていて使いやすいライブラリとなっており,随分と簡単に画面の描画を行えるようになっています.
さてさて,今回書いたコードですが,GitHub に置いておりますので,git clone
して cabal install
して cabal run
すれば実行することができます.必要であれば参考にしてみてください.
GitHub - ryota-ka/scroller: screen scrolling with Vty and StateT
CAMPHOR- Advent Calendar 2015,明日5日目は,今年 Makuake にて募ったクラウドファンディングについて @ohmuraken が書いてくれるそうです.お楽しみに!
謝辞
今回の記事を執筆するにあたり,普段から Haskell 周りのメンタリングを親身にしてくださる @lotz84 氏の cli-rss-reader を多分に参考にさせていただきました.いつもお世話になり,ありがとうございます!