{-# language DeriveGeneric #-}
{-# language GeneralizedNewtypeDeriving #-}
{-# language OverloadedStrings #-}
{-# language PackageImports #-}
{-# language ScopedTypeVariables #-}
module System.ProgressBar
(
ProgressBar
, newProgressBar
, hNewProgressBar
, renderProgressBar
, updateProgress
, incProgress
, Style(..)
, EscapeCode
, defStyle
, ProgressBarWidth(..)
, Progress(..)
, Label(..)
, Timing(..)
, msg
, percentage
, exact
, elapsedTime
, remainingTime
, totalTime
, renderDuration
) where
import "base" Control.Concurrent.MVar ( MVar, newMVar, modifyMVar_)
import "base" Control.Monad ( when )
import "base" Data.Int ( Int64 )
import "base" Data.Monoid ( Monoid, mempty )
import "base" Data.Ratio ( Ratio, (%) )
import "base" Data.Semigroup ( Semigroup, (<>) )
import "base" Data.String ( IsString, fromString )
import "base" GHC.Generics ( Generic )
import "base" System.IO ( Handle, stderr, hFlush )
import "deepseq" Control.DeepSeq ( NFData, rnf )
import qualified "terminal-size" System.Console.Terminal.Size as TS
import qualified "text" Data.Text.Lazy as TL
import qualified "text" Data.Text.Lazy.Builder as TLB
import qualified "text" Data.Text.Lazy.Builder.Int as TLB
import qualified "text" Data.Text.Lazy.IO as TL
import "time" Data.Time.Clock ( UTCTime, NominalDiffTime, diffUTCTime, getCurrentTime )
data ProgressBar s
= ProgressBar
{ pbStyle :: !(Style s)
, pbStateMv :: !(MVar (State s))
, pbRefreshDelay :: !Double
, pbStartTime :: !UTCTime
, pbHandle :: !Handle
}
instance (NFData s) => NFData (ProgressBar s) where
rnf pb = pbStyle pb
`seq` pbStateMv pb
`seq` pbRefreshDelay pb
`seq` pbStartTime pb
`seq` ()
data State s
= State
{ stProgress :: !(Progress s)
, stRenderTime :: !UTCTime
}
data Progress s
= Progress
{ progressDone :: !Int
, progressTodo :: !Int
, progressCustom :: !s
}
progressFinished :: Progress s -> Bool
progressFinished p = progressDone p >= progressTodo p
newProgressBar
:: Style s
-> Double
-> Progress s
-> IO (ProgressBar s)
newProgressBar = hNewProgressBar stderr
hNewProgressBar
:: Handle
-> Style s
-> Double
-> Progress s
-> IO (ProgressBar s)
hNewProgressBar hndl style maxRefreshRate initProgress = do
style' <- updateWidth style
startTime <- getCurrentTime
hPutProgressBar hndl style' initProgress (Timing startTime startTime)
stateMv <- newMVar
State
{ stProgress = initProgress
, stRenderTime = startTime
}
pure ProgressBar
{ pbStyle = style'
, pbStateMv = stateMv
, pbRefreshDelay = recip maxRefreshRate
, pbStartTime = startTime
, pbHandle = hndl
}
updateWidth :: Style s -> IO (Style s)
updateWidth style =
case styleWidth style of
ConstantWidth {} -> pure style
TerminalWidth {} -> do
mbWindow <- TS.size
pure $ case mbWindow of
Nothing -> style
Just window -> style{ styleWidth = TerminalWidth (TS.width window) }
updateProgress
:: forall s. ProgressBar s -> (Progress s -> Progress s) -> IO ()
updateProgress progressBar f = do
updateTime <- getCurrentTime
modifyMVar_ (pbStateMv progressBar) $ renderAndUpdate updateTime
where
renderAndUpdate :: UTCTime -> State s -> IO (State s)
renderAndUpdate updateTime state = do
when shouldRender $
hPutProgressBar hndl (pbStyle progressBar) newProgress timing
pure State
{ stProgress = newProgress
, stRenderTime = if shouldRender then updateTime else stRenderTime state
}
where
timing = Timing
{ timingStart = pbStartTime progressBar
, timingLastUpdate = updateTime
}
shouldRender = not tooFast || finished
tooFast = secSinceLastRender <= pbRefreshDelay progressBar
finished = progressFinished newProgress
newProgress = f $ stProgress state
secSinceLastRender :: Double
secSinceLastRender = realToFrac $ diffUTCTime updateTime (stRenderTime state)
hndl = pbHandle progressBar
incProgress :: ProgressBar s -> Int -> IO ()
incProgress pb n = updateProgress pb $ \p -> p{ progressDone = progressDone p + n }
hPutProgressBar :: Handle -> Style s -> Progress s -> Timing -> IO ()
hPutProgressBar hndl style progress timing = do
TL.hPutStr hndl $ renderProgressBar style progress timing
TL.hPutStr hndl $
if progressFinished progress
then "\n"
else "\r"
hFlush hndl
renderProgressBar :: Style s -> Progress s -> Timing -> TL.Text
renderProgressBar style progress timing = TL.concat
[ styleEscapePrefix style progress
, prefixLabel
, prefixPad
, styleEscapeOpen style progress
, styleOpen style
, styleEscapeDone style progress
, TL.replicate completed $ TL.singleton $ styleDone style
, styleEscapeCurrent style progress
, if remaining /= 0 && completed /= 0
then TL.singleton $ styleCurrent style
else ""
, styleEscapeTodo style progress
, TL.replicate
(remaining - if completed /= 0 then 1 else 0)
(TL.singleton $ styleTodo style)
, styleEscapeClose style progress
, styleClose style
, styleEscapePostfix style progress
, postfixPad
, postfixLabel
]
where
todo = fromIntegral $ progressTodo progress
done = fromIntegral $ progressDone progress
width = fromIntegral $ getProgressBarWidth $ styleWidth style
fraction :: Ratio Int64
fraction | todo /= 0 = done % todo
| otherwise = 0 % 1
effectiveWidth = max 0 $ width - usedSpace
usedSpace = TL.length (styleOpen style)
+ TL.length (styleClose style)
+ TL.length prefixLabel
+ TL.length postfixLabel
+ TL.length prefixPad
+ TL.length postfixPad
numCompletedChars :: Ratio Int64
numCompletedChars = fraction * (effectiveWidth % 1)
completed, remaining :: Int64
completed = min effectiveWidth $ floor numCompletedChars
remaining = effectiveWidth - completed
prefixLabel, postfixLabel :: TL.Text
prefixLabel = runLabel (stylePrefix style) progress timing
postfixLabel = runLabel (stylePostfix style) progress timing
prefixPad, postfixPad :: TL.Text
prefixPad = pad prefixLabel
postfixPad = pad postfixLabel
pad :: TL.Text -> TL.Text
pad s | TL.null s = TL.empty
| otherwise = TL.singleton ' '
data ProgressBarWidth
= ConstantWidth !Int
| TerminalWidth !Int
deriving (Generic)
instance NFData ProgressBarWidth
getProgressBarWidth :: ProgressBarWidth -> Int
getProgressBarWidth (ConstantWidth n) = n
getProgressBarWidth (TerminalWidth n) = n
data Style s
= Style
{ styleOpen :: !TL.Text
, styleClose :: !TL.Text
, styleDone :: !Char
, styleCurrent :: !Char
, styleTodo :: !Char
, stylePrefix :: Label s
, stylePostfix :: Label s
, styleWidth :: !ProgressBarWidth
, styleEscapeOpen :: EscapeCode s
, styleEscapeClose :: EscapeCode s
, styleEscapeDone :: EscapeCode s
, styleEscapeCurrent :: EscapeCode s
, styleEscapeTodo :: EscapeCode s
, styleEscapePrefix :: EscapeCode s
, styleEscapePostfix :: EscapeCode s
} deriving (Generic)
instance (NFData s) => NFData (Style s)
type EscapeCode s
= Progress s
-> TL.Text
defStyle :: Style s
defStyle =
Style
{ styleOpen = "["
, styleClose = "]"
, styleDone = '='
, styleCurrent = '>'
, styleTodo = '.'
, stylePrefix = mempty
, stylePostfix = percentage
, styleWidth = TerminalWidth 50
, styleEscapeOpen = const TL.empty
, styleEscapeClose = const TL.empty
, styleEscapeDone = const TL.empty
, styleEscapeCurrent = const TL.empty
, styleEscapeTodo = const TL.empty
, styleEscapePrefix = const TL.empty
, styleEscapePostfix = const TL.empty
}
newtype Label s = Label{ runLabel :: Progress s -> Timing -> TL.Text } deriving (NFData)
instance Semigroup (Label s) where
Label f <> Label g = Label $ \p t -> f p t <> g p t
instance Monoid (Label s) where
mempty = msg TL.empty
mappend = (<>)
instance IsString (Label s) where
fromString = msg . TL.pack
data Timing
= Timing
{ timingStart :: !UTCTime
, timingLastUpdate :: !UTCTime
}
msg :: TL.Text -> Label s
msg s = Label $ \_ _ -> s
percentage :: Label s
percentage = Label render
where
render progress _timing
| todo == 0 = "100%"
| otherwise = TL.justifyRight 4 ' ' $ TLB.toLazyText $
TLB.decimal (round (done % todo * 100) :: Int)
<> TLB.singleton '%'
where
done = progressDone progress
todo = progressTodo progress
exact :: Label s
exact = Label render
where
render progress _timing =
TL.justifyRight (TL.length todoStr) ' ' doneStr <> "/" <> todoStr
where
todoStr = TLB.toLazyText $ TLB.decimal todo
doneStr = TLB.toLazyText $ TLB.decimal done
done = progressDone progress
todo = progressTodo progress
elapsedTime
:: (NominalDiffTime -> TL.Text)
-> Label s
elapsedTime formatNDT = Label render
where
render _progress timing = formatNDT dt
where
dt :: NominalDiffTime
dt = diffUTCTime (timingLastUpdate timing) (timingStart timing)
remainingTime
:: (NominalDiffTime -> TL.Text)
-> TL.Text
-> Label s
remainingTime formatNDT altMsg = Label render
where
render progress timing
| dt > 1 = formatNDT estimatedRemainingTime
| progressDone progress <= 0 = altMsg
| otherwise = altMsg
where
estimatedRemainingTime = estimatedTotalTime - dt
estimatedTotalTime = dt * recip progressFraction
progressFraction :: NominalDiffTime
progressFraction
| progressTodo progress <= 0 = 1
| otherwise = fromIntegral (progressDone progress)
/ fromIntegral (progressTodo progress)
dt :: NominalDiffTime
dt = diffUTCTime (timingLastUpdate timing) (timingStart timing)
totalTime
:: (NominalDiffTime -> TL.Text)
-> TL.Text
-> Label s
totalTime formatNDT altMsg = Label render
where
render progress timing
| dt > 1 = formatNDT estimatedTotalTime
| progressDone progress <= 0 = altMsg
| otherwise = altMsg
where
estimatedTotalTime = dt * recip progressFraction
progressFraction :: NominalDiffTime
progressFraction
| progressTodo progress <= 0 = 1
| otherwise = fromIntegral (progressDone progress)
/ fromIntegral (progressTodo progress)
dt :: NominalDiffTime
dt = diffUTCTime (timingLastUpdate timing) (timingStart timing)
renderDuration :: NominalDiffTime -> TL.Text
renderDuration dt = hTxt <> mTxt <> sTxt
where
hTxt | h == 0 = mempty
| otherwise = renderDecimal h <> ":"
mTxt | m == 0 = mempty
| otherwise = renderDecimal m <> ":"
sTxt = renderDecimal s
(h, hRem) = ts `quotRem` 3600
(m, s ) = hRem `quotRem` 60
ts :: Int
ts = round dt
renderDecimal n = TL.justifyRight 2 '0' $ TLB.toLazyText $ TLB.decimal n