terminal UI (#44)

* separate terminal IO to separate thread via queues

* terminal input arithmetics (WIP)

* editable multiline terminal input

* print messages above input area

* support Up/Down arrows

* terminal chat: move by word, move to the beginning/end of input

* insert active contact when typing starts

* refactor inserting active contact

* highlight "to contact"

* add username to prompt

* change beginning/end of line keys to shoft-arrow

* remove unused code

* add ctrl arrow key bindings

* add comment for debugging keys in terminal

Co-authored-by: Efim Poberezkin <efim.poberezkin@gmail.com>
This commit is contained in:
Evgeny Poberezkin
2021-02-20 22:26:27 +00:00
committed by Efim Poberezkin
parent 033af51d6d
commit 1251caa4e8
4 changed files with 365 additions and 79 deletions
+1 -3
View File
@@ -1,5 +1,3 @@
*.lock
*.cabal
smp-agent.db
smp-chat.db
smp-chat1.db
*.db
+329
View File
@@ -0,0 +1,329 @@
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
module ChatTerminal
( ChatTerminal (..),
newChatTerminal,
chatTerminal,
updateUsername,
ttyContact,
ttyFromContact,
)
where
import Control.Concurrent (threadDelay)
import Control.Concurrent.Async (race_)
import Control.Concurrent.STM
import Control.Monad
import Data.ByteString.Char8 (ByteString)
import qualified Data.ByteString.Char8 as B
import Data.List (dropWhileEnd)
import Data.Maybe (fromMaybe)
import qualified Data.Text as T
import Data.Text.Encoding
import Numeric.Natural
import Simplex.Messaging.Transport (getLn, putLn)
import qualified System.Console.ANSI as C
import System.IO
import Types
data ChatTerminal = ChatTerminal
{ inputQ :: TBQueue ByteString,
outputQ :: TBQueue ByteString,
activeContact :: TVar (Maybe Contact),
username :: TVar (Maybe Contact),
termState :: TVar TerminalState,
termSize :: (Int, Int),
nextMessageRow :: TVar Int
}
data TerminalState = TerminalState
{ inputPrompt :: String,
inputString :: String,
inputPosition :: Int
}
inputHeight :: TerminalState -> ChatTerminal -> Int
inputHeight ts ct = length (inputPrompt ts <> inputString ts) `div` snd (termSize ct) + 1
data Key
= KeyLeft
| KeyRight
| KeyUp
| KeyDown
| KeyAltLeft
| KeyAltRight
| KeyCtrlLeft
| KeyCtrlRight
| KeyShiftLeft
| KeyShiftRight
| KeyEnter
| KeyBack
| KeyTab
| KeyEsc
| KeyChars String
| KeyUnsupported
deriving (Eq)
newChatTerminal :: Natural -> Maybe Contact -> IO ChatTerminal
newChatTerminal qSize user = do
inputQ <- newTBQueueIO qSize
outputQ <- newTBQueueIO qSize
activeContact <- newTVarIO Nothing
username <- newTVarIO user
termSize <- fromMaybe (0, 0) <$> C.getTerminalSize
let lastRow = fst termSize - 1
termState <- newTVarIO $ newTermState user
nextMessageRow <- newTVarIO lastRow
threadDelay 500000 -- this delay is the same as timeout in getTerminalSize
return ChatTerminal {inputQ, outputQ, activeContact, username, termState, termSize, nextMessageRow}
newTermState :: Maybe Contact -> TerminalState
newTermState user =
TerminalState
{ inputString = "",
inputPosition = 0,
inputPrompt = promptString user
}
chatTerminal :: ChatTerminal -> IO ()
chatTerminal ct =
if termSize ct /= (0, 0)
then do
hSetBuffering stdin NoBuffering
hSetBuffering stdout NoBuffering
hSetEcho stdin False
updateInput ct
run receiveFromTTY' sendToTTY'
else run receiveFromTTY sendToTTY
where
run receive send = race_ (receive ct) (send ct)
receiveFromTTY :: ChatTerminal -> IO ()
receiveFromTTY ct@ChatTerminal {inputQ} =
forever $ getChatLn ct >>= atomically . writeTBQueue inputQ
receiveFromTTY' :: ChatTerminal -> IO ()
receiveFromTTY' ct@ChatTerminal {inputQ, activeContact, termSize, termState} =
forever $
getKey >>= processKey >> updateInput ct
where
processKey :: Key -> IO ()
processKey = \case
KeyEnter -> submitInput
key -> atomically $ do
ac <- readTVar activeContact
modifyTVar termState $ updateTermState ac (snd termSize) key
submitInput :: IO ()
submitInput = do
msg <- atomically $ do
ts <- readTVar termState
writeTVar termState $ ts {inputString = "", inputPosition = 0}
let msg = encodeUtf8 . T.pack $ inputString ts
writeTBQueue inputQ msg
return msg
printMessage ct $ highlightContact msg
updateTermState :: Maybe Contact -> Int -> Key -> TerminalState -> TerminalState
updateTermState ac tw key ts@TerminalState {inputString = s, inputPosition = p} = case key of
KeyChars cs -> insertCharsWithContact cs
KeyTab -> insertChars " "
KeyBack -> backDeleteChar
KeyLeft -> setPosition $ max 0 (p - 1)
KeyRight -> setPosition $ min (length s) (p + 1)
KeyUp -> setPosition $ let p' = p - tw in if p' > 0 then p' else p
KeyDown -> setPosition $ let p' = p + tw in if p' <= length s then p' else p
KeyAltLeft -> setPosition prevWordPos
KeyAltRight -> setPosition nextWordPos
KeyCtrlLeft -> setPosition prevWordPos
KeyCtrlRight -> setPosition nextWordPos
KeyShiftLeft -> setPosition 0
KeyShiftRight -> setPosition $ length s
_ -> ts
where
insertCharsWithContact cs
| null s && cs /= "@" && cs /= "/" =
insertChars $ contactPrefix <> cs
| otherwise = insertChars cs
insertChars = ts' . if p >= length s then append else insert
append cs = let s' = s <> cs in (s', length s')
insert cs = let (b, a) = splitAt p s in (b <> cs <> a, p + length cs)
contactPrefix = case ac of
Just (Contact c) -> "@" <> B.unpack c <> " "
Nothing -> ""
backDeleteChar
| p == 0 || null s = ts
| p >= length s = ts' backDeleteLast
| otherwise = ts' backDelete
backDeleteLast = if null s then (s, 0) else let s' = init s in (s', length s')
backDelete = let (b, a) = splitAt p s in (init b <> a, p - 1)
setPosition p' = ts' (s, p')
prevWordPos
| p == 0 || null s = p
| otherwise =
let before = take p s
beforeWord = dropWhileEnd (/= ' ') $ dropWhileEnd (== ' ') before
in max 0 $ p - length before + length beforeWord
nextWordPos
| p >= length s || null s = p
| otherwise =
let after = drop p s
afterWord = dropWhile (/= ' ') $ dropWhile (== ' ') after
in min (length s) $ p + length after - length afterWord
ts' (s', p') = ts {inputString = s', inputPosition = p'}
highlightContact :: ByteString -> ByteString
highlightContact = \case
"" -> ""
s ->
if B.head s == '@'
then let (c, rest) = B.span (/= ' ') $ B.drop 1 s in ttyToContact (Contact c) <> rest
else s
updateInput :: ChatTerminal -> IO ()
updateInput ct@ChatTerminal {termSize, termState, nextMessageRow} = do
C.hideCursor
ts <- readTVarIO termState
nmr <- readTVarIO nextMessageRow
let (th, tw) = termSize
ih = inputHeight ts ct
iStart = th - ih
prompt = inputPrompt ts
(cRow, cCol) = relativeCursorPosition tw $ length prompt + inputPosition ts
if nmr >= iStart
then atomically $ writeTVar nextMessageRow iStart
else clearLines nmr iStart
C.setCursorPosition (max nmr iStart) 0
putStr $ prompt <> inputString ts <> " "
C.clearFromCursorToLineEnd
C.setCursorPosition (iStart + cRow) cCol
C.showCursor
where
clearLines :: Int -> Int -> IO ()
clearLines from till
| from >= till = return ()
| otherwise = do
C.setCursorPosition from 0
C.clearFromCursorToLineEnd
clearLines (from + 1) till
relativeCursorPosition :: Int -> Int -> (Int, Int)
relativeCursorPosition width pos =
let row = pos `div` width
col = pos - row * width
in (row, col)
updateUsername :: ChatTerminal -> Maybe Contact -> STM ()
updateUsername ct a = do
writeTVar (username ct) a
modifyTVar (termState ct) $ \ts -> ts {inputPrompt = promptString a}
promptString :: Maybe Contact -> String
promptString a = maybe "" (B.unpack . toBs) a <> "> "
sendToTTY :: ChatTerminal -> IO ()
sendToTTY ChatTerminal {outputQ} =
forever $ atomically (readTBQueue outputQ) >>= putLn stdout
sendToTTY' :: ChatTerminal -> IO ()
sendToTTY' ct@ChatTerminal {outputQ} =
forever $ atomically (readTBQueue outputQ) >>= printMessage ct >> updateInput ct
printMessage :: ChatTerminal -> ByteString -> IO ()
printMessage ChatTerminal {termSize, nextMessageRow} msg = do
nmr <- readTVarIO nextMessageRow
C.setCursorPosition nmr 0
let (th, tw) = termSize
lc <- printLines tw msg
atomically . writeTVar nextMessageRow $ min (th - 1) (nmr + lc)
where
printLines :: Int -> ByteString -> IO Int
printLines tw s = do
let ls
| B.null s = [""]
| otherwise = B.lines s <> ["" | B.last s == '\n']
print_ ls
return $ foldl (\lc l -> lc + (B.length l `div` tw) + 1) 0 ls
print_ :: [ByteString] -> IO ()
print_ [] = return ()
print_ (l : ls) = do
B.hPut stdout l
C.clearFromCursorToLineEnd
B.hPut stdout "\n"
print_ ls
getKey :: IO Key
getKey = charsToKey . reverse <$> keyChars ""
where
charsToKey = \case
"\ESC" -> KeyEsc
"\ESC[A" -> KeyUp
"\ESC[B" -> KeyDown
"\ESC[D" -> KeyLeft
"\ESC[C" -> KeyRight
"\ESCb" -> KeyAltLeft
"\ESCf" -> KeyAltRight
"\ESC[1;5D" -> KeyCtrlLeft
"\ESC[1;5C" -> KeyCtrlRight
"\ESC[1;2D" -> KeyShiftLeft
"\ESC[1;2C" -> KeyShiftRight
"\n" -> KeyEnter
"\DEL" -> KeyBack
"\t" -> KeyTab
'\ESC' : _ -> KeyUnsupported
cs -> KeyChars cs
keyChars cs = do
c <- getChar
more <- hReady stdin
-- for debugging - uncomment this, comment line after:
-- (if more then keyChars else \c' -> print (reverse c') >> return c') (c : cs)
(if more then keyChars else return) (c : cs)
getChatLn :: ChatTerminal -> IO ByteString
getChatLn ct = do
setTTY NoBuffering
getChar >>= \case
'/' -> getRest "/"
'@' -> getRest "@"
ch -> do
let s = encodeUtf8 $ T.singleton ch
readTVarIO (activeContact ct) >>= \case
Nothing -> getRest s
Just a -> getWithContact a s
where
getWithContact :: Contact -> ByteString -> IO ByteString
getWithContact a s = do
C.cursorBackward 1
B.hPut stdout $ ttyToContact a <> " " <> s
getRest $ "@" <> toBs a <> " " <> s
getRest :: ByteString -> IO ByteString
getRest s = do
setTTY LineBuffering
(s <>) <$> getLn stdin
setTTY :: BufferMode -> IO ()
setTTY mode = do
hSetBuffering stdin mode
hSetBuffering stdout mode
ttyContact :: Contact -> ByteString
ttyContact (Contact a) = withSGR contactSGR a
ttyFromContact :: Contact -> ByteString
ttyFromContact (Contact a) = withSGR contactSGR $ a <> ">"
ttyToContact :: Contact -> ByteString
ttyToContact (Contact a) = withSGR selfSGR $ "@" <> a
contactSGR :: [C.SGR]
contactSGR = [C.SetColor C.Foreground C.Vivid C.Yellow]
selfSGR :: [C.SGR]
selfSGR = [C.SetColor C.Foreground C.Vivid C.Cyan]
withSGR :: [C.SGR] -> ByteString -> ByteString
withSGR sgr s = B.pack (C.setSGRCode sgr) <> s <> B.pack (C.setSGRCode [C.Reset])
+30 -76
View File
@@ -10,6 +10,7 @@
module Main where
import ChatOptions
import ChatTerminal
import Control.Applicative ((<|>))
import Control.Concurrent.STM
import Control.Logger.Simple
@@ -19,18 +20,14 @@ import qualified Data.Attoparsec.ByteString.Char8 as A
import Data.ByteString.Char8 (ByteString)
import qualified Data.ByteString.Char8 as B
import Data.Functor (($>))
import qualified Data.Text as T
import Data.Text.Encoding
import Numeric.Natural
import Simplex.Messaging.Agent (getSMPAgentClient, runSMPAgentClient)
import Simplex.Messaging.Agent.Client (AgentClient (..))
import Simplex.Messaging.Agent.Env.SQLite
import Simplex.Messaging.Agent.Transmission
import Simplex.Messaging.Client (smpDefaultConfig)
import Simplex.Messaging.Transport (getLn, putLn)
import Simplex.Messaging.Util (bshow, raceAny_)
import qualified System.Console.ANSI as C
import System.IO
import Types
cfg :: AgentConfig
cfg =
@@ -50,12 +47,9 @@ data ChatClient = ChatClient
{ inQ :: TBQueue ChatCommand,
outQ :: TBQueue ChatResponse,
smpServer :: SMPServer,
activeContact :: TVar (Maybe Contact),
username :: TVar (Maybe Contact)
}
newtype Contact = Contact {toBs :: ByteString}
-- | GroupMessage ChatGroup ByteString
-- | AddToGroup Contact
data ChatCommand
@@ -125,21 +119,24 @@ main :: IO ()
main = do
ChatOpts {dbFileName, smpServer, name} <- getChatOpts
putStrLn "simpleX chat prototype (no encryption), \"/help\" for usage information"
t <- getChatClient smpServer (Contact <$> name)
let user = Contact <$> name
t <- getChatClient smpServer user
ct <- newChatTerminal (tbqSize cfg) user
-- setLogLevel LogInfo -- LogError
-- withGlobalLogging logCfg $
env <- newSMPAgentEnv cfg {dbFile = dbFileName}
dogFoodChat t env
dogFoodChat t ct env
dogFoodChat :: ChatClient -> Env -> IO ()
dogFoodChat t env = do
dogFoodChat :: ChatClient -> ChatTerminal -> Env -> IO ()
dogFoodChat t ct env = do
c <- runReaderT getSMPAgentClient env
raceAny_
[ runReaderT (runSMPAgentClient c) env,
sendToAgent t c,
sendToTTY t,
receiveFromAgent t c,
receiveFromTTY t
sendToAgent t ct c,
sendToChatTerm t ct,
receiveFromAgent t ct c,
receiveFromChatTerm t ct,
chatTerminal ct
]
getChatClient :: SMPServer -> Maybe Contact -> IO ChatClient
@@ -149,32 +146,34 @@ newChatClient :: Natural -> SMPServer -> Maybe Contact -> STM ChatClient
newChatClient qSize smpServer name = do
inQ <- newTBQueue qSize
outQ <- newTBQueue qSize
activeContact <- newTVar Nothing
username <- newTVar name
return ChatClient {inQ, outQ, smpServer, activeContact, username}
return ChatClient {inQ, outQ, smpServer, username}
receiveFromTTY :: ChatClient -> IO ()
receiveFromTTY t =
forever $ getChatLn t >>= processOrError . A.parseOnly (chatCommandP <* A.endOfInput)
receiveFromChatTerm :: ChatClient -> ChatTerminal -> IO ()
receiveFromChatTerm t ct = forever $ do
atomically (readTBQueue $ inputQ ct)
>>= processOrError . A.parseOnly (chatCommandP <* A.endOfInput)
where
processOrError = \case
Left err -> atomically . writeTBQueue (outQ t) . ErrorInput $ B.pack err
Right ChatHelp -> atomically . writeTBQueue (outQ t) $ ChatHelpInfo
Right (SetName a) -> atomically $ do
writeTVar (username t) $ Just a
let user = Just a
writeTVar (username (t :: ChatClient)) user
updateUsername ct user
writeTBQueue (outQ t) YesYes
Right cmd -> atomically $ writeTBQueue (inQ t) cmd
sendToTTY :: ChatClient -> IO ()
sendToTTY ChatClient {outQ, username} = forever $ do
sendToChatTerm :: ChatClient -> ChatTerminal -> IO ()
sendToChatTerm ChatClient {outQ, username} ChatTerminal {outputQ} = forever $ do
atomically (readTBQueue outQ) >>= \case
NoChatResponse -> return ()
resp -> do
name <- readTVarIO username
putLn stdout $ serializeChatResponse name resp
atomically . writeTBQueue outputQ $ serializeChatResponse name resp
sendToAgent :: ChatClient -> AgentClient -> IO ()
sendToAgent ChatClient {inQ, smpServer, activeContact} AgentClient {rcvQ} =
sendToAgent :: ChatClient -> ChatTerminal -> AgentClient -> IO ()
sendToAgent ChatClient {inQ, smpServer} ct AgentClient {rcvQ} =
forever . atomically $ do
cmd <- readTBQueue inQ
writeTBQueue rcvQ `mapM_` agentTransmission cmd
@@ -182,7 +181,7 @@ sendToAgent ChatClient {inQ, smpServer, activeContact} AgentClient {rcvQ} =
where
setActiveContact :: ChatCommand -> STM ()
setActiveContact cmd =
writeTVar activeContact $ case cmd of
writeTVar (activeContact ct) $ case cmd of
ChatWith a -> Just a
SendMessage a _ -> Just a
_ -> Nothing
@@ -197,8 +196,8 @@ sendToAgent ChatClient {inQ, smpServer, activeContact} AgentClient {rcvQ} =
transmission :: Contact -> ACommand 'Client -> Maybe (ATransmission 'Client)
transmission (Contact a) cmd = Just ("1", a, cmd)
receiveFromAgent :: ChatClient -> AgentClient -> IO ()
receiveFromAgent t c = forever . atomically $ do
receiveFromAgent :: ChatClient -> ChatTerminal -> AgentClient -> IO ()
receiveFromAgent t ct c = forever . atomically $ do
resp <- chatResponse <$> readTBQueue (sndQ c)
writeTBQueue (outQ t) resp
setActiveContact resp
@@ -219,49 +218,4 @@ receiveFromAgent t c = forever . atomically $ do
Disconnected _ -> set Nothing
_ -> return ()
where
set a = writeTVar (activeContact t) a
getChatLn :: ChatClient -> IO ByteString
getChatLn t = do
setTTY NoBuffering
getChar >>= \case
'/' -> getRest "/"
'@' -> getRest "@"
ch -> do
let s = encodeUtf8 $ T.singleton ch
readTVarIO (activeContact t) >>= \case
Nothing -> getRest s
Just a -> getWithContact a s
where
getWithContact :: Contact -> ByteString -> IO ByteString
getWithContact a s = do
C.cursorBackward 1
B.hPut stdout $ ttyToContact a <> " " <> s
getRest $ "@" <> toBs a <> " " <> s
getRest :: ByteString -> IO ByteString
getRest s = do
setTTY LineBuffering
(s <>) <$> getLn stdin
setTTY :: BufferMode -> IO ()
setTTY mode = do
hSetBuffering stdin mode
hSetBuffering stdout mode
ttyContact :: Contact -> ByteString
ttyContact (Contact a) = withSGR contactSGR a
ttyFromContact :: Contact -> ByteString
ttyFromContact (Contact a) = withSGR contactSGR $ a <> ">"
ttyToContact :: Contact -> ByteString
ttyToContact (Contact a) = withSGR selfSGR $ "@" <> a
contactSGR :: [C.SGR]
contactSGR = [C.SetColor C.Foreground C.Vivid C.Yellow]
selfSGR :: [C.SGR]
selfSGR = [C.SetColor C.Foreground C.Vivid C.Cyan]
withSGR :: [C.SGR] -> ByteString -> ByteString
withSGR sgr s = B.pack (C.setSGRCode sgr) <> s <> B.pack (C.setSGRCode [C.Reset])
set a = writeTVar (activeContact ct) a
+5
View File
@@ -0,0 +1,5 @@
module Types where
import Data.ByteString.Char8 (ByteString)
newtype Contact = Contact {toBs :: ByteString}