packages feed

hledger-1.30: Hledger/Cli/Commands/Demo.hs

{-|
The @demo@ command lists and plays small hledger demos in the terminal, using asciinema.
-}
{-
PROJECTS
improve cast output
 install
  command line editing glitches
  shrink / compress ?
 help
  screen corrupted by pager
 demo
  update (or drop till stable)
 add
 print
 balance
document cast production tips
 always clear screen after running pager/curses apps ?
 record with tall window to avoid showing pager in playback ?
improve functionality
 show "done" in final red line ?
 mirror common asciinema flags like -s, -i and/or set speed/max idle with optional arguments
 support other asciinema operations (cat)
 show hledger.org player urls
 windows/PowerSession support
 attract/continuous play mode
more casts
 clarify goals/target user(s)/scenarios
 identify and prioritise some casts needed
-}

{-# LANGUAGE OverloadedStrings   #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TemplateHaskell     #-}

module Hledger.Cli.Commands.Demo (
  demomode
 ,demo
) where

import Hledger
import Hledger.Cli.CliOptions
import System.Exit (exitFailure)
import Text.Printf
import Control.Concurrent (threadDelay)
import System.Process (callProcess)
import System.IO.Error (catchIOError)
import Safe (readMay, atMay, headMay)
import Data.List (isPrefixOf, find, findIndex, isInfixOf, dropWhileEnd)
import Control.Applicative ((<|>))
import Data.ByteString as B (ByteString)
import Data.Maybe
import qualified Data.ByteString.Char8 as B
import System.IO.Temp (withSystemTempFile)
import System.IO (hClose)
import System.Console.CmdArgs.Explicit (flagReq)

demos :: [Demo]
demos = map readDemo [
  -- XXX these are confusing, redo
  -- $(embedFileRelative "embeddedfiles/help.cast"),     -- https://asciinema.org/a/568112 Getting help
  -- $(embedFileRelative "embeddedfiles/demo.cast"),     -- https://asciinema.org/a/567944 Watching the built-in demos
  $(embedFileRelative "embeddedfiles/add.cast"),      -- https://asciinema.org/a/567935 The easiest way to start a journal (add)
  $(embedFileRelative "embeddedfiles/print.cast"),    -- https://asciinema.org/a/567936 Show full transactions (print)
  $(embedFileRelative "embeddedfiles/balance.cast"),   -- https://asciinema.org/a/567937 Show account balances and changes (balance)
  $(embedFileRelative "embeddedfiles/install.cast")  -- https://asciinema.org/a/567934 Installing hledger from source with hledger-install
  ]

-- | An embedded asciinema cast, with some of the metadata separated out.
-- The original file name is not preserved.
data Demo = Demo {
  dtitle    :: String,      -- asciinema title field
  _dcontent :: ByteString   -- asciinema v2 content
}

-- | Command line options for this command.
demomode = hledgerCommandMode
  $(embedFileRelative "Hledger/Cli/Commands/Demo.txt")
  [
   flagReq  ["speed","s"] (\s opts -> Right $ setopt "speed" s opts) "SPEED"
    ("playback speed (1 is original speed, .5 is half, 2 is double, etc (default: 2))")
  ]
  [generalflagsgroup3]
  []
  ([], Just $ argsFlag optsstr)

optsstr = "[NUM|PREFIX|SUBSTR] [-- ASCIINEMAOPTS]"
usagestr = "Usage: hledger demo " <> optsstr

-- | The demo command.
demo :: CliOpts -> Journal -> IO ()
demo CliOpts{rawopts_=rawopts, reportspec_=ReportSpec{_rsQuery=_query}} _j = do
  -- demos <- getCurrentDirectory >>= readDemos
  case listofstringopt "args" rawopts of
    [] -> putStrLn usagestr >> printDemos
    (a:as) ->
      case findDemo demos a of
        Nothing -> do
          putStrLn $ "No demo \"" <> a <> "\" was found."
          putStrLn usagestr
          printDemos
          exitFailure
        Just (Demo t c) -> do
          let
            -- try to preserve the original pauses a bit while also moving things along
            defidlelimit = 10
            defspeed     = 2
            speed =
              case maybestringopt "speed" rawopts of
                Nothing -> defspeed
                Just s -> fromMaybe err $ readMay s
                  where err = error' $ "could not parse --speed " <> s <> ", numeric argument expected"
            idx = maybe 0 (1+) $ findIndex (\(Demo t2 _) -> t2 == t) demos  -- should succeed
          mw <- getTerminalWidth
          let line = red' $ replicate w '.' where w = fromMaybe (length t) mw
          printf "playing: %d) %s\nspace to pause, . to step, ctrl-c to quit\n" idx (bold' t)
          putStrLn line
          putStrLn ""
          threadDelay 1000000
          runAsciinemaPlay speed defidlelimit c as
          putStrLn ""
          putStrLn line

readDemo :: ByteString -> Demo
readDemo content = Demo title content
  where
    title = maybe "" (readTitle . B.unpack) $ headMay $ B.lines content
      where
        readTitle s
          | "\"title\":" `isPrefixOf` s = takeWhile (/='"') $ drop 1 $ lstrip $ drop 8 s
          | null s = ""
          | otherwise = readTitle $ tail s

findDemo :: [Demo] -> String -> Maybe Demo
findDemo ds s =
      (readMay s >>= atMay ds . subtract 1)         -- try to find by number
  <|> find ((sl `isPrefixOf`).lowercase.dtitle) ds  -- or by title prefix (ignoring case)
  <|> find ((sl `isInfixOf`) .lowercase.dtitle) ds  -- or by title substring (ignoring case)
  where
    sl = lowercase s

printDemos :: IO ()
printDemos = putStrLn $ unlines $
  "Demos:" :
  -- "" :
  [show i <> ") " <> bold' t | (i, Demo t _) <- zip [(1::Int)..] demos]

-- | Run asciinema play with the given speed and idle limit, passing the given content to its stdin.
runAsciinemaPlay :: Float -> Float -> ByteString -> [String] -> IO ()
runAsciinemaPlay speed idlelimit content args =
  withSystemTempFile "hledger-cast" $ \f h -> do  -- try piping to stdin also
    B.hPutStrLn h content >> hClose h
    callProcess "asciinema" (dbg8With (("asciinema: "++).unwords) $ concat [
       ["play"]
      ,["-s"<> showwithouttrailingzero speed]
      ,if idlelimit == 0 then [] else ["-i"<>showwithouttrailingzero idlelimit]
      ,[f]
      ,args
      ])
      `catchIOError` \err -> do
        putStrLn $ "There was a problem. Is asciinema installed ?\n" <> show err  --  (or PowerSession on Windows)
        exitFailure
  where
    showwithouttrailingzero = dropWhileEnd (=='.') . dropWhileEnd (=='0') . show