mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-05-10 15:06:12 +00:00
7c4c627ee9
* terminal: support for multiline messages * fix * fix tests
257 lines
11 KiB
Haskell
257 lines
11 KiB
Haskell
{-# LANGUAGE DuplicateRecordFields #-}
|
|
{-# LANGUAGE FlexibleContexts #-}
|
|
{-# LANGUAGE GADTs #-}
|
|
{-# LANGUAGE LambdaCase #-}
|
|
{-# LANGUAGE NamedFieldPuns #-}
|
|
{-# LANGUAGE OverloadedStrings #-}
|
|
{-# LANGUAGE ScopedTypeVariables #-}
|
|
{-# LANGUAGE TupleSections #-}
|
|
|
|
module Simplex.Chat.Terminal.Input where
|
|
|
|
import Control.Concurrent (forkFinally, forkIO, killThread, mkWeakThreadId, threadDelay)
|
|
import Control.Monad.Except
|
|
import Control.Monad.Reader
|
|
import Data.Char (isAlphaNum)
|
|
import Data.List (dropWhileEnd, foldl')
|
|
import Data.Maybe (isJust, isNothing)
|
|
import qualified Data.Text as T
|
|
import Data.Text.Encoding (encodeUtf8)
|
|
import GHC.Weak (deRefWeak)
|
|
import Simplex.Chat
|
|
import Simplex.Chat.Controller
|
|
import Simplex.Chat.Messages
|
|
import Simplex.Chat.Styled
|
|
import Simplex.Chat.Terminal.Output
|
|
import Simplex.Messaging.Util (whenM)
|
|
import System.Exit (exitSuccess)
|
|
import System.Terminal hiding (insertChars)
|
|
import UnliftIO.STM
|
|
|
|
getKey :: MonadTerminal m => m (Key, Modifiers)
|
|
getKey =
|
|
flush >> awaitEvent >>= \case
|
|
Left Interrupt -> liftIO exitSuccess
|
|
Right (KeyEvent key ms) -> pure (key, ms)
|
|
_ -> getKey
|
|
|
|
runInputLoop :: ChatTerminal -> ChatController -> IO ()
|
|
runInputLoop ct@ChatTerminal {termState, liveMessageState} cc = forever $ do
|
|
s <- atomically . readTBQueue $ inputQ cc
|
|
let bs = encodeUtf8 $ T.pack s
|
|
cmd = parseChatCommand bs
|
|
unless (isMessage cmd) $ echo s
|
|
r <- runReaderT (execChatCommand bs) cc
|
|
case r of
|
|
CRChatCmdError _ _ -> when (isMessage cmd) $ echo s
|
|
CRChatError _ _ -> when (isMessage cmd) $ echo s
|
|
_ -> pure ()
|
|
printRespToTerminal ct cc False r
|
|
startLiveMessage cmd r
|
|
where
|
|
echo s = printToTerminal ct [plain s]
|
|
isMessage = \case
|
|
Right SendMessage {} -> True
|
|
Right SendLiveMessage {} -> True
|
|
Right SendFile {} -> True
|
|
Right SendMessageQuote {} -> True
|
|
Right SendGroupMessageQuote {} -> True
|
|
Right SendMessageBroadcast {} -> True
|
|
_ -> False
|
|
startLiveMessage :: Either a ChatCommand -> ChatResponse -> IO ()
|
|
startLiveMessage (Right (SendLiveMessage chatName msg)) (CRNewChatItem _ (AChatItem cType SMDSnd _ ChatItem {meta = CIMeta {itemId}})) = do
|
|
whenM (isNothing <$> readTVarIO liveMessageState) $ do
|
|
let s = T.unpack msg
|
|
int = case cType of SCTGroup -> 5000000; _ -> 3000000 :: Int
|
|
liveThreadId <- mkWeakThreadId =<< runLiveMessage int `forkFinally` const (atomically $ writeTVar liveMessageState Nothing)
|
|
promptThreadId <- mkWeakThreadId =<< forkIO blinkLivePrompt
|
|
atomically $ do
|
|
let lm = LiveMessage {chatName, chatItemId = itemId, livePrompt = True, sentMsg = s, typedMsg = s, liveThreadId, promptThreadId}
|
|
writeTVar liveMessageState (Just lm)
|
|
modifyTVar termState $ \ts -> ts {inputString = s, inputPosition = length s, inputPrompt = liveInputPrompt lm}
|
|
where
|
|
liveInputPrompt LiveMessage {chatName = n, livePrompt} =
|
|
"> " <> chatNameStr n <> " [" <> (if livePrompt then "LIVE" else " ") <> "] "
|
|
runLiveMessage :: Int -> IO ()
|
|
runLiveMessage int = do
|
|
threadDelay int
|
|
TerminalState {inputString = s} <- readTVarIO termState
|
|
readTVarIO liveMessageState
|
|
>>= mapM_ (\lm -> updateLiveMessage s lm >> runLiveMessage int)
|
|
blinkLivePrompt = readTVarIO liveMessageState >>= mapM_ updateLivePrompt
|
|
where
|
|
updateLivePrompt lm = do
|
|
atomically $ updatePrompt lm
|
|
updateInputView ct
|
|
threadDelay 1000000
|
|
blinkLivePrompt
|
|
updatePrompt lm = do
|
|
writeTVar liveMessageState $ Just lm {livePrompt = not $ livePrompt lm}
|
|
modifyTVar termState (\ts -> ts {inputPrompt = liveInputPrompt lm})
|
|
liveMessageToSend t LiveMessage {sentMsg, typedMsg} =
|
|
let s = if t /= typedMsg then truncateToWords t else t
|
|
in if s /= sentMsg then Just s else Nothing
|
|
updateLiveMessage typedMsg lm = case liveMessageToSend typedMsg lm of
|
|
Just sentMsg ->
|
|
sendUpdatedLiveMessage cc sentMsg lm True >>= \case
|
|
CRChatItemUpdated {} -> setLiveMessage lm {sentMsg, typedMsg}
|
|
_ -> do
|
|
-- TODO print error
|
|
setLiveMessage lm {typedMsg}
|
|
_ -> setLiveMessage lm {typedMsg}
|
|
setLiveMessage :: LiveMessage -> IO ()
|
|
setLiveMessage = atomically . writeTVar liveMessageState . Just
|
|
truncateToWords = fst . foldl' acc ("", "")
|
|
where
|
|
acc (s, w) c
|
|
| isAlphaNum c = (s, c : w)
|
|
| otherwise = (s <> reverse (c : w), "")
|
|
startLiveMessage _ _ = pure ()
|
|
|
|
sendUpdatedLiveMessage :: ChatController -> String -> LiveMessage -> Bool -> IO ChatResponse
|
|
sendUpdatedLiveMessage cc sentMsg LiveMessage {chatName, chatItemId} live = do
|
|
let cmd = UpdateLiveMessage chatName chatItemId live $ T.pack sentMsg
|
|
either (CRChatCmdError Nothing) id <$> runExceptT (processChatCommand cmd) `runReaderT` cc
|
|
|
|
runTerminalInput :: ChatTerminal -> ChatController -> IO ()
|
|
runTerminalInput ct cc = withChatTerm ct $ do
|
|
updateInput ct
|
|
receiveFromTTY cc ct
|
|
|
|
receiveFromTTY :: forall m. MonadTerminal m => ChatController -> ChatTerminal -> m ()
|
|
receiveFromTTY cc@ChatController {inputQ, activeTo} ct@ChatTerminal {termSize, termState, liveMessageState} =
|
|
forever $ getKey >>= liftIO . processKey >> withTermLock ct (updateInput ct)
|
|
where
|
|
processKey :: (Key, Modifiers) -> IO ()
|
|
processKey key = case key of
|
|
(EnterKey, ms)
|
|
| ms == mempty -> submit False
|
|
| ms == altKey -> submit True
|
|
| otherwise -> pure ()
|
|
(CharKey c, ms)
|
|
| (c == 'l' || c == 'L') && ms == ctrlKey -> submit True
|
|
| otherwise -> update key
|
|
_ -> update key
|
|
submit live =
|
|
atomically (readTVar termState >>= submitInput live)
|
|
>>= mapM_ (uncurry endLiveMessage)
|
|
update key = atomically $ do
|
|
ac <- readTVar activeTo
|
|
live <- isJust <$> readTVar liveMessageState
|
|
modifyTVar termState $ updateTermState ac live (width termSize) key
|
|
|
|
endLiveMessage :: String -> LiveMessage -> IO ()
|
|
endLiveMessage sentMsg lm = do
|
|
kill liveThreadId
|
|
kill promptThreadId
|
|
atomically $ writeTVar liveMessageState Nothing
|
|
r <- sendUpdatedLiveMessage cc sentMsg lm False
|
|
printRespToTerminal ct cc False r
|
|
where
|
|
kill sel = deRefWeak (sel lm) >>= mapM_ killThread
|
|
|
|
submitInput :: Bool -> TerminalState -> STM (Maybe (String, LiveMessage))
|
|
submitInput live ts = do
|
|
let s = inputString ts
|
|
lm_ <- readTVar liveMessageState
|
|
case lm_ of
|
|
Just LiveMessage {chatName}
|
|
| live -> do
|
|
writeTVar termState ts' {previousInput}
|
|
writeTBQueue inputQ $ "/live " <> chatNameStr chatName
|
|
| otherwise ->
|
|
writeTVar termState ts' {inputPrompt = "> ", previousInput}
|
|
where
|
|
previousInput = chatNameStr chatName <> " " <> s
|
|
_
|
|
| live -> when (isSend s) $ do
|
|
writeTVar termState ts' {previousInput = s}
|
|
writeTBQueue inputQ $ "/live " <> s
|
|
| otherwise -> do
|
|
writeTVar termState ts' {inputPrompt = "> ", previousInput = s}
|
|
writeTBQueue inputQ s
|
|
pure $ (s,) <$> lm_
|
|
where
|
|
isSend s = length s > 1 && (head s == '@' || head s == '#')
|
|
ts' = ts {inputString = "", inputPosition = 0}
|
|
|
|
updateTermState :: ActiveTo -> Bool -> Int -> (Key, Modifiers) -> TerminalState -> TerminalState
|
|
updateTermState ac live tw (key, ms) ts@TerminalState {inputString = s, inputPosition = p} = case key of
|
|
CharKey c
|
|
| ms == mempty || ms == shiftKey -> insertCharsWithContact [c]
|
|
| ms == altKey && c == 'b' -> setPosition prevWordPos
|
|
| ms == altKey && c == 'f' -> setPosition nextWordPos
|
|
| otherwise -> ts
|
|
TabKey -> insertCharsWithContact " "
|
|
BackspaceKey -> backDeleteChar
|
|
DeleteKey -> deleteChar
|
|
HomeKey -> setPosition 0
|
|
EndKey -> setPosition $ length s
|
|
ArrowKey d -> case d of
|
|
Leftwards -> setPosition leftPos
|
|
Rightwards -> setPosition rightPos
|
|
Upwards
|
|
| ms == mempty && null s -> let s' = upArrowCmd $ previousInput ts in ts' (s', length s')
|
|
| ms == mempty -> let p' = p - tw in if p' > 0 then setPosition p' else ts
|
|
| otherwise -> ts
|
|
Downwards
|
|
| ms == mempty -> let p' = p + tw in if p' <= length s then setPosition p' else ts
|
|
| otherwise -> ts
|
|
_ -> ts
|
|
where
|
|
insertCharsWithContact cs
|
|
| live = insertChars cs
|
|
| null s && cs /= "@" && cs /= "#" && cs /= "/" && cs /= ">" && cs /= "\\" && cs /= "!" =
|
|
insertChars $ contactPrefix <> cs
|
|
| (s == ">" || s == "\\" || s == "!") && cs == " " =
|
|
insertChars $ cs <> contactPrefix
|
|
| 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
|
|
ActiveNone -> ""
|
|
ActiveC c -> "@" <> T.unpack c <> " "
|
|
ActiveG g -> "#" <> T.unpack g <> " "
|
|
backDeleteChar
|
|
| p == 0 || null s = ts
|
|
| p >= length s = ts' (init s, length s - 1)
|
|
| otherwise = let (b, a) = splitAt p s in ts' (init b <> a, p - 1)
|
|
deleteChar
|
|
| p >= length s || null s = ts
|
|
| p == 0 = ts' (tail s, 0)
|
|
| otherwise = let (b, a) = splitAt p s in ts' (b <> tail a, p)
|
|
leftPos
|
|
| ms == mempty = max 0 (p - 1)
|
|
| ms == shiftKey = 0
|
|
| ms == ctrlKey = prevWordPos
|
|
| ms == altKey = prevWordPos
|
|
| otherwise = p
|
|
rightPos
|
|
| ms == mempty = min (length s) (p + 1)
|
|
| ms == shiftKey = length s
|
|
| ms == ctrlKey = nextWordPos
|
|
| ms == altKey = nextWordPos
|
|
| otherwise = p
|
|
upArrowCmd inp = case parseChatCommand . encodeUtf8 $ T.pack inp of
|
|
Left _ -> inp
|
|
Right cmd -> case cmd of
|
|
SendMessage {} -> "! " <> inp
|
|
SendMessageQuote {contactName, message} -> T.unpack $ "! @" <> contactName <> " " <> message
|
|
SendGroupMessageQuote {groupName, message} -> T.unpack $ "! #" <> groupName <> " " <> message
|
|
_ -> inp
|
|
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'}
|