prompt-hs-1.0.0.0: src/System/Prompt.hs
{-# LANGUAGE BlockArguments #-}
{-# LANGUAGE ConstraintKinds #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DefaultSignatures #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE InstanceSigs #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE StrictData #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE TypeOperators #-}
{-# LANGUAGE UndecidableInstances #-}
-- |
-- Module : System.Prompt
-- Description : A user-friendly, dependently-typed library for asking your users questions
-- License : BSD-3-Clause
--
-- @
-- data Colour = Red | Green | Blue deriving (Bounded, Enum, Eq, Show)
--
-- instance Chooseable Colour where
-- showChooseable = pack . show
--
-- main :: IO ()
-- main = do
-- name <- promptText Required RequireConfirmation $ pack "What is your name?"
-- favouriteColour <- promptChoice Optional DontConfirm (Proxy :: Proxy Colour) $ pack "And what is your favourite colour?"
-- putStrLn $
-- "Your name is " <> unpack name <> " and " <> case favouriteColour of
-- Just c -> "your favourite colour is " <> show c
-- Nothing -> "you didn't tell me your favourite colour."
-- @
module System.Prompt
( -- * Actions
promptText,
promptChoice,
promptChoiceFromSet,
-- * Data
Chooseable (..),
ChooseableItem (..),
Requirement (..),
PerRequirement,
Confirmation (..),
)
where
import Control.Monad (forM_)
import Control.Monad.IO.Class (MonadIO (liftIO))
import qualified Data.Char as C
import Data.Kind (Type)
import Data.List.NonEmpty (NonEmpty ((:|)), (<|))
import qualified Data.List.NonEmpty as NE
import Data.Proxy (Proxy (Proxy))
import Data.Text (Text)
import qualified Data.Text as T
import Lens.Micro (Lens', lens, (%~), (&), (.~))
import System.Exit (exitFailure)
import System.Prompt.Requirement
( Requirement (Optional, Required),
SRequirement (SOptional, SRequired),
)
import System.Terminal
( Direction (Downwards, Upwards),
Event (KeyEvent),
Interrupt (Interrupt),
Key (ArrowKey, BackspaceKey, CharKey, EnterKey, EscapeKey),
MonadColorPrinter (blue, cyan, foreground, magenta, yellow),
MonadFormattingPrinter (bold, italic),
MonadInput,
MonadMarkupPrinter (Attribute, resetAttributes, setAttribute),
MonadPrinter (flush, putText, putTextLn),
MonadScreen (deleteLines, moveCursorUp),
awaitEvent,
ctrlKey,
runTerminalT,
withTerminal,
)
-- | Things which can be chosen, following a prompt.
--
-- Most methods are automatically implemented for types with instances
-- of `Bounded` and `Enum`.
class Chooseable a where
-- | Display the option in a user-friendly manner
showChooseable :: a -> Text
-- | Get the initially-selected value of `a`
initialSelectionChooseable :: a
default initialSelectionChooseable :: (Bounded a) => a
initialSelectionChooseable = minBound
-- | Get all values of `a`
universeChooseableNE :: NonEmpty a
default universeChooseableNE :: (Bounded a, Enum a) => NonEmpty a
universeChooseableNE = minBound :| drop 1 [minBound .. maxBound]
instance (Chooseable a) => Chooseable (Maybe a) where
showChooseable :: Maybe a -> Text
showChooseable (Just a) = showChooseable a
showChooseable Nothing = "Skip"
initialSelectionChooseable :: Maybe a
initialSelectionChooseable = Nothing
universeChooseableNE :: NonEmpty (Maybe a)
universeChooseableNE = Nothing <| (Just <$> universeChooseableNE)
-- | Things which can be chosen, but which are not part of a sum type.
class ChooseableItem a where
-- | Display the option in a user-friendly manner
chooseableItemText :: a -> Text
-- | Get the initially-selected value of `a`
initialSelectionChooseableItem :: a
instance (Chooseable a) => ChooseableItem a where
chooseableItemText :: a -> Text
chooseableItemText = showChooseable
initialSelectionChooseableItem :: a
initialSelectionChooseableItem = initialSelectionChooseable
-- | Returns `a` if the question is required, or `Maybe` `a` otherwise.
--
-- Represents what you'd expect to be the response to the given question, wrapped in `Maybe` for `Optional` questions.
type family PerRequirement (requirement :: SRequirement) (a :: Type) where
PerRequirement 'SRequired a = a
PerRequirement 'SOptional a = Maybe a
-- | Whether to get confirmation from the user before accepting their answer.
data Confirmation
= -- | Get confirmation
RequireConfirmation
| -- | Don't get confirmation
DontConfirm
-- Represents instructions shown to the user while prompting them to make a choice
data ChoiceInstruction
= ChoiceInstructionNormal
| ChoiceInstructionNoOptionSelected
-- | Gets the text which will be displayed to the user for a given instruction.
instructionText :: ChoiceInstruction -> Text
instructionText = \case
ChoiceInstructionNormal -> "You can type to search, or use the arrow keys. Press enter to select."
ChoiceInstructionNoOptionSelected -> "No option selected. Try expanding your filter with backspace to see more options."
data PromptChoiceState (requirement :: SRequirement) (a :: Type) = PromptChoiceState
{ pcsConfirmation :: Confirmation,
pcsFilter :: Text,
pcsInstruction :: ChoiceInstruction,
pcsOptions :: NonEmpty (PerRequirement requirement a),
pcsFilteredOptions :: [PerRequirement requirement a],
pcsSelectedOption :: Maybe (PerRequirement requirement a)
}
_pcsFilter :: Lens' (PromptChoiceState requirement a) Text
_pcsFilter = lens pcsFilter \s x -> s {pcsFilter = x}
_pcsInstruction :: Lens' (PromptChoiceState requirement a) ChoiceInstruction
_pcsInstruction = lens pcsInstruction \s x -> s {pcsInstruction = x}
_pcsFilteredOptions :: Lens' (PromptChoiceState requirement a) [PerRequirement requirement a]
_pcsFilteredOptions = lens pcsFilteredOptions \s x -> s {pcsFilteredOptions = x}
_pcsSelectedOption :: Lens' (PromptChoiceState requirement a) (Maybe (PerRequirement requirement a))
_pcsSelectedOption = lens pcsSelectedOption \s x -> s {pcsSelectedOption = x}
data PromptTextState (requirement :: SRequirement) = PromptTextState
{ ptsConfirmation :: Confirmation,
ptsCurrentInput :: Text,
ptsPrompt :: Text,
ptsRequirement :: Requirement requirement
}
_ptsCurrentInput :: Lens' (PromptTextState requirement) Text
_ptsCurrentInput = lens ptsCurrentInput \s x -> s {ptsCurrentInput = x}
type Renderable m requirement a =
( ChooseableItem (PerRequirement requirement a),
Eq (PerRequirement requirement a),
MonadColorPrinter m,
MonadFormattingPrinter m,
MonadMarkupPrinter m,
MonadScreen m
)
-- | Ask the user to choose between the constructors of a sum type.
--
-- @
-- data Colour = Red | Green | Blue deriving (Bounded, Enum, Eq, Show)
--
-- instance Chooseable Colour where
-- showChooseable = Data.Text.pack . show
--
-- main :: IO ()
-- main = do
-- favouriteColour <- promptChoice Optional DontConfirm (Proxy :: Proxy Colour) $ "What is your favourite colour?"
-- print favouriteColour
-- @
promptChoice ::
(result ~ PerRequirement requirement a, Chooseable result, Eq result, MonadIO m) =>
-- | May they skip the question?
Requirement requirement ->
-- | Should they be asked to confirm their answer after selecting it?
Confirmation ->
-- | Proxy of type you want to get back
Proxy a ->
-- | What should the prompt say? (You need not add a space to the end of this text; one will be added)
Text ->
m result
promptChoice requirement confirmation pxy prompt =
liftIO . withTerminal . runTerminalT $
promptChoiceInternal pxy prompt (initialPromptChoiceState requirement confirmation)
-- | Ask the user to choose between arbitrary options.
--
-- Note: duplicate options will be removed.
promptChoiceFromSet ::
(ChooseableItem result, Eq result, MonadIO m) =>
-- | Should they be asked to confirm their answer after selecting it?
Confirmation ->
-- | The items from which they can choose (if you don't require an answer, add a "Skip" option)
NonEmpty result ->
-- | What should the prompt say? (You need not add a space to the end of this text; one will be added)
Text ->
m result
promptChoiceFromSet confirmation set prompt =
liftIO . withTerminal . runTerminalT $
promptChoiceInternal Proxy prompt (initialPromptChoiceStateFromSet confirmation $ NE.nub set)
-- | Ask the user to enter text.
--
-- The answer is stripped of leading and trailing whitespace.
--
-- > promptText Required DontConfirm "What is your name?"
--
-- > promptText Optional RequireConfirmation "What is your greatest fear?"
promptText ::
(result ~ PerRequirement requirement Text, MonadIO m) =>
-- | May they skip the question?
Requirement requirement ->
-- | Should they be asked to confirm their answer after entering it?
Confirmation ->
-- | What should the prompt say? (You need not add a space to the end of this text; one will be added)
Text ->
m result
promptText requirement confirmation prompt =
liftIO . withTerminal . runTerminalT $
promptTextInternal
PromptTextState
{ ptsConfirmation = confirmation,
ptsCurrentInput = "",
ptsPrompt = prompt,
ptsRequirement = requirement
}
initialPromptChoiceState ::
(Chooseable (PerRequirement requirement a)) =>
Requirement requirement ->
Confirmation ->
PromptChoiceState requirement a
initialPromptChoiceState _ confirmation =
PromptChoiceState
{ pcsConfirmation = confirmation,
pcsFilter = "",
pcsInstruction = ChoiceInstructionNormal,
pcsOptions = universeChooseableNE,
pcsFilteredOptions = NE.toList universeChooseableNE,
pcsSelectedOption = Just initialSelectionChooseable
}
initialPromptChoiceStateFromSet ::
(ChooseableItem a) =>
Confirmation ->
NonEmpty a ->
PromptChoiceState 'SRequired a
initialPromptChoiceStateFromSet confirmation options =
PromptChoiceState
{ pcsConfirmation = confirmation,
pcsFilter = "",
pcsInstruction = ChoiceInstructionNormal,
pcsOptions = options,
pcsFilteredOptions = NE.toList options,
pcsSelectedOption = Just initialSelectionChooseableItem
}
promptChoiceInternal ::
(MonadInput m, Renderable m requirement a) =>
Proxy a ->
Text ->
PromptChoiceState requirement a ->
m (PerRequirement requirement a)
promptChoiceInternal _ prompt s = do
linesRendered <- renderPromptChoicePrompt prompt s
awaitEvent >>= handlePromptChoiceEvent prompt linesRendered s
promptTextInternal ::
(MonadColorPrinter m, MonadFormattingPrinter m, MonadInput m, MonadScreen m) =>
PromptTextState requirement ->
m (PerRequirement requirement Text)
promptTextInternal s = do
renderPrompt $ ptsPrompt s
withAttributes [foreground yellow] . putText $ ptsCurrentInput s
flush
awaitEvent >>= handlePromptTextEvent s
-- | Renders the prompt for the user and returns the number of lines output
renderPromptChoicePrompt ::
(Renderable m requirement a) =>
Text ->
PromptChoiceState requirement a ->
m Int
renderPromptChoicePrompt prompt s = do
renderPromptLine prompt
linesRendered <- renderOptionLines s
renderSummaryLine s linesRendered
renderInstructionLine
renderFilterLine
pure $ linesRendered + 4
where
renderInstructionLine = withAttributes [italic] . putTextLn . instructionText $ pcsInstruction s
renderFilterLine = do
withAttributes [bold, foreground magenta] $ putText "> "
putText $ pcsFilter s
flush
renderPrompt :: (MonadColorPrinter m, MonadFormattingPrinter m) => Text -> m ()
renderPrompt = withAttributes [bold, foreground blue] . putText . (<> " ")
renderPromptLine :: (MonadColorPrinter m, MonadFormattingPrinter m) => Text -> m ()
renderPromptLine = withAttributes [bold, foreground blue] . putTextLn
renderSummaryLine :: (Renderable m requirement a) => PromptChoiceState requirement a -> Int -> m ()
renderSummaryLine s numberOfOptionsRendered = do
let nAll = length $ pcsOptions s
nFiltered = length $ pcsFilteredOptions s
withAttributes [italic, foreground cyan] . putTextLn $
T.pack (show nFiltered)
<> "/"
<> T.pack (show nAll)
<> " included by filter, "
<> T.pack (show numberOfOptionsRendered)
<> " shown."
renderOptionLines ::
forall requirement a m.
(Renderable m requirement a) =>
PromptChoiceState requirement a ->
m Int
renderOptionLines s = do
let (selectedOption, otherOptions) = getVisibleOptions s
case selectedOption of
Just o -> withAttributes [bold, foreground yellow] . putTextLn . ("* " <>) $ chooseableItemText o
Nothing -> pure ()
forM_ otherOptions (withAttributes [foreground yellow] . putTextLn . (" " <>) . chooseableItemText)
pure $ length otherOptions + maybe 0 (const 1) selectedOption
-- | Returns a maximum of 6 options, including the one currently selected as the first option.
getVisibleOptions ::
forall requirement a result.
(result ~ PerRequirement requirement a, Eq result) =>
PromptChoiceState requirement a ->
(Maybe result, [result])
getVisibleOptions s = case pcsSelectedOption s of
Just o | o `elem` filteredOptions -> (Just o, take 5 $ filter (/= o) filteredOptions)
_ -> (Nothing, take 6 filteredOptions)
where
maxNumberOfOptions :: Int
maxNumberOfOptions = length $ pcsFilteredOptions s
filteredOptions :: [result]
filteredOptions = take maxNumberOfOptions . dropWhile isNotSelectedOption . cycle $ pcsFilteredOptions s
isNotSelectedOption :: result -> Bool
isNotSelectedOption o = case pcsSelectedOption s of
Just target -> o /= target
Nothing -> False
handlePromptChoiceEvent ::
forall m requirement a.
(Renderable m requirement a, MonadInput m) =>
Text ->
Int ->
PromptChoiceState requirement a ->
Either Interrupt Event ->
m (PerRequirement requirement a)
handlePromptChoiceEvent prompt linesRendered s e = do
moveCursorUp (linesRendered - 1)
deleteLines linesRendered
case e of
Left Interrupt -> liftIO exitFailure
Right (KeyEvent key modifiers) -> case key of
ArrowKey direction -> case direction of
Upwards ->
goAgain $
s
{ pcsSelectedOption =
headViaNonEmpty
$ drop 1
. dropWhile (maybe (const False) (/=) (pcsSelectedOption s))
. cycle
. reverse
$ pcsFilteredOptions s
}
Downwards -> goAgain $ s {pcsSelectedOption = headViaNonEmpty . snd $ getVisibleOptions s}
_ -> goAgain s
BackspaceKey ->
goAgain $
s
& _pcsFilter %~ T.dropEnd 1
& applyFilter
& _pcsInstruction %~ \case
ChoiceInstructionNoOptionSelected -> ChoiceInstructionNormal
currentInstruction -> currentInstruction
EnterKey -> case pcsSelectedOption s of
Just o -> case pcsConfirmation s of
RequireConfirmation -> confirmSelection prompt o (goAgain s)
DontConfirm -> do
renderPrompt prompt
withAttributes [bold, foreground yellow] . putTextLn $ chooseableItemText o
pure o
Nothing -> goAgain $ s {pcsInstruction = ChoiceInstructionNoOptionSelected}
CharKey c ->
if modifiers == mempty
then
if null (pcsFilteredOptions s)
then goAgain $ s {pcsInstruction = ChoiceInstructionNoOptionSelected}
else goAgain $ s & _pcsFilter %~ flip T.snoc c & applyFilter
else goAgain s
_ -> goAgain s
_ -> goAgain s
where
goAgain :: PromptChoiceState requirement a -> m (PerRequirement requirement a)
goAgain = promptChoiceInternal Proxy prompt
handlePromptTextEvent ::
(MonadColorPrinter m, MonadFormattingPrinter m, MonadInput m, MonadScreen m) =>
PromptTextState requirement ->
Either Interrupt Event ->
m (PerRequirement requirement Text)
handlePromptTextEvent s e = do
deleteLines 1
case e of
Left Interrupt -> liftIO exitFailure
Right (KeyEvent key modifiers) -> case key of
BackspaceKey -> promptTextInternal $ s & _ptsCurrentInput %~ T.dropEnd 1
EnterKey -> confirmTextInput s $ promptTextInternal s
CharKey c ->
if modifiers == mempty
then promptTextInternal $ s & _ptsCurrentInput %~ (`T.snoc` c)
else case c of
'U'
| modifiers == ctrlKey ->
promptTextInternal $ s {ptsCurrentInput = ""}
'W'
| modifiers == ctrlKey ->
promptTextInternal $ s & _ptsCurrentInput %~ (T.dropWhile C.isSpace . T.dropWhileEnd (not . C.isSpace))
_ -> promptTextInternal s
_ -> promptTextInternal s
_ -> promptTextInternal s
-- | Applies the filter in `_pcsFilter` to set `_pcsOptions` and update `_pcsSelectedOption`
-- if the previously selected option is no longer included in the filter.
applyFilter ::
(result ~ PerRequirement requirement a, ChooseableItem result, Eq result) =>
PromptChoiceState requirement a ->
PromptChoiceState requirement a
applyFilter s =
let newFilteredOptions =
filter
((T.toLower (pcsFilter s) `T.isInfixOf`) . T.toLower . chooseableItemText)
(NE.toList $ pcsOptions s)
in s
& _pcsFilteredOptions .~ newFilteredOptions
& _pcsSelectedOption %~ \case
Just o | o `elem` newFilteredOptions -> Just o
_ -> headViaNonEmpty newFilteredOptions
confirmSelection ::
(ChooseableItem a, MonadColorPrinter m, MonadFormattingPrinter m, MonadInput m, MonadScreen m) =>
Text ->
a ->
m a ->
m a
confirmSelection prompt selection onCancel = do
renderPromptLine prompt
withAttributes [bold, foreground cyan] $ putText "You have chosen: "
withAttributes [bold, foreground yellow] $ putTextLn (chooseableItemText selection)
withAttributes [italic] $ putTextLn "Press enter again to confirm, or escape to go back."
e <- awaitEvent
moveCursorUp 3 *> deleteLines 4
case e of
Left Interrupt -> liftIO exitFailure
Right (KeyEvent EnterKey _) -> do
renderPrompt prompt
withAttributes [bold, foreground yellow] $ putTextLn (chooseableItemText selection)
pure selection
Right (KeyEvent EscapeKey _) -> onCancel
_ -> confirmSelection prompt selection onCancel
confirmTextInput ::
forall m requirement.
(MonadColorPrinter m, MonadFormattingPrinter m, MonadInput m, MonadScreen m) =>
PromptTextState requirement ->
m (PerRequirement requirement Text) ->
m (PerRequirement requirement Text)
confirmTextInput s onCancel =
if T.null selection && responseIsRequired
then onCancel
else case ptsConfirmation s of
RequireConfirmation -> do
writePromptWithResponse
withAttributes [italic] $ putTextLn "Press enter again to confirm, or escape to go back."
e <- awaitEvent
moveCursorUp 2 *> deleteLines 3
case e of
Left Interrupt -> liftIO exitFailure
Right (KeyEvent EnterKey _) -> do
writePromptWithResponse
returnCurrentInput
Right (KeyEvent EscapeKey _) -> onCancel
_ -> confirmTextInput s onCancel
DontConfirm -> writePromptWithResponse *> returnCurrentInput
where
(prompt, selection) = (ptsPrompt s, T.strip $ ptsCurrentInput s)
responseIsRequired = case ptsRequirement s of Required -> True; Optional -> False
writePromptWithResponse :: m ()
writePromptWithResponse = do
renderPrompt prompt
if T.null selection
then withAttributes [foreground cyan] $ putTextLn "(You did not enter a response)"
else withAttributes [bold, foreground yellow] $ putTextLn selection
returnCurrentInput :: m (PerRequirement requirement Text)
returnCurrentInput = pure $ case (ptsRequirement s, T.null selection) of
(Required, _) -> selection
(Optional, True) -> Nothing
(Optional, False) -> Just selection
-- | Applies the given attributes to the terminal, runs the action,
-- then resets the terminal's attributes.
withAttributes :: (MonadMarkupPrinter m, Foldable t) => t (Attribute m) -> m a -> m a
withAttributes as action = do
resetAttributes
forM_ as setAttribute
result <- action
resetAttributes
pure result
-- | Returns the first element, or `Nothing` if the list is empty.
headViaNonEmpty :: [a] -> Maybe a
headViaNonEmpty =
\case
Just (x :| _) -> Just x
Nothing -> Nothing
. NE.nonEmpty