packages feed

patat-0.14.2.0: lib/Patat/Main.hs

--------------------------------------------------------------------------------
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE OverloadedStrings          #-}
{-# LANGUAGE RecordWildCards            #-}
module Patat.Main
    ( main
    ) where


--------------------------------------------------------------------------------
import           Control.Concurrent               (forkIO, threadDelay)
import qualified Control.Concurrent.Async         as Async
import           Control.Concurrent.Chan.Extended (Chan)
import qualified Control.Concurrent.Chan.Extended as Chan
import           Control.Exception                (bracket)
import           Control.Monad                    (forever, unless, void, when)
import qualified Data.Aeson.Extended              as A
import           Data.Foldable                    (for_)
import           Data.Functor                     (($>))
import qualified Data.List.NonEmpty               as NonEmpty
import qualified Data.Sequence.Extended           as Seq
import           Data.Version                     (showVersion)
import qualified Options.Applicative              as OA
import qualified Options.Applicative.Help.Pretty  as OA.PP
import           Patat.AutoAdvance
import qualified Patat.EncodingFallback           as EncodingFallback
import qualified Patat.Eval                       as Eval
import qualified Patat.Images                     as Images
import           Patat.Presentation
import qualified Patat.Presentation.SpeakerNotes  as SpeakerNotes
import qualified Patat.PrettyPrint                as PP
import           Patat.PrettyPrint.Matrix         (hPutMatrix)
import           Patat.Transition
import qualified Paths_patat
import           Prelude
import qualified System.Console.ANSI              as Ansi
import           System.Directory                 (doesFileExist,
                                                   getModificationTime)
import           System.Environment               (lookupEnv)
import           System.Exit                      (exitFailure, exitSuccess)
import qualified System.IO                        as IO
import qualified Text.Pandoc                      as Pandoc


--------------------------------------------------------------------------------
data Options = Options
    { oFilePath :: !(Maybe FilePath)
    , oForce    :: !Bool
    , oDump     :: !Bool
    , oWatch    :: !Bool
    , oVersion  :: !Bool
    } deriving (Show)


--------------------------------------------------------------------------------
parseOptions :: OA.Parser Options
parseOptions = Options
    <$> (OA.optional $ OA.strArgument $
            OA.metavar "FILENAME" <>
            OA.action  "file" <>  -- For bash file completion
            OA.help    "Input file")
    <*> (OA.switch $
            OA.long    "force" <>
            OA.short   'f' <>
            OA.help    "Force ANSI terminal" <>
            OA.hidden)
    <*> (OA.switch $
            OA.long    "dump" <>
            OA.short   'd' <>
            OA.help    "Just dump all slides and exit" <>
            OA.hidden)
    <*> (OA.switch $
            OA.long    "watch" <>
            OA.short   'w' <>
            OA.help    "Watch file for changes")
    <*> (OA.switch $
            OA.long    "version" <>
            OA.help    "Display version info and exit" <>
            OA.hidden)


--------------------------------------------------------------------------------
parserInfo :: OA.ParserInfo Options
parserInfo = OA.info (OA.helper <*> parseOptions) $
    OA.fullDesc <>
    OA.header ("patat v" <> showVersion Paths_patat.version) <>
    OA.progDescDoc (Just desc)
  where
    desc = OA.PP.vcat
        [ "Terminal-based presentations using Pandoc"
        , ""
        , "Controls:"
        , "- Next slide:             space, enter, l, right, pagedown"
        , "- Previous slide:         backspace, h, left, pageup"
        , "- Go forward 10 slides:   j, down"
        , "- Go backward 10 slides:  k, up"
        , "- First slide:            0"
        , "- Last slide:             G"
        , "- Jump to slide N:        N followed by enter"
        , "- Reload file:            r"
        , "- Quit:                   q"
        ]


--------------------------------------------------------------------------------
parserPrefs :: OA.ParserPrefs
parserPrefs = OA.prefs OA.showHelpOnError


--------------------------------------------------------------------------------
errorAndExit :: [String] -> IO a
errorAndExit msg = do
    mapM_ (IO.hPutStrLn IO.stderr) msg
    exitFailure


--------------------------------------------------------------------------------
assertAnsiFeatures :: IO ()
assertAnsiFeatures = do
    supports <- Ansi.hSupportsANSI IO.stdout
    unless supports $ errorAndExit
        [ "It looks like your terminal does not support ANSI codes."
        , "If you still want to run the presentation, use `--force`."
        ]


--------------------------------------------------------------------------------
data App = App
    { aOptions      :: Options
    , aImages       :: Maybe Images.Handle
    , aSpeakerNotes :: Maybe SpeakerNotes.Handle
    , aCommandChan  :: Chan AppCommand
    , aPresentation :: Presentation
    , aView         :: AppView
    }


--------------------------------------------------------------------------------
data AppView
    = PresentationView
    | ErrorView String
    | TransitionView TransitionInstance


--------------------------------------------------------------------------------
data AppCommand = PresentationCommand PresentationCommand | TransitionTick TransitionId


--------------------------------------------------------------------------------
main :: IO ()
main = do
    options <- OA.customExecParser parserPrefs parserInfo

    when (oVersion options) $ do
        putStrLn $ showVersion Paths_patat.version
        putStrLn $ "Using pandoc: " ++ showVersion Pandoc.pandocVersion
        exitSuccess

    filePath <- case oFilePath options of
        Just fp -> return fp
        Nothing -> OA.handleParseResult $ OA.Failure $
            OA.parserFailure parserPrefs parserInfo
            (OA.ShowHelpText Nothing) mempty

    errOrPres <- readPresentation zeroUniqueGen filePath
    pres      <- either (errorAndExit . return) return errOrPres
    let settings = pSettings pres

    unless (oForce options) assertAnsiFeatures

    if oDump options then
        EncodingFallback.withHandle IO.stdout (pEncodingFallback pres) $ do
        Eval.evalAllVars pres >>= dumpPresentation
    else
        -- (Maybe) initialize images backend.
        withMaybeHandle Images.withHandle (psImages settings) $ \images ->

        -- (Maybe) initialize speaker notes.
        withMaybeHandle SpeakerNotes.withHandle
            (psSpeakerNotes settings) $ \speakerNotes ->

        -- Read presentation commands
        interactively (readPresentationCommand) $ \commandChan0 ->

        -- If an auto delay is set, use 'autoAdvance' to create a new one.
        maybeAutoAdvance
            (A.unFlexibleNum <$> psAutoAdvanceDelay settings)
            commandChan0 $ \commandChan1 ->

        -- Change to AppCommand
        Chan.withMapChan PresentationCommand commandChan1 $ \commandChan ->

        -- Spawn a thread that adds 'Reload' commands based on the file time.
        withWatcher (oWatch options) commandChan (pFilePath pres)
            (PresentationCommand Reload) $

        loop App
            { aOptions      = options
            , aImages       = images
            , aSpeakerNotes = speakerNotes
            , aCommandChan  = commandChan
            , aPresentation = pres
            , aView         = PresentationView
            }


--------------------------------------------------------------------------------
loop :: App -> IO ()
loop app@App {..} = do
    for_ aSpeakerNotes $ \sn -> SpeakerNotes.write sn
        (pEncodingFallback aPresentation)
        (activeSpeakerNotes aPresentation)

    -- Start necessary eval blocks
    presentation <- Eval.evalActiveVars
        (\v -> Chan.writeChan aCommandChan . PresentationCommand . UpdateVar v)
        aPresentation

    size <- getPresentationSize presentation
    Ansi.clearScreen
    Ansi.setCursorPosition 0 0
    cleanup <- case aView of
        PresentationView -> case displayPresentation size presentation of
            DisplayDoc doc    -> drawDoc doc
            DisplayImage path -> drawImg size path
        ErrorView err -> drawDoc $
                displayPresentationError size presentation err
        TransitionView tr -> do
            drawMatrix (tiSize tr) . fst . NonEmpty.head $ tiFrames tr
            pure mempty

    appCmd <- Chan.readChan aCommandChan
    cleanup
    case appCmd of
        TransitionTick eid -> case aView of
            PresentationView -> loop app
            ErrorView _      -> loop app
            TransitionView tr0  -> case stepTransition eid tr0 of
                Just tr1 -> do
                    scheduleTransitionTick tr1
                    loop app {aView = TransitionView tr1}
                Nothing -> loop app {aView = PresentationView}
        PresentationCommand c -> do
            update <- updatePresentation c presentation
            case update of
                ExitedPresentation       -> return ()
                UpdatedPresentation pres
                    | Just tgen <- mbTransition c size presentation pres -> do
                        tr <- tgen
                        scheduleTransitionTick tr
                        loop app
                            {aPresentation = pres, aView = TransitionView tr}
                    | otherwise -> loop app
                        {aPresentation = pres, aView = PresentationView}
                ErroredPresentation err  ->
                    loop app {aView = ErrorView err}
  where
    drawDoc doc = EncodingFallback.withHandle
        IO.stdout (pEncodingFallback aPresentation) $
        PP.putDoc doc $> mempty
    drawImg size path = case aImages of
        Nothing -> drawDoc $ displayPresentationError
            size aPresentation "image backend not initialized"
        Just img -> do
            putStrLn ""
            IO.hFlush IO.stdout
            Images.drawImage img path
    drawMatrix size raster = hPutMatrix IO.stdout size raster

    mbTransition c size old new
        | c == Forward
        , oldSlide + 1 == newSlide
        , DisplayDoc oldDoc <- displayPresentation size old
        , DisplayDoc newDoc <- displayPresentation size new
        , Just (Just tgen) <- pTransitionGens new `Seq.safeIndex` newSlide =
            Just $ newTransition tgen size oldDoc newDoc
        | otherwise = Nothing
      where
        (oldSlide, _) = pActiveFragment old
        (newSlide, _) = pActiveFragment new

    scheduleTransitionTick tr = void $ forkIO $ do
        threadDelayDuration . snd . NonEmpty.head $ tiFrames tr
        Chan.writeChan aCommandChan $ TransitionTick $ tiId tr


--------------------------------------------------------------------------------
-- | Utility for dealing with pecularities of stdin & interactive applications
-- on the terminal.  Tries to restore the original state of the terminal as much
-- as possible.
interactively
    :: (IO.Handle -> IO a)
    -- ^ Reads a command from stdin (or from some other IO).  This will be
    -- interrupted by 'killThread' when the application finishes.
    -> (Chan a -> IO ())
    -- ^ Application to run.
    -> IO ()
    -- ^ Returns when application finishes.
interactively reader app = bracket setup teardown $ \(_, _, chan) ->
    Async.withAsync
        (forever $ reader IO.stdin >>= Chan.writeChan chan)
        (\_ -> app chan)
  where
    setup = do
        chan <- Chan.newChan
        echo <- IO.hGetEcho      IO.stdin
        buff <- IO.hGetBuffering IO.stdin
        IO.hSetEcho      IO.stdin False
        IO.hSetBuffering IO.stdin IO.NoBuffering

        -- Suppress cursor hiding for WezTerm image compatibility
        termProgram <- lookupEnv "TERM_PROGRAM"
        unless (termProgram == Just "WezTerm") $ Ansi.hideCursor

        return (echo, buff, chan)

    teardown (echo, buff, _chan) = do
        Ansi.showCursor
        Ansi.clearScreen
        Ansi.setCursorPosition 0 0
        IO.hSetEcho      IO.stdin echo
        IO.hSetBuffering IO.stdin buff


--------------------------------------------------------------------------------
withWatcher
    :: Bool -> Chan.Chan cmd -> FilePath -> cmd -> IO a -> IO a
withWatcher False _    _        _   mx = mx
withWatcher True  chan filePath cmd mx = do
    mtime0 <- getModificationTime filePath
    Async.withAsync (watcher mtime0) (\_ -> mx)
  where
    watcher mtime0 = do
        -- The extra exists check helps because some editors temporarily make
        -- the file disappear while writing.
        exists <- doesFileExist filePath
        mtime1 <- if exists then getModificationTime filePath else return mtime0

        when (mtime1 > mtime0) $ Chan.writeChan chan cmd
        threadDelay (200 * 1000)
        watcher mtime1


--------------------------------------------------------------------------------
-- | Wrapper for optional handles.
withMaybeHandle
    :: (settings -> (handle -> IO a) -> IO a)
    -> Maybe settings
    -> (Maybe handle -> IO a)
    -> IO a
withMaybeHandle _    Nothing         f = f Nothing
withMaybeHandle impl (Just settings) f = impl settings (f . Just)