packages feed

reflex-vty-1.3.0.0: test/pty/Main.hs

{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}

-- |
-- Module: Main
-- Description: PTY test driver for the 'mainWidget' entry point.
--
-- This executable runs @mainWidget@ applications in a PTY.
-- @test/pty_smoke.py@ controls the PTY.
-- The driver uses the real Vty instead of the test recorder.
-- It sends results and readiness messages through an inherited file descriptor.
-- @REFLEX_VTY_TEST_CONTROL_FD@ specifies this descriptor.
-- The driver does not display these messages in the UI.
--
-- Newline-delimited control protocol:
--
-- > READY
-- > VALUE <Haskell-show String>
-- > VIEWPORT <width> <height>
-- > SCROLL <first-visible-line> <visible-line-count>
-- > EXIT
--
-- Supported modes: @echo@, @pager@, @postbuild-exit@, @throw-after-start@.
module Main (main) where

import Control.Exception (ErrorCall (..), throwIO)
import Control.Monad (void)
import Control.Monad.IO.Class (MonadIO (..))
import Data.Default (def)
import qualified Data.Text as T
import qualified Graphics.Vty as V
import Reflex
  ( Behavior
  , Event
  , MonadSample (..)
  , PerformEvent
  , Performable
  , PostBuild
  , current
  , ffor
  , getPostBuild
  , leftmost
  , never
  , performEvent
  , performEvent_
  , sample
  , updated
  , zipDyn
  )
import System.Environment (getArgs, lookupEnv)
import System.Exit (exitFailure)
import System.IO (Handle, hFlush, hPutStrLn, hSetEncoding, stderr, utf8)
import System.Posix.IO (fdToHandle)
import System.Posix.Types (Fd (..))
import Text.Read (readMaybe)

import Reflex.Vty
  ( HasColorProfile
  , HasCursor
  , HasDisplayRegion (..)
  , HasFocusReader
  , HasImageWriter
  , HasInput
  , HasScreenMode
  , HasTheme
  , MonadVtyApp
  , ScrollPos (..)
  , Scrollable (..)
  , ctrlc
  , displayHeight
  , displayWidth
  , key
  , mainWidget
  , mouseScroll
  , scrollable
  , text
  , textInput
  , _scrollable_scrollPosition
  , _textInput_value
  )

-- | The total line count of the pager content.
-- Lines are short enough to prevent wrapping at all tested terminal widths.
-- The Python driver uses the same total line count.
pagerTotalLines :: Int
pagerTotalLines = 40

main :: IO ()
main = do
  mode <-
    getArgs >>= \case
      [m] | m `elem` modes -> pure m
      _ -> do
        hPutStrLn
          stderr
          ( "usage: reflex-vty-test-driver <mode>  (modes: "
              ++ unwords modes
              ++ "); control fd taken from REFLEX_VTY_TEST_CONTROL_FD"
          )
        exitFailure
  ctl <- openControlFd
  mainWidget def (driverWidget ctl mode)
  where
    modes = ["echo", "pager", "postbuild-exit", "throw-after-start"]

-- | Convert the inherited control file descriptor to a 'Handle'.
openControlFd :: IO Handle
openControlFd = do
  mf <- lookupEnv "REFLEX_VTY_TEST_CONTROL_FD"
  fd <- case mf >>= readMaybe of
    Nothing -> failWithoutFd
    Just fd -> pure (Fd fd)
  h <- fdToHandle fd
  hSetEncoding h utf8
  pure h
  where
    failWithoutFd = do
      hPutStrLn stderr "REFLEX_VTY_TEST_CONTROL_FD must name an inherited file descriptor"
      exitFailure

-- | Write one protocol line to the control channel and flush it.
ctlLine :: Handle -> String -> IO ()
ctlLine h msg = do
  hPutStrLn h msg
  hFlush h

-- | The widget run by 'mainWidget'. Reports READY and the root viewport on
-- the control channel, delegates mode-specific behaviour, and reports EXIT
-- on whichever shutdown event the mode produces.
driverWidget
  :: forall t m
   . ( MonadVtyApp t m
     , MonadIO m
     , HasImageWriter t m
     , HasDisplayRegion t m
     , HasFocusReader t m
     , HasInput t m
     , HasTheme t m
     , HasColorProfile t m
     , HasCursor t m
     , HasScreenMode t m
     )
  => Handle
  -> String
  -> m (Event t ())
driverWidget ctl mode = do
  pb <- getPostBuild
  ready <- performEvent $ ffor pb $ \_ -> liftIO (ctlLine ctl "READY")

  size <- zipDyn <$> displayWidth <*> displayHeight
  performEvent_ $ ffor (leftmost [() <$ updated size, pb]) $ \_ -> do
    (w, h') <- sample (current size)
    liftIO (ctlLine ctl ("VIEWPORT " ++ show w ++ " " ++ show h'))

  shutdown <- case mode of
    "echo" -> echoMode ctl
    "pager" -> pagerMode ctl pb
    "postbuild-exit" -> pure pb
    _ -> throwAfterStartMode ready

  performEvent_ $ ffor shutdown $ \_ -> liftIO (ctlLine ctl "EXIT")
  pure shutdown

-- | Report each change in the text input value as @VALUE <Haskell-show String>@.
-- Ctrl-C stops the widget.
echoMode
  :: forall t m
   . ( MonadVtyApp t m
     , MonadIO m
     , HasImageWriter t m
     , HasDisplayRegion t m
     , HasFocusReader t m
     , HasInput t m
     , HasTheme t m
     )
  => Handle
  -> m (Event t ())
echoMode ctl = do
  ti <- textInput def
  performEvent_ $ ffor (updated (_textInput_value ti)) $ \t ->
    liftIO (ctlLine ctl ("VALUE " ++ show (T.unpack t)))
  quit <- ctrlc
  pure (void quit)

-- | A scrollable text widget.
-- Arrow keys, mouse wheel events, and viewport changes produce @SCROLL <first-visible-line> <visible-line-count>@ reports.
-- Ctrl-C stops the widget.
pagerMode
  :: forall t m
   . ( MonadVtyApp t m
     , MonadIO m
     , HasImageWriter t m
     , HasDisplayRegion t m
     , HasFocusReader t m
     , HasInput t m
     , HasTheme t m
     )
  => Handle
  -> Event t ()
  -> m (Event t ())
pagerMode ctl pb = do
  let content :: T.Text
      content = T.intercalate "\n" (T.pack . pad3 <$> [1 .. pagerTotalLines])
      pad3 :: Int -> String
      pad3 n = let s = show n in replicate (3 - length s) '0' ++ s

  sc <- fmap fst $
    scrollable def $ do
      text (pure content)
      pure (never, ())

  kup <- key V.KUp
  kdown <- key V.KDown
  wheel <- mouseScroll
  dh <- displayHeight
  let reportTriggers :: Event t ()
      reportTriggers =
        leftmost
          [ void kup
          , void kdown
          , void wheel
          , pb
          , () <$ updated dh
          ]
      scrollPos :: Behavior t ScrollPos
      scrollPos = _scrollable_scrollPosition sc
      viewportHeight' :: Behavior t Int
      viewportHeight' = current dh

  performEvent_ $ ffor reportTriggers $ \_ -> do
    pos <- sample scrollPos
    h' <- sample viewportHeight'
    liftIO $
      ctlLine ctl $
        "SCROLL "
          ++ show (firstVisibleLine pos pagerTotalLines h')
          ++ " "
          ++ show (min pagerTotalLines h')
  quit <- ctrlc
  pure (void quit)

-- | Calculate the first visible line from the scroll position, total line count, and viewport height.
firstVisibleLine :: ScrollPos -> Int -> Int -> Int
firstVisibleLine pos total h = case pos of
  ScrollPos_Top -> 0
  ScrollPos_Line n -> n
  ScrollPos_Bottom -> max 0 (total - h)

-- | Raise an exception after the driver sends READY.
-- The host must restore the terminal and stop with a nonzero exit code.
throwAfterStartMode :: forall t m. (PostBuild t m, PerformEvent t m, MonadIO (Performable m)) => Event t () -> m (Event t ())
throwAfterStartMode pb = do
  performEvent_ $ ffor pb $ \_ ->
    liftIO $ throwIO (ErrorCall "reflex-vty-test-driver: requested failure (throw-after-start)")
  pure never