htalkat-0.1.2.2: CursesClient.hs
-- This file is part of htalkat
-- Copyright (C) 2021 Martin Bays <mbays@sdf.org>
--
-- This program is free software: you can redistribute it and/or modify
-- it under the terms of version 3 of the GNU General Public License as
-- published by the Free Software Foundation, or any later version.
--
-- You should have received a copy of the GNU General Public License
-- along with this program. If not, see http://www.gnu.org/licenses/.
{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
module CursesClient (cursesClient) where
import Prelude hiding (lines)
import Control.Concurrent
import Control.Exception (bracket, bracket_)
import Control.Monad.State
import Data.Char (isSpace)
import Data.Function (on)
import Data.Int (Int64)
import Data.List (uncons, (\\))
import Data.Maybe (fromMaybe)
import Safe (headMay)
import System.Exit (exitFailure)
import System.IO (Handle, hFlush)
import UI.HSCurses.Curses hiding (ls)
import qualified Data.ByteString.Lazy.Char8 as BLC
import qualified Data.Text.Encoding.Error as T
import qualified Data.Text.Lazy as T
import qualified Data.Text.Lazy.Encoding as T
import qualified Data.Text.Lazy.IO as T
import qualified Network.Socket as S
import qualified Network.Socket.ByteString.Lazy as SL
import UI.HSCurses.CursesHelper as CH
import Mundanities
import Util
import WCWidth
#ifndef WINDOWS
import System.Posix.Signals
#endif
#if !(MIN_VERSION_base(4,11,0))
import Data.Semigroup
#endif
newtype Reversed = Reversed {getReversed :: T.Text}
data StreamState = StreamState
{ streamWin :: Window
, streamLines :: [T.Text]
, streamHeights :: [Int]
, streamCurLine :: Reversed
, streamView :: Int
}
resetSS :: Int -> Window -> StreamState -> StreamState
resetSS w win ss = ss
{ streamWin = win
, streamHeights = lineHeight w <$> streamLines ss
, streamView = 0
}
curLineHeight :: Int -> StreamState -> Int
curLineHeight w = lineHeight w . T.reverse . getReversed . streamCurLine
mapCurLine :: (T.Text -> T.Text) -> StreamState -> StreamState
mapCurLine f s = s { streamCurLine = Reversed . f . getReversed $ streamCurLine s }
allHeights :: Int -> StreamState -> [Int]
allHeights w ss = curLineHeight w ss : streamHeights ss
allLines :: StreamState -> [T.Text]
allLines ss = (T.reverse . getReversed $ streamCurLine ss) : streamLines ss
data TalkStream = OutStream | InStream
deriving Eq
data CState = CState
{ otherName :: String
, connectionClosed :: Bool
, monitor :: Bool
, logHandle :: Maybe Handle
, lastMoved :: TalkStream
, onTop :: TalkStream
, outStream :: StreamState
, inStream :: StreamState
}
initCState :: String -> TalkStream -> Maybe Handle -> (Window,Window) -> CState
initCState name top mLog (w,w') = CState
{ otherName = name
, connectionClosed = False
, monitor = False
, lastMoved = OutStream
, onTop = top
, logHandle = mLog
, outStream = emptySS $ if top == OutStream then w else w'
, inStream = emptySS $ if top == InStream then w else w'
} where emptySS x = StreamState x [] [] (Reversed "") 0
resetWindows :: Int -> TalkStream -> (Window,Window) -> CState -> CState
resetWindows ww top (w,w') =
mapSS OutStream (resetSS ww $ winOf OutStream) .
mapSS InStream (resetSS ww $ winOf InStream)
where winOf str = if str == top then w else w'
getSS :: TalkStream -> CState -> StreamState
getSS OutStream = outStream
getSS InStream = inStream
mapSS :: TalkStream -> (StreamState -> StreamState) -> CState -> CState
mapSS OutStream f ss = ss { outStream = f $ outStream ss }
mapSS InStream f ss = ss { inStream = f $ inStream ss }
wcwidthNonNeg :: Char -> Int
wcwidthNonNeg = max 0 . wcwidth
wcLength :: T.Text -> Int
wcLength = sum . (wcwidthNonNeg <$>) . T.unpack
resetTerm :: IO ()
resetTerm = nl True >> raw False >> cBreak True
getGeom :: IO ((Int,Int),Int)
getGeom = do
(lines,cols) <- scrSize
let (d,m) = divMod lines 2
pure ((d + m - 1, d), cols)
getWidth :: IO Int
getWidth = snd <$> getGeom
getDividerY :: IO Int
getDividerY = fst . fst <$> getGeom
checkSize :: IO ()
checkSize = do
dy <- getDividerY
ww <- getWidth
when (dy == 0 || ww <= maxWCWidth) $
CH.end >> putStrLn "Terminal too small." >> exitFailure
where maxWCWidth = 2
initWindows :: IO (Window,Window)
initWindows = do
((wh,wh'),ww) <- getGeom
liftM2 (,)
(newWin wh (ww + 1) 0 0)
(newWin wh' (ww + 1) (wh+1) 0)
data Event = InCharEv Char | KeyEv Key | ConnectionClosed | NeedReset | Quit
deriving (Eq)
isQuit :: Event -> Bool
isQuit =
-- Handle ^C, in case it doesn't generate a signal (as on windows?)
(`elem` [Quit, KeyEv (KeyChar '\ETX')])
connectingNamedSocket :: FilePath -> (S.Socket -> IO a) -> IO a
connectingNamedSocket path =
(`bracket` (`S.gracefulClose` 1000)) $ do
sock <- S.socket S.AF_UNIX S.Stream 0
S.connect sock $ S.SockAddrUnix path
pure sock
cursesClient :: Bool -> Maybe Handle -> String -> FilePath -> IO ()
cursesClient localTop mLog name path = connectingNamedSocket path $ \sock -> do
charStream <- T.unpack . T.decodeUtf8With T.lenientDecode <$> SL.getContents sock
putStrLn "Waiting for answer..."
case uncons charStream of
Nothing -> putStrLn "Connection closed."
Just ('T', charStream') -> bracket_ CH.start CH.end $ do
checkSize
resetTerm
eventChan <- newChan
#ifndef WINDOWS
let suspHandler = CatchOnce $ do
raiseSignal sigTSTP
void installTSTP
writeChan eventChan NeedReset
installTSTP = installHandler sigTSTP suspHandler Nothing
_ <- installTSTP
_ <- installHandler sigINT (Catch $ writeChan eventChan Quit) Nothing
#endif
let top = if localTop then OutStream else InStream
st <- initCState name top mLog <$> initWindows
_ <- forkIO $ do
mapM_ (writeChan eventChan . InCharEv) charStream'
writeChan eventChan ConnectionClosed
_ <- forkIO $ writeList2Chan eventChan . (KeyEv <$>) =<< getContentsCurses
void . (`runStateT` st) $ do
redrawAll
mapM_ (handleEvent sock) . takeWhile (not . isQuit) =<<
liftIO (getChanContents eventChan)
_ -> error "Impossible handshake char!"
-- hscurses doesn't bind get_wch, so we do utf8 decoding by hand.
getContentsCurses :: IO [Key]
getContentsCurses = do
cChan <- newChan
kChan <- newChan
_ <- forkIO . forever $ getCh >>= \case
(KeyChar c) -> writeChan cChan c
k -> writeChan kChan k
_ <- forkIO . writeList2Chan kChan . (KeyChar <$>) .
T.unpack . T.decodeUtf8With T.lenientDecode . BLC.concat . (BLC.singleton <$>) =<<
getChanContents cChan
getChanContents kChan
handleEvent :: S.Socket -> Event -> StateT CState IO ()
handleEvent _ (InCharEv c) = do
gets monitor >>? do
liftIO beep
modify $ \cs -> cs { monitor = False }
redrawIndicators
addCharToStream InStream $ if allowedTalkatChar c then c else '?'
handleEvent _ (KeyEv (KeyChar '\DLE')) = -- ^P
viewAddPg InStream 1
handleEvent _ (KeyEv (KeyChar '\SO')) = -- ^N
viewAddPg InStream $ -1
handleEvent _ (KeyEv KeyPPage) = viewAddPg OutStream 1
handleEvent _ (KeyEv KeyNPage) = viewAddPg OutStream $ -1
handleEvent _ (KeyEv KeyUp) = modView InStream (+1)
handleEvent _ (KeyEv KeyDown) = modView InStream (+ (-1))
handleEvent _ (KeyEv KeyLeft) = modView OutStream (+1)
handleEvent _ (KeyEv KeyRight) = modView OutStream (+ (-1))
handleEvent sock (KeyEv (KeyChar '\ETB')) = do -- ^W
l <- gets $ getReversed . streamCurLine . getSS OutStream
let (spaces, rest) = break (> 0) . (fromIntegral . T.length <$>) $ T.split isSpace l
n = length spaces + fromMaybe 0 (headMay rest)
replicateM_ n $ handleEvent sock (KeyEv (KeyChar '\b'))
handleEvent _ (KeyEv (KeyChar '\a')) = do
modify $ \cs -> cs { monitor = not $ monitor cs }
redrawIndicators
handleEvent _ (KeyEv (KeyChar '\f')) = redrawAll
handleEvent sock (KeyEv k) | k `elem` [KeyBackspace, KeyDC, KeyChar '\DEL'] =
handleEvent sock (KeyEv (KeyChar '\b'))
handleEvent sock (KeyEv KeyEnter) =
handleEvent sock (KeyEv (KeyChar '\n'))
handleEvent _ (KeyEv KeyResize) = do
liftIO checkSize
forM_ [InStream,OutStream] $ \strm -> liftIO . delWin =<< gets (streamWin . getSS strm)
ww <- liftIO $ CH.resizeui >> getWidth
top <- gets onTop
modify . resetWindows ww top =<< liftIO initWindows
redrawAll
handleEvent sock (KeyEv (KeyChar c)) | allowedTalkatChar c =
gets connectionClosed >>! do
addCharToStream OutStream c
liftIO . ignoreIOErr . SL.sendAll sock . T.encodeUtf8 $ T.singleton c
handleEvent _ (KeyEv _) = liftIO showHelp
handleEvent _ ConnectionClosed = do
modify $ \cs -> cs { connectionClosed = True }
redrawIndicators
handleEvent _ NeedReset = do
liftIO resetTerm
redrawAll
handleEvent _ _ = pure ()
allowedTalkatChar :: Char -> Bool
allowedTalkatChar c =
-- disallow C0 control chars without special meaning
-- (curses displays them with the wrong width)
c `notElem` (['\0'..'\31'] \\ "\b\NAK\n")
addCharToStream :: TalkStream -> Char -> StateT CState IO ()
addCharToStream strm c = do
modify (\cs -> cs { lastMoved = strm })
case c of
'\b' -> eraseLast strm 1
'\NAK' -> do
n <- gets $ T.length . getReversed . streamCurLine . getSS strm
eraseLast strm n
'\n' -> do
ww <- liftIO getWidth
curLine <- gets $ T.reverse . getReversed . streamCurLine . getSS strm
modify . mapSS strm $ \ss -> ss
{ streamLines = curLine : streamLines ss
, streamHeights = curLineHeight ww ss : streamHeights ss
, streamCurLine = Reversed ""
}
maybe (pure ()) (liftIO . logLine strm curLine) =<< gets logHandle
redrawStream strm
_ -> do
modify . mapSS strm $ mapCurLine (T.cons c)
p <- gets (streamView . getSS strm)
win <- gets $ streamWin . getSS strm
(_,x) <- liftIO $ getYX win
ww <- liftIO getWidth
if x + wcwidthNonNeg c <= ww
then when (p == 0) . liftIO $
wAddStr win [c] >> wRefresh win
else redrawStream strm
placeCursor
logLine :: TalkStream -> T.Text -> Handle -> IO ()
logLine strm s h = (>> hFlush h) . T.hPutStrLn h . (<> s) $ case strm of
InStream -> "<< "
OutStream -> ">> "
redrawAll :: StateT CState IO ()
redrawAll = do
liftIO $ wclear stdScr >> refresh
forM_ [InStream,OutStream] redrawStream
redrawIndicators
placeCursor
placeCursor :: StateT CState IO ()
placeCursor = do
lst <- gets lastMoved
scrolled <- gets $ (>0) . streamView . getSS lst
closed <- gets connectionClosed
if scrolled || closed
then liftIO $ do
dy <- getDividerY
wMove stdScr dy 0
else
liftIO . wRefresh =<< gets (streamWin . getSS lst)
redrawIndicators :: StateT CState IO ()
redrawIndicators = do
ov <- gets $ streamView . getSS OutStream
iv <- gets $ streamView . getSS InStream
name <- gets otherName
closed <- gets connectionClosed
mtr <- gets monitor
let os = if ov > 0 then "[you ^ " <> show ov <> "]" else ""
is = "[" <> name <> (if iv > 0 then " ^ " <> show iv else "") <>
(if mtr then "*" else "") <> "]"
cs = "[Connection lost; ^C to quit]"
csShort = "[Closed]"
liftIO $ do
ww <- getWidth
dy <- getDividerY
mvWAddStr stdScr dy 0 $ replicate ww '─'
when closed $ do
let drawCentred s = mvWAddStr stdScr dy ((ww - length s) `div` 2) s
if ww > length cs + 4 then drawCentred cs
else when (ww > length csShort + 4) $ drawCentred csShort
when (ww > 12 + length is + length os + (if closed then length cs + 2 else 0)) $
sequence_ [ unless (null s) $ mvWAddStr stdScr dy p s
| (s,p) <- [(is,5), (os, ww - 5 - length os)] ]
wMove stdScr dy 0
refresh
showHelp :: IO ()
showHelp = do
ww <- liftIO getWidth
dy <- liftIO getDividerY
mvWAddStr stdScr dy 0 $ replicate ww '─'
mvWAddStr stdScr dy (max 1 ((ww - length helpStr) `div` 2)) $
take (ww - 2) helpStr
wMove stdScr dy 0
refresh
where helpStr = "^P/^N,PgUp/PgDn,arrows scroll; ^W/^U erase; ^C quit; ^G monitor; ^L redraw"
getStreamHeight :: TalkStream -> StateT CState IO Int
getStreamHeight strm = do
top <- gets onTop
((wh,wh'),_) <- liftIO getGeom
pure $ if top == strm then wh else wh'
redrawStream :: TalkStream -> StateT CState IO ()
redrawStream strm = do
ww <- liftIO getWidth
wh <- getStreamHeight strm
ss <- gets $ getSS strm
let v = streamView ss
(len,n) = lenToSum (wh + v) $ allHeights ww ss
ls = reverse . take len $ allLines ss
pls = take wh . drop (n - (wh + v)) . concat $
reverse . zip (False:repeat True) . reverse .
wrap ww <$> ls
win = streamWin ss
liftIO $ do
werase win
sequence_ [ do
mvWAddStr win y 0 $ T.unpack s
when isWrap $ do
CH.wSetStyle win $ CH.mkCursesStyle [CH.Reverse]
mvWAddStr win y (ww-1) " "
CH.wResetStyle win
| ((isWrap,s),y) <- zip pls [(wh - length pls) ..] ]
wRefresh win
where
lenToSum :: (Num n, Ord n) => n -> [n] -> (Int,n)
lenToSum m = go 0 0 where
go l n (a:as) | n < m = go (l + 1) (n + a) as
go l n _ = (l,n)
wrap :: Int -> T.Text -> [T.Text]
wrap wrapWidth _ | wrapWidth < 2 = error "Tried to wrap with too small width!"
wrap wrapWidth line = wrap' "" 0 . mergeSpaces $ T.groupBy ((==) `on` isSpace) line
where
mergeSpaces (s:ws) | isSpace (T.head s) = s : mergeSpaces ws
mergeSpaces (w:s:ws) = (w <> s) : mergeSpaces ws
mergeSpaces ws = ws
wrap' l n ws | n > wrapWidth =
let (a,b) = splitAtWC (wrapWidth - 1) l in a : wrap' b (n - wcLength a) ws
wrap' l _ [] = [l]
wrap' l n (w:ws) =
let l' = l <> w
nw = wcLength w
n' = n + nw
in if n' > wrapWidth
then (if T.null l then id else (l:)) $ wrap' w nw ws
else wrap' l' n' ws
splitAtWC :: Int -> T.Text -> (T.Text,T.Text)
splitAtWC m = go m T.empty where
go !n !acc t
| Just (c,r) <- T.uncons t = let w = max 0 $ wcwidth c in
if w > max 0 n then (T.reverse acc,t)
else go (n - w) (T.cons c acc) r
| otherwise = (T.reverse acc, T.empty)
lineHeight :: Int -> T.Text -> Int
lineHeight w = length . wrap w
eraseLast :: TalkStream -> Int64 -> StateT CState IO ()
eraseLast strm n = do
ss <- gets $ getSS strm
ww <- liftIO getWidth
p <- gets (streamView . getSS strm)
let cur = getReversed $ streamCurLine ss
mightDecreaseHeight =
-- +1 to handle case of wrapping at double-width char
wcLength cur + 1 > ww
wErase = wcLength $ T.take n cur
modify . mapSS strm . mapCurLine $ T.drop n
if mightDecreaseHeight
then redrawStream strm
else when (p == 0) . liftIO $ do
let win = streamWin ss
(y,x) <- getYX win
mvWAddStr win y (x - wErase) (replicate wErase ' ')
wMove win y $ x - wErase
wRefresh win
viewAddPg :: TalkStream -> Int -> StateT CState IO ()
viewAddPg strm n = do
wh <- getStreamHeight strm
modView strm (+ (n * wh))
modView :: TalkStream -> (Int -> Int) -> StateT CState IO ()
modView strm f = do
ww <- liftIO getWidth
wh <- getStreamHeight strm
ss <- gets $ getSS strm
let h = (+ (-wh)) . sum . allHeights ww $ ss
oldView = streamView ss
newView = max 0 . min h $ f oldView
when (newView /= oldView) $ do
modify . mapSS strm $ \s -> s { streamView = newView }
redrawStream strm
redrawIndicators
placeCursor