packages feed

sectile-0.1.0.0: src/Data/Sectile/Display.hs

-- |
-- Module        : Data.Sectile.Display
-- Copyright     : Gautier DI FOLCO
-- License       : ISC
--
-- Maintainer    : Gautier DI FOLCO <foss@difolco.dev>
-- Stability     : Stable
-- Portability   : Portable
module Data.Sectile.Display
  ( -- * Truncation
    takeStart,
    takeEnd,

    -- * Padding
    padStart,
    padEnd,

    -- * Fixed-size
    fixedSizeStart,
    fixedSizeEnd,

    -- * Regex rewriting
    regex,

    -- * Combinators
    progressBar,
    hideIf,
    marquee,
  )
where

import qualified Control.Lens as Lens
import qualified Control.Lens.Regex.Text as Regex
import qualified Data.Char as Char
import Data.Maybe (listToMaybe)
import qualified Data.Sectile.Tmux as Colour
import Data.Sectile.Types
import qualified Data.Text as T
import qualified Data.Text.Read as T
import qualified Data.Time.Clock.POSIX as Time
import qualified Text.Regex.PCRE.Light as PCRE

-- | Keep only the first @n@ characters of a segment's rendered text.
--
-- Truncates chunks from the end to fit within the character limit.
--
-- Example:
--
-- > import Data.Sectile
-- > import Data.Sectile.Display
-- >
-- > short :: Segment IO -> Segment IO
-- > short = takeStart 10
-- > -- "Hello, world!" becomes "Hello, wor"
takeStart :: (Functor m) => Int -> Segment m -> Segment m
takeStart = transformChunks . chunksStart

-- | Keep only the last @n@ characters of a segment's rendered text.
--
-- Truncates chunks from the start to fit within the character limit.
--
-- Example:
--
-- > import Data.Sectile
-- > import Data.Sectile.Display
-- >
-- > tail5 :: Segment IO -> Segment IO
-- > tail5 = takeEnd 5
-- > -- "Hello, world!" becomes "orld!"
takeEnd :: (Functor m) => Int -> Segment m -> Segment m
takeEnd = transformChunks . chunksEnd

-- | Pad the start of a segment with spaces to reach at least @n@ characters.
--
-- If the segment is already @n@ or more characters, it is unchanged.
--
-- Example:
--
-- > import Data.Sectile
-- > import Data.Sectile.Display
-- >
-- > rightAligned :: Segment IO -> Segment IO
-- > rightAligned = padStart 20
-- > -- "hi" becomes "                  hi"
padStart :: (Functor m) => Int -> Segment m -> Segment m
padStart n = transformChunks (padChunksStart n)

-- | Pad the end of a segment with spaces to reach at least @n@ characters.
--
-- If the segment is already @n@ or more characters, it is unchanged.
--
-- Example:
--
-- > import Data.Sectile
-- > import Data.Sectile.Display
-- >
-- > leftAligned :: Segment IO -> Segment IO
-- > leftAligned = padEnd 20
-- > -- "hi" becomes "hi                  "
padEnd :: (Functor m) => Int -> Segment m -> Segment m
padEnd n = transformChunks (padChunksEnd n)

-- | Constrain a segment to exactly @n@ characters, padding at the start
-- or truncating from the end as needed.
--
-- Example:
--
-- > import Data.Sectile
-- > import Data.Sectile.Display
-- >
-- > fixed :: Segment IO -> Segment IO
-- > fixed = fixedSizeStart 10
-- > -- "Hi" becomes "        Hi"
-- > -- "Hello, world!" becomes "Hello, wor"
fixedSizeStart :: (Functor m) => Int -> Segment m -> Segment m
fixedSizeStart n = transformChunks (padChunksStart n . chunksStart n)

-- | Constrain a segment to exactly @n@ characters, padding at the end
-- or truncating from the start as needed.
--
-- Example:
--
-- > import Data.Sectile
-- > import Data.Sectile.Display
-- >
-- > fixed :: Segment IO -> Segment IO
-- > fixed = fixedSizeEnd 10
-- > -- "Hi" becomes "Hi        "
-- > -- "Hello, world!" becomes "orld!"
fixedSizeEnd :: (Functor m) => Int -> Segment m -> Segment m
fixedSizeEnd n = transformChunks (padChunksEnd n . chunksEnd n)

-- | Apply a PCRE regex replacement to a segment's rendered text.
--
-- Takes a compiled regex and a replacement function.
-- The replacement function receives the matched text and returns
-- the replacement.
--
-- Example:
--
-- > import Data.Sectile
-- > import Data.Sectile.Display
-- > import qualified Text.Regex.PCRE.Light as PCRE
-- >
-- > -- Remove all digits
-- > noDigits :: Segment IO -> Segment IO
-- > noDigits seg =
-- >   let pat = PCRE.compile "[0-9]+" []
-- >    in regex pat (const "") seg
regex :: (Functor m) => PCRE.Regex -> (T.Text -> T.Text) -> Segment m -> Segment m
regex pat replacement = transformChunks (regexReplace pat replacement)

-- | Convert a numerical segment output into an ASCII progress bar.
--
-- Parses the first sequence of digits from the rendered text and bounds it between 0-100.
-- Replaces the text with a bar of the specified width.
--
-- Example:
--
-- > import Data.Sectile
-- > import Data.Sectile.Display
-- >
-- > batBar :: Segment IO
-- > batBar = progressBar 10 (string "50%")
-- > -- Renders as "[====    ]"
progressBar :: (Functor m) => Int -> Segment m -> Segment m
progressBar width = transformChunks $ \cs ->
  let txt = mconcat $ map Colour.chunkText cs
      digits = T.filter Char.isDigit txt
      parsed = case T.decimal digits of
        Right (n, _) -> n
        Left _ -> 0 :: Int
      pct = max 0 $ min 100 parsed
      filled = (pct * width) `div` 100
      empty = width - filled
      bar = "[" <> T.replicate filled "=" <> T.replicate empty " " <> "]"
   in [Colour.Chunk bar Colour.noStyle]

-- | Conditionally hide a segment if its rendered text matches a predicate.
--
-- Example:
--
-- > import Data.Sectile
-- > import Data.Sectile.Display
-- > import qualified Data.Text as T
-- >
-- > hideEmpty :: Segment IO -> Segment IO
-- > hideEmpty = hideIf T.null
hideIf :: (Functor m) => (T.Text -> Bool) -> Segment m -> Segment m
hideIf p = transformChunks $ \cs ->
  if p (mconcat $ map Colour.chunkText cs) then [] else cs

-- | Scroll a long segment text horizontally over time.
--
-- Takes a fixed width and a number of seconds per tick.
--
-- Example:
--
-- > import Data.Sectile
-- > import Data.Sectile.Display
-- >
-- > scrolling :: Segment IO -> Segment IO
-- > scrolling = marquee 10 1
marquee :: Int -> Int -> Segment IO -> Segment IO
marquee width tickLenSeg (Segment s) = Segment $ do
  now <- Time.getPOSIXTime
  let ticks = floor now `div` tickLenSeg
  runSeg <- s
  pure $ do
    formatted <- runSeg
    let txt = mconcat $ map Colour.chunkText formatted.rendered
        len = T.length txt
        shifted =
          if len <= width
            then txt
            else
              let offset = ticks `mod` len
                  padded = txt <> " " <> txt
               in T.take width (T.drop offset padded)
    pure
      formatted
        { rendered = [Colour.Chunk shifted Colour.noStyle],
          explain = \renderSyle renderChunks ->
            formatted.explain renderSyle $ renderChunks . const [Colour.Chunk shifted Colour.noStyle]
        }

-- Internal helpers

-- | Apply a chunk transformation to a segment.
transformChunks :: (Functor m) => ([Colour.Chunk] -> [Colour.Chunk]) -> Segment m -> Segment m
transformChunks f (Segment s) = Segment $ fmap transform s
  where
    transform g = do
      formatted <- g
      pure
        formatted
          { rendered = f formatted.rendered,
            explain = \renderSyle renderChunks ->
              formatted.explain renderSyle $ renderChunks . f
          }

-- | Total width of a list of chunks.
chunksWidth :: [Colour.Chunk] -> Int
chunksWidth = sum . map Colour.chunkWidth

-- | Take the first n characters across chunks.
chunksStart :: Int -> [Colour.Chunk] -> [Colour.Chunk]
chunksStart _ [] = []
chunksStart n _ | n <= 0 = []
chunksStart n (c : cs) =
  let w = Colour.chunkWidth c
   in if w <= n
        then c : chunksStart (n - w) cs
        else [c {Colour.chunkText = T.take n c.chunkText}]

-- | Take the last n characters across chunks.
chunksEnd :: Int -> [Colour.Chunk] -> [Colour.Chunk]
chunksEnd n cs = map reverseChunk $ reverse $ chunksStart n $ reverse $ map reverseChunk cs
  where
    reverseChunk c = c {Colour.chunkText = T.reverse c.chunkText}

-- | Pad chunks at the start with spaces to reach width n.
padChunksStart :: Int -> [Colour.Chunk] -> [Colour.Chunk]
padChunksStart n cs =
  let w = chunksWidth cs
      padding = n - w
   in if padding > 0
        then mkPadChunk padding (firstStyle cs) : cs
        else cs

-- | Pad chunks at the end with spaces to reach width n.
padChunksEnd :: Int -> [Colour.Chunk] -> [Colour.Chunk]
padChunksEnd n cs =
  let w = chunksWidth cs
      padding = n - w
   in if padding > 0
        then cs <> [mkPadChunk padding (firstStyle cs)]
        else cs

-- | Create a padding chunk of n spaces.
mkPadChunk :: Int -> Colour.ChunkStyle -> Colour.Chunk
mkPadChunk n style =
  Colour.Chunk
    { Colour.chunkText = T.replicate n " ",
      Colour.chunkStyle = style
    }

firstStyle :: [Colour.Chunk] -> Colour.ChunkStyle
firstStyle = maybe Colour.noStyle Colour.chunkStyle . listToMaybe

-- | Apply regex replacement to chunk text.
regexReplace :: PCRE.Regex -> (T.Text -> T.Text) -> [Colour.Chunk] -> [Colour.Chunk]
regexReplace pat replacement = map replaceInChunk
  where
    replaceInChunk c =
      c {Colour.chunkText = Lens.over (Regex.regexing pat . Regex.match) replacement c.chunkText}