Grapefruit電卓

Grapefruitが気になってしょうがないので、おもちゃの電卓を書いてみた。

GrapefruitはFRPライブラリの一つで、定期的なポーリングを必要としないpush型の設計でありながら、push型FRPにありがちな問題をいくつか解消しているのが売りらしい。

  • ループのある回路を正しく扱える
  • 同時に発生した複数のイベントを正しく扱える
  • シグナルを第一級の値として使える*1

説明を読んでいる限りでは、GUIの書き方としてかなり理想に近いように思える。

コード

{-# LANGUAGE Arrows #-}

import Control.Applicative as Applicative
import Control.Arrow       as Arrow
import Control.Monad.State
import Control.Monad.Cont
import Control.Monad.Trans

import Data.Monoid
import Data.Maybe
import Data.Ratio

import FRP.Grapefruit.Signal           as Signal
import FRP.Grapefruit.Signal.Discrete  as DSignal hiding(map)
import FRP.Grapefruit.Signal.Segmented as SSignal
import FRP.Grapefruit.Record           as Record

import Graphics.UI.Grapefruit.Comp        as UIComp
import Graphics.UI.Grapefruit.Item        as UIItem
import Graphics.UI.Grapefruit.Circuit     as UICircuit
import Graphics.UI.Grapefruit.Backend.Std as StdUIBackend
import Graphics.UI.Grapefruit.GTK

import Text.Printf

-- 回路全体
mainCircuit :: (StdUIBackend uiBackend) => UICircuit Window uiBackend era () (DSignal era ())
mainCircuit = proc _ -> do
  X :& Closure ::= closure `With` _  <-
    window `with` boxed Vertical boxContent -< X :& Title ::= pure "toy calc" `With` ()
  returnA -< closure

-- トップレベルウィンドウの内部の回路
boxContent :: (StdUIBackend uiBackend) => UICircuit Widget uiBackend era () ()
boxContent = proc _ -> do
  rec
    X <- just label -< X :& Text ::= txt
    let txt = fmap showState $ SSignal.scan initialState nextState cmd
    cmd <- signalerMatrix mat -< ()
  returnA -< ()
  where
    mat = map (map $ uncurry $ simpleButton) 
      [ [("C", Clear), ("AC", AllClear), ("=", Equal)]
      , [dig 7, dig 8, dig 9, ("/", Op Slash)]
      , [dig 4, dig 5, dig 6, ("*", Op Star)]
      , [dig 1, dig 2, dig 3, ("-", Op Minus)]
      , [dig 0, ("+/-", Sign), (".", Dot), ("+", Op Plus)]
      ]
    dig n = (show n, Digit n)

-- シグナルを生成するUI回路を縦横に並べる
signalerMatrix :: (StdUIBackend backend) => [[UICircuit Widget backend era () (DSignal era a)]] -> UICircuit Widget backend era () (DSignal era a)
signalerMatrix tbl = tie Vertical $ map (tie Horizontal) tbl
  where
    tie ori xs = boxed ori $ arr (const mempty) >>> foldA mappend xs

-- UI回路をboxに入れる
boxed 
  :: (StdUIBackend backend, UIComp uiComp)
  => Orientation
  -> UICircuit Widget backend era i o
  -> uiComp Widget backend era i o
boxed ori content = arr (X `With`) |>> body >>| arr (\(X `With` r) -> r)
  where
    body = StdUIBackend.box ori `with` content

-- ラベルが一定のボタン
simpleButton :: (StdUIBackend backend, UIComp uiComp) => String -> a -> uiComp Widget backend era () (DSignal era a)
simpleButton label val = (arr $ const $ X :& Text ::= pure label) |>> just pushButton >>| (arr $ \(X :& Push ::= p) -> fmap (const val) p)

foldA :: (Arrow a) => (acc -> c -> acc) -> [a () c] -> a acc acc
foldA f [] = arr id
foldA f (x:xs) = proc acc -> do
  v <- x -< ()
  foldA f xs -< f acc v

-- 電卓のロジック。やけに長い
-- 明示的な状態遷移表を書いてもいいが、Continuationモナドを使って手続き的に書いてみた

data Command
  = Digit Int 
  | Op Op
  | Dot
  | Sign
  | Equal
  | Clear
  | AllClear
  deriving (Show, Eq)

data Op = Plus | Minus | Star | Slash
  deriving (Show, Eq)

data CalcState = CS String (Command -> CalcState)
type CalcProc = StateT String (Cont CalcState)

showState :: CalcState -> String
showState (CS s _) = s

initialState :: CalcState
initialState = runCont (evalStateT calcMain "") $ error "unexpected termination of calc process"

nextState :: CalcState -> Command -> CalcState
nextState (CS _ f) = f

getCommand :: CalcProc Command
getCommand = do
  current <- get
  lift $ Cont $ \k -> CS current k

display :: CalcNum -> CalcProc ()
display = put . showNum

calcMain :: CalcProc ()
calcMain = display 0 >> getNum >>= loop
  where
    loop (n0, c) = case c of
      AllClear -> calcMain
      Equal -> getNumWith n0 >>= loop
      Op op -> do
        display n0
        (n1, c1) <- getNum
        loop (evalOp op n0 n1, c1)

evalOp :: Op -> CalcNum -> CalcNum -> CalcNum
evalOp op = case op of
  Plus -> (+)
  Minus -> (-)
  Star -> (*)
  Slash -> (/)

getNum :: CalcProc (CalcNum, Command)
getNum = getCommand >>= handleNum False 0

getNumWith :: CalcNum -> CalcProc (CalcNum, Command)
getNumWith n = do
  display n
  c <- getCommand
  case c of
    Sign -> getNumWith (-n)
    Op _ -> return (n, c)
    Equal -> return (n, c)
    _ -> handleNum False 0 c

handleNum :: Bool -> Integer -> Command -> CalcProc (CalcNum, Command)
handleNum negative n c = case c of
  Digit k -> go negative $ 10 * n + fromIntegral k
  Clear -> go False 0
  Sign -> go (not negative) n
  Dot -> getNumDot negative n
  _ -> return (fromIntegral $ eval negative n, c)
  where
    go negative n = do
      put $ show $ eval negative n
      getCommand >>= handleNum negative n
    eval negative n = if negative then -n else n

getNumDot :: Bool -> Integer -> CalcProc (CalcNum, Command)
getNumDot = loop (0::Int) 0
  where
    loop pos frac negative int = do
      put $ if pos == 0
        then printf "%d." (sgn int)
        else printf "%d.%0*d" (sgn int) pos frac
      c <- getCommand
      case c of
        Digit k -> loop (pos+1) (10*frac + fromIntegral k) negative int
        Clear -> display 0 >> getNum
        Sign -> loop pos frac (not negative) int
        Dot -> loop pos frac negative int
        _ -> return (value, c)
      where
        value = sgn (fromInteger int + fromInteger frac * 0.1^pos)
        sgn :: (Num a) => a -> a
        sgn = if negative then negate else id
      
type CalcNum = Rational

showNum :: CalcNum -> String
showNum z = show int ++ frac
  where
    int = truncate z
    fracv = abs $ z - fromIntegral int
    frac = if fracv == 0 then "" else tail $ printf "%f" (fromRational fracv :: Double)

-- main。ここで初めてGTKへの依存が導入される

main = run GTK mainCircuit

こういう感じになる。


感想

  • UI部分のコードが長ったらしい。特にレコード操作
  • 型名が長ったらしい*2
  • 上記のコードは、mainを除いて一切GUIバックエンド(GTK)に依存しない形になっている。それ自体は良いのだが、GTKの機能を直接呼ぼう思うと相当面倒なようだ(標準のpushButtonなどが使えない)
  • boxを使うとレイアウトの微調整ができない。電卓のボタンが均一の大きさにならないのはかなり格好悪い

総じてGUI部分が使いにくい印象だった。ここはまだ概念実証の段階なのかもしれない。一方、コアであるFRP部分は良さそうだが、電卓では単純すぎて詳しいことが分からない。

おまけ

Hackageからダウンロードしたままでは手元のGHC 6.12.3でビルドできなかったので、以下の変更を加えた。

diff -ur grapefruits-orig/grapefruit-frp-0.0.0.0/grapefruit-frp.cabal grapefruits/grapefruit-frp-0.0.0.0/grapefruit-frp.cabal
--- grapefruits-orig/grapefruit-frp-0.0.0.0/grapefruit-frp.cabal	2009-02-13 22:27:08.000000000 +0900
+++ grapefruits/grapefruit-frp-0.0.0.0/grapefruit-frp.cabal	2010-10-23 19:40:44.000000000 +0900
@@ -24,8 +24,8 @@
 Library
     Build-Depends:   arrows      >= 0.2 && < 0.5,
                      base        >= 3.0 && < 4.1,
-                     containers  >= 0.1 && < 0.3,
-                     TypeCompose >= 0.3 && < 0.7
+                     containers  >= 0.1 && < 0.4,
+                     TypeCompose >= 0.3 && < 0.9
     Extensions:      Arrows
                      CPP
                      EmptyDataDecls
diff -ur grapefruits-orig/grapefruit-frp-0.0.0.0/src/FRP/Grapefruit/Signal/Continuous.hs grapefruits/grapefruit-frp-0.0.0.0/src/FRP/Grapefruit/Signal/Continuous.hs
--- grapefruits-orig/grapefruit-frp-0.0.0.0/src/FRP/Grapefruit/Signal/Continuous.hs	2009-02-13 22:27:08.000000000 +0900
+++ grapefruits/grapefruit-frp-0.0.0.0/src/FRP/Grapefruit/Signal/Continuous.hs	2010-10-23 19:43:42.000000000 +0900
@@ -1,3 +1,4 @@
+{-# LANGUAGE ImpredicativeTypes #-}
 {-|
     This module is about continuous signals.
 
diff -ur grapefruits-orig/grapefruit-frp-0.0.0.0/src/Internal/Signal/Discrete.hs grapefruits/grapefruit-frp-0.0.0.0/src/Internal/Signal/Discrete.hs
--- grapefruits-orig/grapefruit-frp-0.0.0.0/src/Internal/Signal/Discrete.hs	2009-02-13 22:27:08.000000000 +0900
+++ grapefruits/grapefruit-frp-0.0.0.0/src/Internal/Signal/Discrete.hs	2010-10-23 19:42:22.000000000 +0900
@@ -1,3 +1,4 @@
+{-# LANGUAGE ImpredicativeTypes #-}
 module Internal.Signal.Discrete (
 
     -- * Discrete signal type
diff -ur grapefruits-orig/grapefruit-frp-0.0.0.0/src/Internal/Signal/Segmented.hs grapefruits/grapefruit-frp-0.0.0.0/src/Internal/Signal/Segmented.hs
--- grapefruits-orig/grapefruit-frp-0.0.0.0/src/Internal/Signal/Segmented.hs	2009-02-13 22:27:08.000000000 +0900
+++ grapefruits/grapefruit-frp-0.0.0.0/src/Internal/Signal/Segmented.hs	2010-10-23 19:42:43.000000000 +0900
@@ -1,3 +1,4 @@
+{-# LANGUAGE ImpredicativeTypes #-}
 module Internal.Signal.Segmented (
 
     -- * Segmented signal type
diff -ur grapefruits-orig/grapefruit-frp-0.0.0.0/src/Internal/Signal.hs grapefruits/grapefruit-frp-0.0.0.0/src/Internal/Signal.hs
--- grapefruits-orig/grapefruit-frp-0.0.0.0/src/Internal/Signal.hs	2009-02-13 22:27:08.000000000 +0900
+++ grapefruits/grapefruit-frp-0.0.0.0/src/Internal/Signal.hs	2010-10-23 19:41:48.000000000 +0900
@@ -1,3 +1,4 @@
+{-# LANGUAGE ImpredicativeTypes #-}
 module Internal.Signal (
 
     -- * Signals
diff -ur grapefruits-orig/type-equality-check-0.0.0.0/type-equality-check.cabal grapefruits/type-equality-check-0.0.0.0/type-equality-check.cabal
--- grapefruits-orig/type-equality-check-0.0.0.0/type-equality-check.cabal	2009-01-23 09:35:37.000000000 +0900
+++ grapefruits/type-equality-check-0.0.0.0/type-equality-check.cabal	2010-10-23 21:49:07.000000000 +0900
@@ -1,6 +1,6 @@
 Name:          type-equality-check
 Version:       0.0.0.0
-Cabal-Version: >= 1.2 && < 1.8
+Cabal-Version: >= 1.2 && < 1.9
 Build-Type:    Simple
 License:       BSD3
 License-File:  LICENSE

*1:Grapefruitでシグナルというのは、他のFRPでいうBehaviorとEventの総称

*2:ちょっとC++ templateを思い起こさせる