packages feed

ansiform-1.0.0: src/Ansiform/Internal/Input.hs

-- | Immediate single-key input and line input for interactive interfaces.
module Ansiform.Internal.Input (withImmediateInput, readKey, readKeyPress,
                                withLineInput, readLine,
                                readLineAt, readLineAtOn,
                                Prompt, promptAt, promptWithInitial,
                                promptSection, promptRow, promptColumn, promptInitial,
                                FormError(..), readForm, readFormOn) where

--------------------------------------------------------------------------------
import           Control.Exception (bracket_, tryJust)
import           Control.Monad (when)
import           Data.Char (isPrint)
import           Data.Maybe (isJust)
import           System.Console.ANSI (hClearFromCursorToLineEnd, hCursorDown,
                                      hCursorUp, hSetCursorColumn)
import           System.IO (BufferMode (..), Handle, hFlush, hGetBuffering,
                            hGetEcho, hIsTerminalDevice, hPutStr, hSetBuffering,
                            hSetEcho, hWaitForInput, isEOF, stdin, stdout)
import           System.IO.Error (isEOFError)
import           Ansiform.Draw (drawOn)
import           Ansiform.Internal.Line
import           Ansiform.Internal.Number
import           Ansiform.Internal.Page
import           Ansiform.Internal.State
import           Ansiform.Internal.Terminal (termWOn)
import           Ansiform.Internal.Utils


-- Terminal Input
--------------------------------------------------------------------------------

-- | Runs an action with stdin configured for immediate single-key input.
--   Buffering and echo are changed only when stdin is a terminal,
--   and the original settings are restored when the action finishes.
withImmediateInput :: IO a -> IO a
withImmediateInput action = do
    interactive <- hIsTerminalDevice stdin
    oldBuffering <- hGetBuffering stdin
    oldEcho <- if interactive then hGetEcho stdin else return True
    let setup = when interactive $ do
                    hSetBuffering stdin NoBuffering
                    hSetEcho stdin False
        restore = when interactive $ do
                    hSetBuffering stdin oldBuffering
                    hSetEcho stdin oldEcho
    bracket_ setup restore action

-- | Reads one key without waiting for Enter.
--   Returns 'Nothing' at end of input.
--   This is the raw reader: it returns every character, including the parts of
--   an escape sequence. Use 'readKeyPress' in menus and command loops.
readKey :: IO (Maybe Char)
readKey = do
    eof <- isEOF
    if eof then return Nothing else Just <$> getChar

-- | Delay in milliseconds used to tell a lone Escape from an escape sequence.
escapeDelay :: Int
escapeDelay = 50

-- | Result of waiting for the next key within a time limit.
data KeyWait = KeyReady Char | KeyTimeout | KeyEnded

-- | Waits up to the given number of milliseconds for the next key.
--   Uses 'hWaitForInput', which is portable and avoids interrupting a blocked
--   read with an asynchronous exception. Do not call 'isEOF' first: on a
--   terminal it blocks until a key arrives and would defeat the timeout.
--   Only an end-of-file exception is turned into data; other I/O errors still
--   propagate.
waitKey :: Int -> IO KeyWait
waitKey ms = do
    ready <- tryJust eofOnly (hWaitForInput stdin ms)
    case ready of
        Left _ -> return KeyEnded
        Right False -> return KeyTimeout
        Right True -> do
            value <- tryJust eofOnly getChar
            case value of
                Left _ -> return KeyEnded
                Right c -> return (KeyReady c)
  where
    eofOnly e = if isEOFError e then Just e else Nothing

-- | Reads one key press for shortcut handling.
--   Arrow keys, Home, End, function keys and other escape sequences consume
--   their whole sequence and are ignored, so they cannot trigger the shortcuts
--   of a menu. A lone Escape is returned as @Just '\ESC'@.
--   Returns 'Nothing' at end of input. Use 'readKey' when the raw characters
--   are needed, such as inside a text editor.
readKeyPress :: IO (Maybe Char)
readKeyPress = do
    key <- readKey
    case key of
        Nothing -> return Nothing
        Just '\ESC' -> do
            next <- waitKey escapeDelay
            case next of
                KeyTimeout -> return (Just '\ESC')
                KeyEnded -> return Nothing
                KeyReady '[' -> skipControlSequence >> readKeyPress
                KeyReady 'O' -> skipFinalByte >> readKeyPress
                KeyReady _ -> readKeyPress
        Just c -> return (Just c)

-- Skips the rest of a CSI sequence, whose final byte is in 0x40 to 0x7E.
skipControlSequence :: IO ()
skipControlSequence = do
    next <- waitKey escapeDelay
    case next of
        KeyTimeout -> return ()
        KeyEnded -> return ()
        KeyReady c
            | c >= '\x40' && c <= '\x7E' -> return ()
            | otherwise -> skipControlSequence

-- Skips the final byte of an SS3 sequence, such as an arrow in application
-- cursor mode.
skipFinalByte :: IO ()
skipFinalByte = do
    _ <- waitKey escapeDelay
    return ()

-- | Runs an action with stdin configured for line input with echo.
--   This is useful for prompts that ask the user to type a value and press Enter.
withLineInput :: IO a -> IO a
withLineInput action = do
    interactive <- hIsTerminalDevice stdin
    oldBuffering <- hGetBuffering stdin
    oldEcho <- if interactive then hGetEcho stdin else return True
    let setup = when interactive $ do
                    hSetBuffering stdin LineBuffering
                    hSetEcho stdin True
        restore = when interactive $ do
                    hSetBuffering stdin oldBuffering
                    hSetEcho stdin oldEcho
    bracket_ setup restore action

-- | Reads one line, including the newline consumed by 'getLine'.
--   Returns 'Nothing' at end of input.
readLine :: IO (Maybe String)
readLine = do
    eof <- isEOF
    if eof then return Nothing else Just <$> getLine

-- | Reads one line with echo after moving the cursor up and to a column.
--   The first argument is the number of lines to move up from the current
--   position; the second is the absolute column. This places the input on
--   a prompt line that has other printed lines below it.
--   This function already wraps 'withLineInput'; wrapping it again changes
--   the terminal mode twice and can leave echo or buffering in the wrong
--   state. Use it directly, or use 'readForm' for several prompts.
readLineAt :: Number -> Offset -> IO (Maybe String)
readLineAt = readLineAtOn stdout

-- | Version of 'readLineAt' that moves the cursor on a given handle.
--   Cursor movement happens only when stdin is a terminal, matching
--   'withLineInput'. Like 'readLineAt', this function already wraps
--   'withLineInput'; do not wrap it again.
readLineAtOn :: Handle -> Number -> Offset -> IO (Maybe String)
readLineAtOn h up column = do
    moveCursorBy h (negate (unn up)) column
    withLineInput readLine


-- Page Forms
--------------------------------------------------------------------------------

-- | A line prompt placed in a section of a page.
data Prompt = Prompt
    { promptSection :: Number
      -- ^ Zero-based section index.
    , promptRow :: Number
      -- ^ Zero-based row inside the section.
    , promptColumn :: Offset
      -- ^ Absolute column where typing starts.
    , promptInitial :: Maybe String
      -- ^ Optional initial value read by the line editor.
    } deriving (Eq, Show)

-- | Builds a prompt from a zero-based section index, a zero-based row inside
--   that section and an absolute column. A prompt row is easier to keep
--   correct than the number of lines below the prompt, and it survives
--   changes to the other sections of the page.
promptAt :: Number -> Number -> Offset -> Prompt
promptAt section row column = Prompt section row column Nothing

-- | Builds a prompt that opens with an editable initial value.
--   Non-printable characters are removed, because the editor is single-line.
--   An empty initial value is still meaningful: it opens the line editor with
--   an empty buffer instead of falling back to the terminal line discipline.
promptWithInitial :: Number -> Number -> Offset -> String -> Prompt
promptWithInitial section row column initial =
    Prompt section row column (Just (filter isPrint initial))

-- | Why a form could not be completed.
data FormError
    = PromptOutsidePage Prompt
    | FormInputEnded
    | FormCancelled
    deriving (Eq, Show)

-- | Draws a page and reads one line for every prompt, in the order given.
--   The function computes the cursor movement from the line counts recorded
--   by the draw, so callers do not count the lines below each prompt.
--   Prompts without an initial value are read with 'readLine', and line input
--   is enabled once for the whole form, without nesting 'withLineInput'.
--   Prompts built with 'promptWithInitial' use the Ansiform line editor when
--   stdin is a terminal, so the value can be changed in place. When stdin is
--   not a terminal, they fall back to 'readLine' and ignore the initial value.
--   Returns the state from drawing the page and either the values or the
--   reason the form stopped. A prompt outside the page fails before any input
--   is read.
readForm :: Page -> [Prompt] -> State -> IO (State, Either FormError [String])
readForm = readFormOn stdout

-- | Version of 'readForm' that draws the page on a given handle.
readFormOn :: Handle -> Page -> [Prompt] -> State -> IO (State, Either FormError [String])
readFormOn h p prompts st = do
    st' <- drawOn h p st
    let counts = fmap unNumber (unStateResults st')
        total = sum counts
        placed = traverse (promptLine counts) prompts
    case placed of
        Left bad -> return (st', Left (PromptOutsidePage bad))
        Right lines' -> do
            interactive <- hIsTerminalDevice stdin
            let useEditor = interactive && any (isJust . promptInitial) prompts
                fields = zip lines' prompts
                readFields = if useEditor
                    then do
                        width <- termWOn h
                        lastLine <- drawInitialFields h width total fields
                        moveCursorBy h (total - lastLine) 0
                        readEditorFields h total fields
                    else withLineInput (readPlainFields h total fields)
            values <- readFields
            return (st', values)

-- Draws the initial window of every editable field before the first field is
-- read, so a form shows all current values at once. A field without an
-- initial value is left as the page drew it. Returns the last line drawn.
drawInitialFields :: Handle -> Int -> Int -> [(Int, Prompt)] -> IO Int
drawInitialFields _ _ current [] = return current
drawInitialFields h width current ((line, prompt):rest) =
    case promptInitial prompt of
        Nothing -> drawInitialFields h width current rest
        Just initial -> do
            moveCursorBy h (line - current) (promptColumn prompt)
            redrawLine h width (promptColumn prompt)
                        (LineState initial (length initial))
            drawInitialFields h width line rest

-- Reads prompts through the terminal line discipline.
readPlainFields :: Handle -> Int -> [(Int, Prompt)] -> IO (Either FormError [String])
readPlainFields _ _ [] = return (Right [])
readPlainFields h current ((line,prompt):rest) = do
    moveCursorBy h (line - current) (promptColumn prompt)
    value <- readLine
    case value of
        Nothing -> return (Left FormInputEnded)
        Just v -> fmap (fmap (v:)) (readPlainFields h (line + 1) rest)

-- Reads prompts in order, choosing the line editor or the line discipline.
readEditorFields :: Handle -> Int -> [(Int, Prompt)] -> IO (Either FormError [String])
readEditorFields _ _ [] = return (Right [])
readEditorFields h current ((line,prompt):rest) = do
    moveCursorBy h (line - current) (promptColumn prompt)
    value <- case promptInitial prompt of
        Nothing -> fmap (maybe (Left FormInputEnded) Right) (withLineInput readLine)
        Just initial ->
            withImmediateInput (readLineEditor h (promptColumn prompt) initial)
    case value of
        Left err -> return (Left err)
        Right v -> fmap (fmap (v:)) (readEditorFields h (line + 1) rest)

-- Converts a section and row to an absolute page line.
promptLine :: [Int] -> Prompt -> Either Prompt Int
promptLine counts p = case atMay counts section of
    Nothing -> Left p
    Just sectionLines
        | row < sectionLines -> Right (sum (take section counts) + row)
        | otherwise -> Left p
  where
    section = unNumber (promptSection p)
    row = unNumber (promptRow p)

-- Moves the cursor by a number of rows and sets an absolute column.
moveCursorBy :: Handle -> Int -> Offset -> IO ()
moveCursorBy h delta column = do
    interactive <- hIsTerminalDevice stdin
    when interactive $ do
        when (delta > 0) $ hCursorDown h delta
        when (delta < 0) $ hCursorUp h (negate delta)
        hSetCursorColumn h (unn column)
        hFlush h


-- Line Editor
--------------------------------------------------------------------------------

-- | One decoded key press for the line editor.
data LineEvent = LineSubmit | LineCancel | LineEdit LineKey | LineIgnore

-- | Reads one field with an editable initial value.
--   When stdin is not a terminal the initial value is ignored and a regular
--   line is read, keeping non-interactive consumers neutral. The caller is
--   responsible for the immediate input mode of the interactive branch.
readLineEditor :: Handle -> Offset -> String -> IO (Either FormError String)
readLineEditor h column initial = do
    interactive <- hIsTerminalDevice stdin
    if not interactive
        then fmap (maybe (Left FormInputEnded) Right) readLine
        else do
            width <- termWOn h
            let start = LineState initial (length initial)
            redrawLine h width column start
            editLoop h width column start

-- Applies decoded keys until the field is submitted, cancelled or input ends.
editLoop :: Handle -> Int -> Offset -> LineState -> IO (Either FormError String)
editLoop h width column state = do
    event <- readLineEvent
    case event of
        Nothing -> return (Left FormInputEnded)
        Just LineCancel -> do
            newline h
            return (Left FormCancelled)
        Just LineSubmit -> do
            newline h
            return (Right (lineText state))
        Just (LineEdit key) -> do
            let next = applyLineKey key state
            redrawLine h width column next
            editLoop h width column next
        Just LineIgnore -> editLoop h width column state

-- Moves to the prompt column, draws the visible window and clears the rest of
-- the line, then places the cursor at its display offset.
redrawLine :: Handle -> Int -> Offset -> LineState -> IO ()
redrawLine h width column state = do
    let available = max 0 (width - unn column)
        (window, cursorOffset) = visibleLine available state
    hSetCursorColumn h (unn column)
    hPutStr h window
    hClearFromCursorToLineEnd h
    hSetCursorColumn h (unn column + cursorOffset)
    hFlush h

newline :: Handle -> IO ()
newline h = hPutStr h "\n" >> hFlush h

-- Decodes one key press, reading escape sequences with a short timeout.
readLineEvent :: IO (Maybe LineEvent)
readLineEvent = do
    key <- readKey
    case key of
        Nothing -> return Nothing
        Just '\r' -> return (Just LineSubmit)
        Just '\n' -> return (Just LineSubmit)
        Just '\DEL' -> return (Just (LineEdit LineBackspace))
        Just '\b' -> return (Just (LineEdit LineBackspace))
        Just '\ESC' -> readEscapeSequence
        Just c
            | isPrint c -> return (Just (LineEdit (LineInsert c)))
            | otherwise -> return (Just LineIgnore)

-- A lone Escape cancels; a bracket starts a control sequence.
readEscapeSequence :: IO (Maybe LineEvent)
readEscapeSequence = do
    next <- waitKey escapeDelay
    case next of
        KeyTimeout -> return (Just LineCancel)
        KeyEnded -> return Nothing
        KeyReady '[' -> readControlSequence
        KeyReady _ -> return (Just LineIgnore)

-- Decodes the final byte of a CSI sequence. Unknown sequences are ignored.
readControlSequence :: IO (Maybe LineEvent)
readControlSequence = do
    next <- waitKey escapeDelay
    case next of
        KeyTimeout -> return (Just LineIgnore)
        KeyEnded -> return Nothing
        KeyReady c -> case c of
            'D' -> return (Just (LineEdit LineMoveLeft))
            'C' -> return (Just (LineEdit LineMoveRight))
            'H' -> return (Just (LineEdit LineMoveHome))
            'F' -> return (Just (LineEdit LineMoveEnd))
            '1' -> readTildeSequence LineMoveHome
            '4' -> readTildeSequence LineMoveEnd
            '3' -> readTildeSequence LineDelete
            _ -> return (Just LineIgnore)

-- Reads the final tilde of a numbered CSI sequence.
readTildeSequence :: LineKey -> IO (Maybe LineEvent)
readTildeSequence key = do
    next <- waitKey escapeDelay
    case next of
        KeyTimeout -> return (Just LineIgnore)
        KeyEnded -> return Nothing
        KeyReady '~' -> return (Just (LineEdit key))
        KeyReady _ -> return (Just LineIgnore)