Skip to content

Commit 9f13c15

Browse files
authored
support non-controlling terminals (#156)
* Add `useTermHandles` and `useTermHandlesWith` behaviors for explicitly providing term handles and term type * add testing for useTermHandles[With] * update changelog with useTermHandles * change serial example to websocket/pty
1 parent d8e398b commit 9f13c15

10 files changed

Lines changed: 286 additions & 56 deletions

File tree

Changelog

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ Changed in unreleased:
33
* System.Console.Haskeline.Internal now exposes far more internals of
44
Haskeline.
55

6+
* Added `useTermHandles` and `useTermHandlesWith` Behaviors for driving
7+
Haskeline against caller-supplied input/output handles (e.g. a serial
8+
console or a PTY pair other than the controlling terminal). POSIX only.
9+
610
Changed in version 0.8.4.1:
711

812
* Implemented ; and , movements and enabled them for d, c, and y actions.

System/Console/Haskeline.hs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ module System.Console.Haskeline(
3939
defaultBehavior,
4040
useFileHandle,
4141
useFile,
42+
#ifndef MINGW
43+
useTermHandles,
44+
useTermHandlesWith,
45+
#endif
4246
preferTerm,
4347
-- * User interaction functions
4448
-- ** Reading user input

System/Console/Haskeline/Backend.hs

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,27 +23,39 @@ defaultRunTerm = (liftIO (hGetEcho stdin) >>= guard >> stdinTTY)
2323
terminalRunTerm :: IO RunTerm
2424
terminalRunTerm = directTTY `orElse` fileHandleRunTerm stdin
2525

26+
#ifndef MINGW
27+
useTermHandlesRunTerm :: Maybe String -> Handle -> Handle -> IO RunTerm
28+
useTermHandlesRunTerm termtype input output =
29+
explicitTTY termtype input output `orElse` fileHandleRunTerm input
30+
#endif
31+
2632
stdinTTY :: MaybeT IO RunTerm
2733
#ifdef MINGW
2834
stdinTTY = win32TermStdin
2935
#else
30-
stdinTTY = stdinTTYHandles >>= runDraw
36+
stdinTTY = stdinTTYHandles >>= runDraw Nothing
3137
#endif
3238

3339
directTTY :: MaybeT IO RunTerm
3440
#ifdef MINGW
3541
directTTY = win32Term
3642
#else
37-
directTTY = ttyHandles >>= runDraw
43+
directTTY = ttyHandles >>= runDraw Nothing
44+
#endif
45+
46+
#ifndef MINGW
47+
explicitTTY :: Maybe String -> Handle -> Handle -> MaybeT IO RunTerm
48+
explicitTTY termtype input output =
49+
explicitTTYHandles input output >>= runDraw termtype
3850
#endif
3951

4052

4153
#ifndef MINGW
42-
runDraw :: Handles -> MaybeT IO RunTerm
54+
runDraw :: Maybe String -> Handles -> MaybeT IO RunTerm
4355
#ifndef TERMINFO
44-
runDraw = runDumbTerm
56+
runDraw _termtype = runDumbTerm
4557
#else
46-
runDraw h = runTerminfoDraw h `mplus` runDumbTerm h
58+
runDraw termtype h = runTerminfoDraw termtype h `mplus` runDumbTerm h
4759
#endif
4860
#endif
4961

System/Console/Haskeline/Backend/Posix.hsc

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ module System.Console.Haskeline.Backend.Posix (
1111
mapLines,
1212
stdinTTYHandles,
1313
ttyHandles,
14+
explicitTTYHandles,
1415
posixRunTerm,
1516
fileRunTerm
1617
) where
@@ -286,6 +287,15 @@ openTerm :: IOMode -> MaybeT IO ExternalHandle
286287
openTerm mode = handle (\(_::IOException) -> mzero)
287288
$ liftIO $ openInCodingMode "/dev/tty" mode
288289

290+
explicitTTYHandles :: Handle -> Handle -> MaybeT IO Handles
291+
explicitTTYHandles h_in h_out = do
292+
isInTerm <- liftIO $ hIsTerminalDevice h_in
293+
guard isInTerm
294+
return Handles
295+
{ hIn = externalHandle h_in
296+
, hOut = externalHandle h_out
297+
, closeHandles = return ()
298+
}
289299

290300
posixRunTerm ::
291301
Handles

System/Console/Haskeline/Backend/Terminfo.hs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -125,9 +125,9 @@ evalDraw term actions = EvalTerm eval liftE
125125
. unDraw
126126

127127

128-
runTerminfoDraw :: Handles -> MaybeT IO RunTerm
129-
runTerminfoDraw h = do
130-
mterm <- liftIO $ Exception.try setupTermFromEnv
128+
runTerminfoDraw :: Maybe String -> Handles -> MaybeT IO RunTerm
129+
runTerminfoDraw termtype h = do
130+
mterm <- liftIO $ Exception.try $ maybe setupTermFromEnv setupTerm termtype
131131
case mterm of
132132
Left (_::SetupTermError) -> mzero
133133
Right term -> do

System/Console/Haskeline/InputT.hs

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,70 @@ useFile file = Behavior $ do
216216
preferTerm :: Behavior
217217
preferTerm = Behavior terminalRunTerm
218218

219+
#ifndef MINGW
220+
-- | Use terminal-style interaction on the given input and output handles,
221+
-- taking the terminal type from the @TERM@ environment variable.
222+
--
223+
-- This behavior is for driving Haskeline against a terminal that is not the
224+
-- process's controlling terminal — for example, a serial console, a PTY pair
225+
-- you opened yourself, or a socket-backed TTY. The caller is responsible for
226+
-- closing @input@ and @output@ after use. Not available on Windows.
227+
--
228+
-- See 'useTermHandlesWith' to override the terminal type.
229+
useTermHandles :: Handle -> Handle -> Behavior
230+
useTermHandles input output =
231+
Behavior $ useTermHandlesRunTerm Nothing input output
232+
233+
-- | Like 'useTermHandles', but with the terminal type given explicitly
234+
-- (e.g. @\"xterm-256color\"@ or @\"vt100\"@) instead of read from the @TERM@
235+
-- environment variable.
236+
--
237+
-- The terminal type is only consulted when haskeline is built with terminfo
238+
-- support; in non-terminfo builds it is ignored and a dumb terminal is used.
239+
--
240+
-- ==== __Example: a Haskeline session over a WebSocket__
241+
--
242+
-- Bridge a WebSocket to the master end of a PTY pair and run Haskeline
243+
-- against the slave end. Uses the @websockets@ and @unix@ packages.
244+
-- Pair this with a browser-side terminal emulator such as
245+
-- <https://xtermjs.org/ Xterm.js> for an in-browser shell.
246+
--
247+
-- > import qualified Network.WebSockets as WS
248+
-- > import System.Posix.Terminal (openPseudoTerminal)
249+
-- > import System.Posix.IO (dup, fdToHandle)
250+
-- > import Control.Applicative ((<|>))
251+
-- > import Control.Concurrent.Async (Concurrently(..), runConcurrently)
252+
-- > import Control.Monad (forever)
253+
-- > import qualified Data.ByteString as BS
254+
-- > import System.IO (hSetBuffering, BufferMode(..))
255+
-- > import System.Console.Haskeline
256+
-- >
257+
-- > websocketUI :: WS.Connection -> IO ()
258+
-- > websocketUI conn = do
259+
-- > (master, slave) <- openPseudoTerminal
260+
-- > slaveDup <- dup slave
261+
-- > masterH <- fdToHandle master
262+
-- > slaveIn <- fdToHandle slave
263+
-- > slaveOut <- fdToHandle slaveDup
264+
-- > hSetBuffering masterH NoBuffering
265+
-- > -- Whichever of the three actions finishes first cancels the others.
266+
-- > runConcurrently
267+
-- > $ Concurrently (forever $ WS.receiveData conn >>= BS.hPut masterH)
268+
-- > <|> Concurrently (forever $ BS.hGetSome masterH 4096 >>= WS.sendBinaryData conn)
269+
-- > <|> Concurrently (runInputTBehavior
270+
-- > (useTermHandlesWith "vt100" slaveIn slaveOut)
271+
-- > defaultSettings loop)
272+
-- > where
273+
-- > loop = do
274+
-- > minput <- getInputLine "% "
275+
-- > case minput of
276+
-- > Nothing -> return ()
277+
-- > Just "quit" -> return ()
278+
-- > Just s -> outputStrLn ("got: " ++ s) >> loop
279+
useTermHandlesWith :: String -> Handle -> Handle -> Behavior
280+
useTermHandlesWith termtype input output =
281+
Behavior $ useTermHandlesRunTerm (Just termtype) input output
282+
#endif
219283

220284
-- | Read 'Prefs' from @$XDG_CONFIG_HOME/haskeline/haskeline@ if present
221285
-- ortherwise @~/.haskeline.@ If there is an error reading the file,

examples/Test.hs

Lines changed: 123 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,138 @@
1+
{-# LANGUAGE CPP #-}
12
module Main where
23

34
import System.Console.Haskeline
4-
import System.Environment
5+
import Options.Applicative
6+
import Data.Monoid ((<>))
7+
#ifndef MINGW
8+
import System.IO (openFile, hClose, IOMode(..))
9+
#endif
510

611
{--
712
Testing the line-input functions and their interaction with ctrl-c signals.
813
914
Usage:
10-
./Test (line input)
11-
./Test chars (character input)
12-
./Test password (no masking characters)
13-
./Test password \*
14-
./Test initial (use initial text in the prompt)
15+
./Test (line input, default Behavior)
16+
./Test chars (character input)
17+
./Test password (no masking)
18+
./Test password \* (masking with '*')
19+
./Test initial (initial text in the prompt)
20+
21+
./Test --mode useTermHandles (POSIX only)
22+
./Test --mode useTermHandlesWith --term-type vt100
23+
(POSIX only)
24+
25+
The --mode flag selects the haskeline Behavior; positional INPUT_ARG
26+
selects which prompt function to use, and works with any --mode.
1527
--}
1628

29+
data Mode
30+
= UseTerm -- ^ defaultBehavior
31+
| UseTermHandles -- ^ useTermHandles on /dev/tty
32+
| UseTermHandlesWith String -- ^ useTermHandlesWith TERM on /dev/tty
33+
34+
data InputMode
35+
= LineInput
36+
| CharInput
37+
| PasswordInput (Maybe Char)
38+
| InitialInput
39+
40+
data Opts = Opts { optMode :: Mode, optInput :: InputMode }
41+
1742
mySettings :: Settings IO
1843
mySettings = defaultSettings {historyFile = Just "myhist"}
1944

2045
main :: IO ()
2146
main = do
22-
args <- getArgs
23-
let inputFunc = case args of
24-
["chars"] -> fmap (fmap (\c -> [c])) . getInputChar
25-
["password"] -> getPassword Nothing
26-
["password", [c]] -> getPassword (Just c)
27-
["initial"] -> flip getInputLineWithInitial ("left ", "right")
28-
_ -> getInputLine
29-
runInputT mySettings $ withInterrupt $ loop inputFunc 0
30-
where
31-
loop :: (String -> InputT IO (Maybe String)) -> Int -> InputT IO ()
32-
loop inputFunc n = do
33-
minput <- handleInterrupt (return (Just "Caught interrupted"))
34-
$ inputFunc (show n ++ ":")
35-
case minput of
36-
Nothing -> return ()
37-
Just "quit" -> return ()
38-
Just "q" -> return ()
39-
Just s -> do
40-
outputStrLn ("line " ++ show n ++ ":" ++ s)
41-
loop inputFunc (n+1)
47+
Opts {optMode = m, optInput = im} <- execParser optsInfo
48+
case m of
49+
UseTerm ->
50+
runInputT mySettings (runAction im)
51+
#ifndef MINGW
52+
UseTermHandles ->
53+
runWithHandles Nothing im
54+
UseTermHandlesWith t ->
55+
runWithHandles (Just t) im
56+
#else
57+
_ -> error "useTermHandles[With] is not available on Windows"
58+
#endif
59+
60+
runAction :: InputMode -> InputT IO ()
61+
runAction im = withInterrupt $ loop (inputFunc im) 0
62+
63+
inputFunc :: InputMode -> String -> InputT IO (Maybe String)
64+
inputFunc LineInput = getInputLine
65+
inputFunc CharInput = fmap (fmap (\c -> [c])) . getInputChar
66+
inputFunc (PasswordInput mc) = getPassword mc
67+
inputFunc InitialInput = flip getInputLineWithInitial ("left ", "right")
68+
69+
loop :: (String -> InputT IO (Maybe String)) -> Int -> InputT IO ()
70+
loop f n = do
71+
minput <- handleInterrupt (return (Just "Caught interrupted"))
72+
$ f (show n ++ ":")
73+
case minput of
74+
Nothing -> return ()
75+
Just "quit" -> return ()
76+
Just "q" -> return ()
77+
Just s -> do
78+
outputStrLn ("line " ++ show n ++ ":" ++ s)
79+
loop f (n+1)
80+
81+
#ifndef MINGW
82+
-- Drive Haskeline against /dev/tty (the controlling terminal) via the
83+
-- useTermHandles[With] Behaviors. This still happens to be the controlling
84+
-- tty in our test setup, but it goes through the new code path.
85+
runWithHandles :: Maybe String -> InputMode -> IO ()
86+
runWithHandles mTerm im = do
87+
input <- openFile "/dev/tty" ReadMode
88+
output <- openFile "/dev/tty" WriteMode
89+
let beh = case mTerm of
90+
Nothing -> useTermHandles input output
91+
Just t -> useTermHandlesWith t input output
92+
runInputTBehavior beh mySettings (runAction im)
93+
hClose input
94+
hClose output
95+
#endif
96+
97+
----------------------------------------------------------------
98+
-- Command-line parsing
99+
100+
optsInfo :: ParserInfo Opts
101+
optsInfo = info (optsP <**> helper)
102+
(fullDesc <> progDesc "Haskeline test program")
103+
104+
optsP :: Parser Opts
105+
optsP = Opts <$> modeP <*> inputModeP
106+
107+
modeP :: Parser Mode
108+
modeP = mkMode
109+
<$> strOption
110+
( long "mode"
111+
<> value "useTerm"
112+
<> showDefault
113+
<> metavar "MODE"
114+
<> help "useTerm | useTermHandles | useTermHandlesWith" )
115+
<*> optional
116+
(strOption
117+
( long "term-type"
118+
<> metavar "TERM"
119+
<> help "term type for --mode useTermHandlesWith" ))
120+
where
121+
mkMode "useTerm" _ = UseTerm
122+
mkMode "useTermHandles" _ = UseTermHandles
123+
mkMode "useTermHandlesWith" (Just t) = UseTermHandlesWith t
124+
mkMode "useTermHandlesWith" Nothing =
125+
error "--mode useTermHandlesWith requires --term-type"
126+
mkMode other _ =
127+
error ("unknown --mode: " ++ other)
128+
129+
inputModeP :: Parser InputMode
130+
inputModeP = mkInput <$> many (argument str (metavar "INPUT_ARG"))
131+
where
132+
mkInput [] = LineInput
133+
mkInput ["chars"] = CharInput
134+
mkInput ["password"] = PasswordInput Nothing
135+
mkInput ["password", [c]] = PasswordInput (Just c)
136+
mkInput ["initial"] = InitialInput
137+
mkInput xs =
138+
error ("unrecognized positional args: " ++ show xs)

haskeline.cabal

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ Executable haskeline-examples-Test
169169
if !flag(examples) {
170170
buildable: False
171171
}
172-
Build-depends: base, containers, haskeline
172+
Build-depends: base, containers, haskeline, optparse-applicative
173173
Default-Language: Haskell2010
174174
hs-source-dirs: examples
175175
Main-Is: Test.hs

tests/RunTTY.hs

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,18 @@ import Pty
2323

2424
data Invocation = Invocation {
2525
prog :: FilePath
26-
, progArgs :: [String]
26+
-- | Mode-selection flags passed before any positional args
27+
-- (e.g. @[\"--mode\", \"useTermHandles\"]@).
28+
, progModeArgs :: [String]
29+
-- | Positional input-mode args (e.g. @[\"chars\"]@).
30+
, progInputArgs :: [String]
2731
, runInTTY :: Bool
2832
, environment :: [(String,String)]
2933
}
3034

35+
progArgs :: Invocation -> [String]
36+
progArgs Invocation{..} = progModeArgs ++ progInputArgs
37+
3138
setEnv :: String -> String -> Invocation -> Invocation
3239
setEnv var val Invocation {..} = Invocation{
3340
environment = (var,val) : Prelude.filter ((/=var).fst) environment
@@ -51,11 +58,11 @@ runInvocation :: Invocation
5158
-- simulate real user input and prevent Haskeline
5259
-- from coalescing the changes.)
5360
-> IO [B.ByteString]
54-
runInvocation Invocation {..} inputs
55-
| runInTTY = runCommandInPty prog progArgs (Just environment) inputs
61+
runInvocation inv@Invocation {..} inputs
62+
| runInTTY = runCommandInPty prog (progArgs inv) (Just environment) inputs
5663
| otherwise = do
5764
(Just inH, Just outH, Nothing, ph)
58-
<- createProcess (proc prog progArgs)
65+
<- createProcess (proc prog (progArgs inv))
5966
{ env = Just environment
6067
, std_in = CreatePipe
6168
, std_out = CreatePipe

0 commit comments

Comments
 (0)