1 -- This file is part of htalkat
2 -- Copyright (C) 2021 Martin Bays <mbays@sdf.org>
4 -- This program is free software: you can redistribute it and/or modify
5 -- it under the terms of version 3 of the GNU General Public License as
6 -- published by the Free Software Foundation, or any later version.
8 -- You should have received a copy of the GNU General Public License
9 -- along with this program. If not, see http://www.gnu.org/licenses/.
11 {-# LANGUAGE BangPatterns #-}
12 {-# LANGUAGE LambdaCase #-}
13 {-# LANGUAGE OverloadedStrings #-}
15 module CursesClient
(cursesClient
) where
17 import Prelude
hiding (lines)
19 import Control
.Concurrent
20 import Control
.Exception
(bracket, bracket_)
21 import Control
.Monad
.State
22 import Data
.Char (isSpace)
23 import Data
.Function
(on
)
24 import Data
.Int (Int64
)
25 import Data
.List
(uncons
, (\\))
26 import Data
.Maybe (fromMaybe)
28 import System
.Exit
(exitFailure)
29 import System
.IO (Handle, hFlush)
30 import UI
.HSCurses
.Curses
hiding (ls
)
32 import qualified Data
.ByteString
.Lazy
.Char8
as BLC
33 import qualified Data
.Text
.Encoding
.Error
as T
34 import qualified Data
.Text
.Lazy
as T
35 import qualified Data
.Text
.Lazy
.Encoding
as T
36 import qualified Data
.Text
.Lazy
.IO as T
37 import qualified Network
.Socket
as S
38 import qualified Network
.Socket
.ByteString
.Lazy
as SL
39 import UI
.HSCurses
.CursesHelper
as CH
46 import System
.Posix
.Signals
49 #if !(MIN_VERSION_base
(4,11,0))
53 newtype Reversed
= Reversed
{getReversed
:: T
.Text
}
55 data StreamState
= StreamState
57 , streamLines
:: [T
.Text
]
58 , streamHeights
:: [Int]
59 , streamCurLine
:: Reversed
63 resetSS
:: Int -> Window
-> StreamState
-> StreamState
66 , streamHeights
= lineHeight w
<$> streamLines ss
70 curLineHeight
:: Int -> StreamState
-> Int
71 curLineHeight w
= lineHeight w
. T
.reverse . getReversed
. streamCurLine
73 mapCurLine
:: (T
.Text
-> T
.Text
) -> StreamState
-> StreamState
74 mapCurLine f s
= s
{ streamCurLine
= Reversed
. f
. getReversed
$ streamCurLine s
}
76 allHeights
:: Int -> StreamState
-> [Int]
77 allHeights w ss
= curLineHeight w ss
: streamHeights ss
79 allLines
:: StreamState
-> [T
.Text
]
80 allLines ss
= (T
.reverse . getReversed
$ streamCurLine ss
) : streamLines ss
82 data TalkStream
= OutStream | InStream
87 , connectionClosed
:: Bool
89 , logHandle
:: Maybe Handle
90 , lastMoved
:: TalkStream
92 , outStream
:: StreamState
93 , inStream
:: StreamState
96 initCState
:: String -> TalkStream
-> Maybe Handle -> (Window
,Window
) -> CState
97 initCState name top mLog
(w
,w
') = CState
99 , connectionClosed
= False
101 , lastMoved
= OutStream
104 , outStream
= emptySS
$ if top
== OutStream
then w
else w
'
105 , inStream
= emptySS
$ if top
== InStream
then w
else w
'
106 } where emptySS x
= StreamState x
[] [] (Reversed
"") 0
108 resetWindows
:: Int -> TalkStream
-> (Window
,Window
) -> CState
-> CState
109 resetWindows ww top
(w
,w
') =
110 mapSS OutStream
(resetSS ww
$ winOf OutStream
) .
111 mapSS InStream
(resetSS ww
$ winOf InStream
)
112 where winOf str
= if str
== top
then w
else w
'
114 getSS
:: TalkStream
-> CState
-> StreamState
115 getSS OutStream
= outStream
116 getSS InStream
= inStream
118 mapSS
:: TalkStream
-> (StreamState
-> StreamState
) -> CState
-> CState
119 mapSS OutStream f ss
= ss
{ outStream
= f
$ outStream ss
}
120 mapSS InStream f ss
= ss
{ inStream
= f
$ inStream ss
}
123 wcwidthNonNeg
:: Char -> Int
124 wcwidthNonNeg
= max 0 . wcwidth
126 wcLength
:: T
.Text
-> Int
127 wcLength
= sum . (wcwidthNonNeg
<$>) . T
.unpack
131 resetTerm
= nl
True >> raw
False >> cBreak
True
133 getGeom
:: IO ((Int,Int),Int)
135 (lines,cols
) <- scrSize
136 let (d
,m
) = divMod lines 2
137 pure
((d
+ m
- 1, d
), cols
)
140 getWidth
= snd <$> getGeom
142 getDividerY
:: IO Int
143 getDividerY
= fst . fst <$> getGeom
149 when (dy
== 0 || ww
<= maxWCWidth
) $
150 CH
.end
>> putStrLn "Terminal too small." >> exitFailure
153 initWindows
:: IO (Window
,Window
)
155 ((wh
,wh
'),ww
) <- getGeom
157 (newWin wh
(ww
+ 1) 0 0)
158 (newWin wh
' (ww
+ 1) (wh
+1) 0)
160 data Event
= InCharEv
Char | KeyEv Key | ConnectionClosed | NeedReset | Quit
163 isQuit
:: Event
-> Bool
165 -- Handle ^C, in case it doesn't generate a signal (as on windows?)
166 (`
elem`
[Quit
, KeyEv
(KeyChar
'\ETX
')])
168 connectingNamedSocket
:: FilePath -> (S
.Socket
-> IO a
) -> IO a
169 connectingNamedSocket path
=
170 (`
bracket`
(`S
.gracefulClose`
1000)) $ do
171 sock
<- S
.socket S
.AF_UNIX S
.Stream
0
172 S
.connect sock
$ S
.SockAddrUnix path
175 cursesClient
:: Bool -> Maybe Handle -> String -> FilePath -> IO ()
176 cursesClient localTop mLog name path
= connectingNamedSocket path
$ \sock
-> do
177 charStream
<- T
.unpack
. T
.decodeUtf8With T
.lenientDecode
<$> SL
.getContents sock
178 putStrLn "Waiting for answer..."
179 case uncons charStream
of
180 Nothing
-> putStrLn "Connection closed."
181 Just
('T
', charStream
') -> bracket_ CH
.start CH
.end
$ do
186 let suspHandler
= CatchOnce
$ do
189 writeChan eventChan NeedReset
190 installTSTP
= installHandler sigTSTP suspHandler Nothing
192 _
<- installHandler sigINT
(Catch
$ writeChan eventChan Quit
) Nothing
194 let top
= if localTop
then OutStream
else InStream
195 st
<- initCState name top mLog
<$> initWindows
197 mapM_ (writeChan eventChan
. InCharEv
) charStream
'
198 writeChan eventChan ConnectionClosed
199 _
<- forkIO
$ writeList2Chan eventChan
. (KeyEv
<$>) =<< getContentsCurses
200 void
. (`runStateT` st
) $ do
202 mapM_ (handleEvent sock
) . takeWhile (not . isQuit
) =<<
203 liftIO
(getChanContents eventChan
)
204 _
-> error "Impossible handshake char!"
206 -- hscurses doesn't bind get_wch, so we do utf8 decoding by hand.
207 getContentsCurses
:: IO [Key
]
208 getContentsCurses
= do
211 _
<- forkIO
. forever
$ getCh
>>= \case
212 (KeyChar c
) -> writeChan cChan c
213 k
-> writeChan kChan k
214 _
<- forkIO
. writeList2Chan kChan
. (KeyChar
<$>) .
215 T
.unpack
. T
.decodeUtf8With T
.lenientDecode
. BLC
.concat . (BLC
.singleton
<$>) =<<
216 getChanContents cChan
217 getChanContents kChan
219 handleEvent
:: S
.Socket
-> Event
-> StateT CState
IO ()
220 handleEvent _
(InCharEv c
) = do
223 modify
$ \cs
-> cs
{ monitor
= False }
225 addCharToStream InStream
$ if allowedTalkatChar c
then c
else '?
'
226 handleEvent _
(KeyEv
(KeyChar
'\DLE
')) = -- ^P
228 handleEvent _
(KeyEv
(KeyChar
'\SO
')) = -- ^N
229 viewAddPg InStream
$ -1
230 handleEvent _
(KeyEv KeyPPage
) = viewAddPg OutStream
1
231 handleEvent _
(KeyEv KeyNPage
) = viewAddPg OutStream
$ -1
232 handleEvent _
(KeyEv KeyUp
) = modView InStream
(+1)
233 handleEvent _
(KeyEv KeyDown
) = modView InStream
(+ (-1))
234 handleEvent _
(KeyEv KeyLeft
) = modView OutStream
(+1)
235 handleEvent _
(KeyEv KeyRight
) = modView OutStream
(+ (-1))
236 handleEvent sock
(KeyEv
(KeyChar
'\ETB
')) = do -- ^W
237 l
<- gets
$ getReversed
. streamCurLine
. getSS OutStream
238 let (spaces
, rest
) = break (> 0) . (fromIntegral . T
.length <$>) $ T
.split isSpace l
239 n
= length spaces
+ fromMaybe 0 (headMay rest
)
240 replicateM_ n
$ handleEvent sock
(KeyEv
(KeyChar
'\b'))
241 handleEvent _
(KeyEv
(KeyChar
'\a')) = do
242 modify
$ \cs
-> cs
{ monitor
= not $ monitor cs
}
244 handleEvent _
(KeyEv
(KeyChar
'\f')) = redrawAll
245 handleEvent sock
(KeyEv k
) | k `
elem`
[KeyBackspace
, KeyDC
, KeyChar
'\DEL
'] =
246 handleEvent sock
(KeyEv
(KeyChar
'\b'))
247 handleEvent sock
(KeyEv KeyEnter
) =
248 handleEvent sock
(KeyEv
(KeyChar
'\n'))
249 handleEvent _
(KeyEv KeyResize
) = do
251 forM_
[InStream
,OutStream
] $ \strm
-> liftIO
. delWin
=<< gets
(streamWin
. getSS strm
)
252 ww
<- liftIO
$ CH
.resizeui
>> getWidth
254 modify
. resetWindows ww top
=<< liftIO initWindows
256 handleEvent sock
(KeyEv
(KeyChar c
)) | allowedTalkatChar c
=
257 gets connectionClosed
>>! do
258 addCharToStream OutStream c
259 liftIO
. ignoreIOErr
. SL
.sendAll sock
. T
.encodeUtf8
$ T
.singleton c
260 handleEvent _
(KeyEv _
) = liftIO showHelp
261 handleEvent _ ConnectionClosed
= do
262 modify
$ \cs
-> cs
{ connectionClosed
= True }
264 handleEvent _ NeedReset
= do
267 handleEvent _ _
= pure
()
269 allowedTalkatChar
:: Char -> Bool
270 allowedTalkatChar c
=
271 -- disallow C0 control chars without special meaning
272 -- (curses displays them with the wrong width)
273 c `
notElem`
(['\0'..'\31'] \\ "\b\NAK\n")
275 addCharToStream
:: TalkStream
-> Char -> StateT CState
IO ()
276 addCharToStream strm c
= do
277 modify
(\cs
-> cs
{ lastMoved
= strm
})
279 '\b' -> eraseLast strm
1
281 n
<- gets
$ T
.length . getReversed
. streamCurLine
. getSS strm
284 ww
<- liftIO getWidth
285 curLine
<- gets
$ T
.reverse . getReversed
. streamCurLine
. getSS strm
286 modify
. mapSS strm
$ \ss
-> ss
287 { streamLines
= curLine
: streamLines ss
288 , streamHeights
= curLineHeight ww ss
: streamHeights ss
289 , streamCurLine
= Reversed
""
291 maybe (pure
()) (liftIO
. logLine strm curLine
) =<< gets logHandle
294 modify
. mapSS strm
$ mapCurLine
(T
.cons c
)
295 p
<- gets
(streamView
. getSS strm
)
296 win
<- gets
$ streamWin
. getSS strm
297 (_
,x
) <- liftIO
$ getYX win
298 ww
<- liftIO getWidth
299 if x
+ wcwidthNonNeg c
<= ww
300 then when (p
== 0) . liftIO
$
301 wAddStr win
[c
] >> wRefresh win
302 else redrawStream strm
305 logLine
:: TalkStream
-> T
.Text
-> Handle -> IO ()
306 logLine strm s h
= (>> hFlush h
) . T
.hPutStrLn h
. (<> s
) $ case strm
of
310 redrawAll
:: StateT CState
IO ()
312 liftIO
$ wclear stdScr
>> refresh
313 forM_
[InStream
,OutStream
] redrawStream
317 placeCursor
:: StateT CState
IO ()
319 lst
<- gets lastMoved
320 scrolled
<- gets
$ (>0) . streamView
. getSS lst
321 closed
<- gets connectionClosed
322 if scrolled || closed
327 liftIO
. wRefresh
=<< gets
(streamWin
. getSS lst
)
329 redrawIndicators
:: StateT CState
IO ()
330 redrawIndicators
= do
331 ov
<- gets
$ streamView
. getSS OutStream
332 iv
<- gets
$ streamView
. getSS InStream
333 name
<- gets otherName
334 closed
<- gets connectionClosed
336 let os
= if ov
> 0 then "[you ^ " <> show ov
<> "]" else ""
337 is
= "[" <> name
<> (if iv
> 0 then " ^ " <> show iv
else "") <>
338 (if mtr
then "*" else "") <> "]"
339 cs
= "[Connection lost; ^C to quit]"
344 mvWAddStr stdScr dy
0 $ replicate ww
'─
'
346 let drawCentred s
= mvWAddStr stdScr dy
((ww
- length s
) `
div`
2) s
347 if ww
> length cs
+ 4 then drawCentred cs
348 else when (ww
> length csShort
+ 4) $ drawCentred csShort
349 when (ww
> 12 + length is
+ length os
+ (if closed
then length cs
+ 2 else 0)) $
350 sequence_ [ unless (null s
) $ mvWAddStr stdScr dy p s
351 |
(s
,p
) <- [(is
,5), (os
, ww
- 5 - length os
)] ]
357 ww
<- liftIO getWidth
358 dy
<- liftIO getDividerY
359 mvWAddStr stdScr dy
0 $ replicate ww
'─
'
360 mvWAddStr stdScr dy
(max 1 ((ww
- length helpStr
) `
div`
2)) $
361 take (ww
- 2) helpStr
364 where helpStr
= "^P/^N,PgUp/PgDn,arrows scroll; ^W/^U erase; ^C quit; ^G monitor; ^L redraw"
367 getStreamHeight
:: TalkStream
-> StateT CState
IO Int
368 getStreamHeight strm
= do
370 ((wh
,wh
'),_
) <- liftIO getGeom
371 pure
$ if top
== strm
then wh
else wh
'
373 redrawStream
:: TalkStream
-> StateT CState
IO ()
374 redrawStream strm
= do
375 ww
<- liftIO getWidth
376 wh
<- getStreamHeight strm
377 ss
<- gets
$ getSS strm
378 let v
= streamView ss
379 (len
,n
) = lenToSum
(wh
+ v
) $ allHeights ww ss
380 ls
= reverse . take len
$ allLines ss
381 pls
= take wh
. drop (n
- (wh
+ v
)) $ concatMap
382 (reverse . zip (False:repeat True) . reverse . wrap ww
) ls
387 mvWAddStr win y
0 $ T
.unpack s
389 CH
.wSetStyle win
$ CH
.mkCursesStyle
[CH
.Reverse
]
390 mvWAddStr win y
(ww
-1) " "
392 |
((isWrap
,s
),y
) <- zip pls
[(wh
- length pls
) ..] ]
395 lenToSum
:: (Num n
, Ord n
) => n
-> [n
] -> (Int,n
)
396 lenToSum m
= go
0 0 where
397 go l n
(a
:as) | n
< m
= go
(l
+ 1) (n
+ a
) as
401 wrap
:: Int -> T
.Text
-> [T
.Text
]
402 wrap wrapWidth _ | wrapWidth
< 2 = error "Tried to wrap with too small width!"
403 wrap wrapWidth line
= wrap
' "" 0 . mergeSpaces
$ T
.groupBy ((==) `on`
isSpace) line
405 mergeSpaces
(s
:ws
) |
isSpace (T
.head s
) = s
: mergeSpaces ws
406 mergeSpaces
(w
:s
:ws
) = (w
<> s
) : mergeSpaces ws
409 wrap
' l n ws | n
> wrapWidth
=
410 let (a
,b
) = splitAtWC
(wrapWidth
- 1) l
in a
: wrap
' b
(n
- wcLength a
) ws
417 then (if T
.null l
then id else (l
:)) $ wrap
' w nw ws
420 splitAtWC
:: Int -> T
.Text
-> (T
.Text
,T
.Text
)
421 splitAtWC m
= go m T
.empty where
423 | Just
(c
,r
) <- T
.uncons t
= let w
= max 0 $ wcwidth c
in
424 if w
> max 0 n
then (T
.reverse acc
,t
)
425 else go
(n
- w
) (T
.cons c acc
) r
426 |
otherwise = (T
.reverse acc
, T
.empty)
428 lineHeight
:: Int -> T
.Text
-> Int
429 lineHeight w
= length . wrap w
431 eraseLast
:: TalkStream
-> Int64
-> StateT CState
IO ()
432 eraseLast strm n
= do
433 ss
<- gets
$ getSS strm
434 ww
<- liftIO getWidth
435 p
<- gets
(streamView
. getSS strm
)
436 let cur
= getReversed
$ streamCurLine ss
437 mightDecreaseHeight
=
438 -- +1 to handle case of wrapping at double-width char
439 wcLength cur
+ 1 > ww
440 wErase
= wcLength
$ T
.take n cur
441 modify
. mapSS strm
. mapCurLine
$ T
.drop n
442 if mightDecreaseHeight
443 then redrawStream strm
444 else when (p
== 0) . liftIO
$ do
445 let win
= streamWin ss
447 mvWAddStr win y
(x
- wErase
) (replicate wErase
' ')
448 wMove win y
$ x
- wErase
452 viewAddPg
:: TalkStream
-> Int -> StateT CState
IO ()
453 viewAddPg strm n
= do
454 wh
<- getStreamHeight strm
455 modView strm
(+ (n
* wh
))
457 modView
:: TalkStream
-> (Int -> Int) -> StateT CState
IO ()
459 ww
<- liftIO getWidth
460 wh
<- getStreamHeight strm
461 ss
<- gets
$ getSS strm
462 let h
= (+ (-wh
)) . sum . allHeights ww
$ ss
463 oldView
= streamView ss
464 newView
= max 0 . min h
$ f oldView
465 when (newView
/= oldView
) $ do
466 modify
. mapSS strm
$ \s
-> s
{ streamView
= newView
}