ansiform-1.0.0.1: 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 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
--------------------------------------------------------------------------------
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)
-- 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)