{-# LANGUAGE TemplateHaskell #-} module TUI ( TrackitEvent (..) , appMain ) where import Control.Monad (guard) import Control.Monad.Trans (liftIO) import Data.Char (toLower) import Data.Text (Text) import qualified Data.Text as Text import Lens.Micro.Platform ((&), (.~), (%~), makeLenses) import Brick ( App(..) , BrickEvent(..) , EventM , Location (..) , Next , Size (..) , Widget (..) , attrMap , availHeight , availWidth , continue , customMain , getContext , getVtyHandle , halt , neverShowCursor , raw , translateBy ) import Brick.BChan (BChan) import Graphics.Vty ( Event(..) , Image , Key(..) , Modifier(..) , (<|>) , black , defAttr , defaultConfig , displayBounds , imageWidth , mkVty , outputIface , text' , white , withBackColor , withForeColor ) import Options import ParseANSI -- | Create an 'Image' from a list of lines (ANSI codes supported) -- -- Given the first two arguments `x` and `w`, `x` characters are be dropped at -- the beginning of each line. After offsetting, each line is cropped to `w` -- characters. ansiImage :: Int -- ^ X offset -> Int -- ^ Available width -> [Text] -- ^ Lines -> Image ansiImage w x = foldMap (mkLine . takeSegs w . dropSegs x . parseANSI) where mkLine ss | imageWidth line == 0 = text' defAttr " " -- Apparently, each line must have at least one character, otherwise -- it doesn't take up any vertical space. | otherwise = line where line = foldr (<|>) mempty [text' a s | Segment a s <- ss] -- Note that horizontal panning of lines cannot be done outside of this -- function. If the lines contain ANSI codes, plain dropping and taking of -- characters in the 'Text' representation may lead to strange results. So -- panning can only be done after ANSI parsing. (See comment on `AppState` on -- why the buffer isn't parsed immediately.) -- | Case-insensitive key-press recognizer keyPressed :: Char -> BrickEvent n e -> Bool keyPressed c (VtyEvent (EvKey (KChar c') [])) = toLower c == toLower c' keyPressed _ _ = False data AppState = AppState { commandOutput :: [Text] -- ^ Lines in reverse , commandRunning :: !Bool , bufferWidth :: !Int -- ^ Width of the widest line in the buffer , bufferHeight :: !Int -- ^ Height of buffer , _xOffset :: !Int , _yOffset :: !Int , updateCount :: !Integer } deriving (Eq, Show) -- Note: One option would be to have `commandOutput :: [[Segments]]`; i.e. -- parse ANSI codes immediately when the buffer is read. This would add some -- type safety and would avoid having to parse the same line multiple times. -- However, tests show that this approach requires around 3 times more memory -- for large buffers. makeLenses ''AppState -- | Ensure that the offsets are within the available area clampState :: (Int, Int) -- ^ Available width, height -> AppState -> AppState clampState (w, h) s@AppState {..} | validOffset = s -- avoid allocation when nothing needs to change | otherwise = s & xOffset %~ (max 0 . min (bufferWidth - w)) & yOffset %~ (max 0 . min (bufferHeight - h)) where validOffset = and [ _xOffset >= 0 , _xOffset <= bufferWidth - w , _yOffset >= 0 , _yOffset <= bufferHeight - h ] data TrackitEvent = Running -- ^ A non-incremental command started running | Start -- ^ An incremental command started running | Done -- ^ An incremental command is done | AddLine Text -- ^ An incremental command produced a line | UpdateBuffer [Text] -- ^ A non-incremental command finished with the given output deriving (Eq, Show) initState :: AppState initState = AppState { commandOutput = [] , commandRunning = False , bufferWidth = 0 , bufferHeight = 0 , _xOffset = 0 , _yOffset = 0 , updateCount = 0 } bufferWidget :: AppState -> [Text] -- ^ Lines in reverse order -> Widget m bufferWidget AppState {..} ls = Widget Greedy Greedy $ do cxt <- getContext let (w, h) = (availWidth cxt, availHeight cxt) offsetFromEnd = bufferHeight - _yOffset - h visibleLines = reverse $ take h $ drop offsetFromEnd ls render $ raw $ ansiImage w _xOffset visibleLines drawApp :: Options -> AppState -> [Widget n] drawApp Options {..} s@AppState {..} = concat [ guard debug >> pure debugWidget , guard commandRunning >> pure runningWidget , pure $ bufferWidget s commandOutput ] where attr = defAttr `withForeColor` black `withBackColor` white runningText = "running.." runningWidget = Widget Fixed Fixed $ do cxt <- getContext let x = availWidth cxt - Text.length runningText render $ translateBy (Location (x, 0)) $ raw $ text' attr runningText -- I tried using `padLeft` instead, but it doesn't work because the -- padding overwrites any content below it. See this issue/question: -- debugText = "Update count: " <> Text.pack (show updateCount) debugWidget = Widget Fixed Fixed $ do cxt <- getContext let x = availWidth cxt - Text.length debugText y = availHeight cxt - 1 render $ translateBy (Location (x, y)) $ raw $ text' attr debugText stepApp :: Options -> IO () -- ^ Update request -> AppState -> BrickEvent n TrackitEvent -> EventM n (Next AppState) stepApp _ _ s (keyPressed 'q' -> True) = halt s stepApp _ updReq s (keyPressed ' ' -> True) = liftIO updReq >> continue s stepApp opts _ s ev = do vty <- getVtyHandle size <- liftIO $ displayBounds $ outputIface vty let s' = clampState size $ stepState opts ev size s continue s' stepState :: Options -> BrickEvent n TrackitEvent -> (Int, Int) -- ^ Available width, height -> AppState -> AppState stepState _ (AppEvent Running) _ s = s { commandRunning = True } stepState _ (AppEvent Start) _ s = s { commandOutput = [] , commandRunning = True , bufferWidth = 0 , bufferHeight = 0 } stepState _ (AppEvent Done) _ s@AppState {..} = s { commandRunning = False , updateCount = updateCount + 1 } stepState opts (AppEvent (AddLine line)) (_, h) s@AppState {..} = s { commandOutput = line : commandOutput , bufferWidth = bufferWidth `max` lengthSegs (parseANSI line) , bufferHeight = bufferHeight + 1 , _yOffset = if followTail opts then bufferHeight + 1 - h else _yOffset } stepState opts (AppEvent (UpdateBuffer buf)) (_, h) s@AppState {..} = s { commandOutput = buf , commandRunning = False , bufferWidth = maximum $ 0 : map (lengthSegs . parseANSI) buf , bufferHeight = len , _yOffset = if followTail opts then len - h else _yOffset , updateCount = updateCount + 1 } where len = length buf stepState _ (VtyEvent (EvKey kc [])) (w, h) s@AppState {bufferHeight} | kc `elem` [KDown, KChar 'j'] = s & yOffset %~ (+1) | kc `elem` [KUp, KChar 'k'] = s & yOffset %~ subtract 1 | kc `elem` [KLeft, KChar 'h'] = s & xOffset %~ subtract (div w 2) | kc `elem` [KRight, KChar 'l'] = s & xOffset %~ (+ div w 2) | kc `elem` [KHome, KChar 'g'] = s & yOffset .~ 0 | kc `elem` [KEnd, KChar 'G'] = s & yOffset .~ (bufferHeight - h) | kc == KPageUp = s & yOffset %~ subtract h | kc == KPageDown = s & yOffset %~ (+h) stepState _ (VtyEvent (EvKey kc [MCtrl])) _ s | kc == KChar 'u' = s & yOffset %~ subtract 25 | kc == KChar 'd' = s & yOffset %~ (+25) stepState _ _ _ s = s myApp :: Options -> IO () -- ^ Update request -> App AppState TrackitEvent () myApp opts updReq = App { appDraw = drawApp opts , appHandleEvent = stepApp opts updReq , appStartEvent = return , appAttrMap = const $ attrMap defAttr [] , appChooseCursor = neverShowCursor } appMain :: Options -> IO () -- ^ Update request -> BChan TrackitEvent -> IO AppState appMain opts updReq updEv = do vty <- mkVty defaultConfig customMain vty (mkVty defaultConfig) (Just updEv) (myApp opts updReq) initState