jwiegley / hledger

A port of the Ledger data architecture to Haskell

hledger / UICommand.hs
100644 391 lines (338 sloc) 13.967 kb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
{-|
 
A simple text UI for hledger, based on the vty library.
 
-}
 
module UICommand
where
import qualified Data.Map as Map
import Data.Map ((!))
import Graphics.Vty
import qualified Data.ByteString.Char8 as B
import Ledger
import Options
import BalanceCommand
import RegisterCommand
import PrintCommand
 
 
helpmsg = "Welcome to hledger vty ui. (b)alances, (r)egister, (p)rint entries, (l)edger, (right) to drill down, (left) to back up, or (q)uit"
 
instance Show Vty where show v = "a Vty"
 
-- | The application state when running the ui command.
data AppState = AppState {
     av :: Vty -- ^ the vty context
    ,aw :: Int -- ^ window width
    ,ah :: Int -- ^ window height
    ,amsg :: String -- ^ status message
    ,aopts :: [Opt] -- ^ command-line opts
    ,aargs :: [String] -- ^ command-line args
    ,aledger :: Ledger -- ^ parsed ledger
    ,abuf :: [String] -- ^ lines of the current buffered view
    ,alocs :: [Loc] -- ^ user's navigation trail within the UI
                                -- ^ never null, head is current location
    } deriving (Show)
 
-- | A location within the user interface.
data Loc = Loc {
     scr :: Screen -- ^ one of the available screens
    ,sy :: Int -- ^ viewport y scroll position
    ,cy :: Int -- ^ cursor y position
    } deriving (Show)
 
-- | The screens available within the user interface.
data Screen = BalanceScreen -- ^ like hledger balance, shows accounts
            | RegisterScreen -- ^ like hledger register, shows transactions
            | PrintScreen -- ^ like hledger print, shows entries
            | LedgerScreen -- ^ shows the raw ledger
              deriving (Eq,Show)
 
-- | Run the interactive text ui.
ui :: [Opt] -> [String] -> Ledger -> IO ()
ui opts args l = do
  v <- mkVty
  (w,h) <- getSize v
  let opts' = SubTotal:opts
  let a = enter BalanceScreen $
          AppState {
                  av=v
                 ,aw=w
                 ,ah=h
                 ,amsg=helpmsg
                 ,aopts=opts'
                 ,aargs=args
                 ,aledger=l
                 ,abuf=[]
                 ,alocs=[]
                 }
  go a
 
-- | Update the screen, wait for the next event, repeat.
go :: AppState -> IO ()
go a@AppState{av=av,aw=aw,ah=ah,abuf=buf,amsg=amsg,aopts=opts,aargs=args,aledger=l} = do
  when (not $ DebugNoUI `elem` opts) $ update av (renderScreen a)
  k <- getEvent av
  case k of
    EvResize x y -> go $ resize x y a
    EvKey (KASCII 'l') [MCtrl] -> refresh av >> go a{amsg=helpmsg}
    EvKey (KASCII 'b') [] -> go $ resetTrailAndEnter BalanceScreen a
    EvKey (KASCII 'r') [] -> go $ resetTrailAndEnter RegisterScreen a
    EvKey (KASCII 'p') [] -> go $ resetTrailAndEnter PrintScreen a
    EvKey (KASCII 'l') [] -> go $ resetTrailAndEnter LedgerScreen a
    EvKey KRight [] -> go $ drilldown a
    EvKey KEnter [] -> go $ drilldown a
    EvKey KLeft [] -> go $ backout a
    EvKey KUp [] -> go $ moveUpAndPushEdge a
    EvKey KDown [] -> go $ moveDownAndPushEdge a
    EvKey KHome [] -> go $ moveToTop a
    EvKey KUp [MCtrl] -> go $ moveToTop a
    EvKey KUp [MShift] -> go $ moveToTop a
    EvKey KEnd [] -> go $ moveToBottom a
    EvKey KDown [MCtrl] -> go $ moveToBottom a
    EvKey KDown [MShift] -> go $ moveToBottom a
    EvKey KPageUp [] -> go $ prevpage a
    EvKey KBS [] -> go $ prevpage a
    EvKey (KASCII ' ') [MShift] -> go $ prevpage a
    EvKey KPageDown [] -> go $ nextpage a
    EvKey (KASCII ' ') [] -> go $ nextpage a
    EvKey (KASCII 'q') [] -> shutdown av >> return ()
-- EvKey KEsc [] -> shutdown av >> return ()
    _ -> go a
    where
      bh = length buf
      y = posY a
 
-- app state modifiers
 
-- | The number of lines currently available for the main data display area.
pageHeight :: AppState -> Int
pageHeight a = ah a - 1
 
setLocCursorY, setLocScrollY :: Int -> Loc -> Loc
setLocCursorY y l = l{cy=y}
setLocScrollY y l = l{sy=y}
 
cursorY, scrollY, posY :: AppState -> Int
cursorY = cy . loc
scrollY = sy . loc
posY a = scrollY a + cursorY a
 
setCursorY, setScrollY, setPosY :: Int -> AppState -> AppState
setCursorY y a@AppState{alocs=(l:locs)} = a{alocs=(l':locs)} where l' = setLocCursorY y l
setScrollY y a@AppState{alocs=(l:locs)} = a{alocs=(l':locs)} where l' = setLocScrollY y l
setPosY y a@AppState{alocs=(l:locs)} = a{alocs=(l':locs)}
    where
      l' = setLocScrollY sy $ setLocCursorY cy l
      ph = pageHeight a
      cy = y `mod` ph
      sy = y - cy
 
updateCursorY, updateScrollY, updatePosY :: (Int -> Int) -> AppState -> AppState
updateCursorY f a = setCursorY (f $ cursorY a) a
updateScrollY f a = setScrollY (f $ scrollY a) a
updatePosY f a = setPosY (f $ posY a) a
 
resize :: Int -> Int -> AppState -> AppState
resize x y a = setCursorY cy' a{aw=x,ah=y}
    where
      cy = cursorY a
      cy' = min cy (y-2)
 
moveToTop :: AppState -> AppState
moveToTop a = setPosY 0 a
 
moveToBottom :: AppState -> AppState
moveToBottom a = setPosY (length $ abuf a) a
 
moveUpAndPushEdge :: AppState -> AppState
moveUpAndPushEdge a@AppState{alocs=(Loc{sy=sy,cy=cy}:_)}
    | cy > 0 = updateCursorY (subtract 1) a
    | sy > 0 = updateScrollY (subtract 1) a
    | otherwise = a
 
moveDownAndPushEdge :: AppState -> AppState
moveDownAndPushEdge a@AppState{alocs=(Loc{sy=sy,cy=cy}:_)}
    | sy+cy >= bh = a
    | cy < ph-1 = updateCursorY (+1) a
    | otherwise = updateScrollY (+1) a
    where
      ph = pageHeight a
      bh = length $ abuf a
 
-- | Scroll down by page height or until we can just see the last line,
-- without moving the cursor, or if we are already scrolled as far as
-- possible then move the cursor to the last line.
nextpage :: AppState -> AppState
nextpage (a@AppState{abuf=b})
    | sy < bh-jump = setScrollY sy' a
    | otherwise = setCursorY (bh-sy) a
    where
      sy = scrollY a
      jump = pageHeight a - 1
      bh = length b
      sy' = min (sy+jump) (bh-jump)
 
-- | Scroll up by page height or until we can just see the first line,
-- without moving the cursor, or if we are scrolled as far as possible
-- then move the cursor to the first line.
prevpage :: AppState -> AppState
prevpage (a@AppState{abuf=b})
    | sy > 0 = setScrollY sy' a
    | otherwise = setCursorY 0 a
    where
      sy = scrollY a
      jump = pageHeight a - 1
      sy' = max (sy-jump) 0
 
-- | Push a new UI location on to the stack.
pushLoc :: Loc -> AppState -> AppState
pushLoc l a = a{alocs=(l:alocs a)}
 
popLoc :: AppState -> AppState
popLoc a@AppState{alocs=locs}
    | length locs > 1 = a{alocs=drop 1 locs}
    | otherwise = a
 
clearLocs :: AppState -> AppState
clearLocs a = a{alocs=[]}
 
exit :: AppState -> AppState
exit = popLoc
 
loc :: AppState -> Loc
loc = head . alocs
 
screen :: AppState -> Screen
screen a = scr where (Loc scr _ _) = loc a
 
-- | Enter a new screen, saving the old ui location on the stack.
enter :: Screen -> AppState -> AppState
enter scr@BalanceScreen a = updateData $ pushLoc Loc{scr=scr,sy=0,cy=0} a
enter scr@RegisterScreen a = updateData $ pushLoc Loc{scr=scr,sy=0,cy=0} a
enter scr@PrintScreen a = updateData $ pushLoc Loc{scr=scr,sy=0,cy=0} a
enter scr@LedgerScreen a = updateData $ pushLoc Loc{scr=scr,sy=0,cy=0} a
 
resetTrailAndEnter scr a = enter scr $ clearLocs a
 
-- | Regenerate the display data appropriate for the current screen.
updateData :: AppState -> AppState
updateData a@AppState{aopts=opts,aargs=args,aledger=l}
    | scr == BalanceScreen = a{abuf=lines $ showBalanceReport opts [] l, aargs=[]}
    | scr == RegisterScreen = a{abuf=lines $ showRegisterReport opts args l}
    | scr == PrintScreen = a{abuf=lines $ showEntries opts args l}
    | scr == LedgerScreen = a{abuf=lines $ rawledgertext l}
    where scr = screen a
 
backout :: AppState -> AppState
backout a
    | screen a == BalanceScreen = a
    | otherwise = updateData $ popLoc a
 
drilldown :: AppState -> AppState
drilldown a
    | screen a == BalanceScreen = enter RegisterScreen a{aargs=[currentAccountName a]}
    | screen a == RegisterScreen = scrollToEntry e $ enter PrintScreen a
    | screen a == PrintScreen = enter LedgerScreen a
    | screen a == LedgerScreen = a
    where e = currentEntry a
 
-- | Get the account name currently highlighted by the cursor on the
-- balance screen. Results undefined while on other screens.
currentAccountName :: AppState -> AccountName
currentAccountName a = accountNameAt (abuf a) (posY a)
 
-- | Get the full name of the account being displayed at a specific line
-- within the balance command's output.
accountNameAt :: [String] -> Int -> AccountName
accountNameAt buf lineno = accountNameFromComponents anamecomponents
    where
      namestohere = map (drop 22) $ take (lineno+1) buf
      (indented, nonindented) = span (" " `isPrefixOf`) $ reverse namestohere
      thisbranch = indented ++ take 1 nonindented
      anamecomponents = reverse $ map strip $ dropsiblings thisbranch
 
      dropsiblings :: [AccountName] -> [AccountName]
      dropsiblings [] = []
      dropsiblings (x:xs) = [x] ++ dropsiblings xs'
          where
            xs' = dropWhile moreindented xs
            moreindented = (>= myindent) . indentof
            myindent = indentof x
            indentof = length . takeWhile (==' ')
 
-- | If on the print screen, move the cursor to highlight the specified entry
-- (or a reasonable guess). Doesn't work.
scrollToEntry :: Entry -> AppState -> AppState
scrollToEntry e a@AppState{abuf=buf} = setCursorY cy $ setScrollY sy a
    where
      entryfirstline = head $ lines $ showEntry $ e
      halfph = pageHeight a `div` 2
      y = fromMaybe 0 $ findIndex (== entryfirstline) buf
      sy = max 0 $ y - halfph
      cy = y - sy
 
-- | Get the entry containing the transaction currently highlighted by the
-- cursor on the register screen (or best guess). Results undefined while
-- on other screens. Doesn't work.
currentEntry :: AppState -> Entry
currentEntry a@AppState{aledger=l,abuf=buf} = entryContainingTransaction a t
    where
      t = safehead nulltxn $ filter ismatch $ ledgerTransactions l
      ismatch t = date t == (parsedate $ take 10 datedesc)
                  && (take 70 $ showtxn False t nullmixedamt) == (datedesc ++ acctamt)
      datedesc = take 32 $ fromMaybe "" $ find (not . (" " `isPrefixOf`)) $ [safehead "" rest] ++ reverse above
      acctamt = drop 32 $ safehead "" rest
      safehead d ls = if null ls then d else head ls
      (above,rest) = splitAt y buf
      y = posY a
 
-- | Get the entry which contains the given transaction.
-- Will raise an error if there are problems.
entryContainingTransaction :: AppState -> Transaction -> Entry
entryContainingTransaction AppState{aledger=l} t = (entries $ rawledger l) !! entryno t
 
-- renderers
 
renderScreen :: AppState -> Picture
renderScreen (a@AppState{aw=w,ah=h,abuf=buf,amsg=msg}) =
    pic {pCursor = Cursor cx cy,
         pImage = mainimg
                  <->
                  renderStatus w msg
        }
    where
      (cx, cy) = (0, cursorY a)
      sy = scrollY a
      -- trying for more speed
      mainimg = (vertcat $ map (render defaultattr) above)
               <->
               (render currentlineattr thisline)
               <->
               (vertcat $ map (render defaultattr) below)
      render attr = renderBS attr . B.pack
      (thisline,below) | null rest = (blankline,[])
                       | otherwise = (head rest, tail rest)
      (above,rest) = splitAt cy linestorender
      linestorender = map padclipline $ take (h-1) $ drop sy $ buf ++ replicate h blankline
      padclipline l = take w $ l ++ blankline
      blankline = replicate w ' '
-- mainimg = (renderString attr $ unlines $ above)
-- <->
-- (renderString reverseattr $ thisline)
-- <->
-- (renderString attr $ unlines $ below)
-- (above,(thisline:below))
-- | null ls = ([],[""])
-- | otherwise = splitAt y ls
-- ls = lines $ fitto w (h-1) $ unlines $ drop as $ buf
 
padClipString :: Int -> Int -> String -> [String]
padClipString h w s = rows
    where
      rows = map padclipline $ take h $ lines s ++ replicate h blankline
      padclipline l = take w $ l ++ blankline
      blankline = replicate w ' '
 
renderString :: Attr -> String -> Image
renderString attr s = vertcat $ map (renderBS attr . B.pack) rows
    where
      rows = lines $ fitto w h s
      w = maximum $ map length $ ls
      h = length ls
      ls = lines s
 
renderStatus :: Int -> String -> Image
renderStatus w s = renderBS statusattr (B.pack $ take w (s ++ repeat ' '))
 
 
-- the all-important theming engine
 
theme = 1
 
(defaultattr,
 currentlineattr,
 statusattr
 ) =
    case theme of
      1 -> ( -- restrained
           attr
          ,setBold attr
          ,setRV attr
          )
      2 -> ( -- colorful
           setRV attr
          ,setFG white $ setBG red $ attr
          ,setFG black $ setBG green $ attr
          )
      3 -> ( --
           setRV attr
          ,setFG white $ setBG red $ attr
          ,setRV attr
          )
 
halfbrightattr = setHalfBright attr
reverseattr = setRV attr
redattr = setFG red attr
greenattr = setFG green attr
reverseredattr = setRV $ setFG red attr
reversegreenattr= setRV $ setFG green attr
 
-- pic { pCursor = Cursor x y,
-- pImage = renderFill pieceA ' ' w y
-- <->
-- renderHFill pieceA ' ' x <|> renderChar pieceA '@' <|> renderHFill pieceA ' ' (w - x - 1)
-- <->
-- renderFill pieceA ' ' w (h - y - 1)
-- <->
-- renderStatus w msg
-- }