trackit-0.7: src/ParseANSI.hs
-- | Parse text containing ANSI escape codes
--
-- The parser only handles colors and the \"bold\" property at the moment.
module ParseANSI
( Segment (..)
, lengthSegs
, takeSegs
, dropSegs
, parseANSI
) where
-- Reference: <http://ascii-table.com/ansi-escape-sequences.php>
import Data.Foldable (fold)
import Data.Monoid (Endo (..))
import Data.Text (Text)
import qualified Data.Text as Text
import Graphics.Vty.Attributes
-- | A tab character represented as a number of spaces
tab :: Text
tab = Text.replicate tabLength " "
where
tabLength = 8
-- | Convert all tab characters to spaces
convTabs :: Text -> Text
convTabs = Text.concatMap $ \c -> case c of
'\t' -> tab
_ -> Text.singleton c
readMay :: Read a => Text -> Maybe a
readMay t = case reads $ Text.unpack t of
[(a, "")] -> Just a
_ -> Nothing
onHead :: (a -> a) -> [a] -> [a]
onHead _ [] = []
onHead f (a:as) = f a : as
esc = "\ESC["
-- | Parse a text that has been preceded by an 'esc' sequence
--
-- The result contains the control codes and the rest of the text.
parseEsc :: Text -> Maybe ([Int], Text)
parseEsc t = case Text.uncons rest of
Just ('m', rest') -> (, rest') <$> parseCodes codes
_ -> Nothing
where
codes = Text.takeWhile (/= 'm') t
rest = Text.dropWhile (/= 'm') t
parseCodes :: Text -> Maybe [Int]
parseCodes = mapM readMay . filter (not . Text.null) . Text.splitOn ";"
-- | Mapping from control code to 'Attr'
-- (reference: <http://ascii-table.com/ansi-escape-sequences.php>)
codeMap :: [(Int, Endo Attr)]
codeMap =
[ (1, Endo (`withStyle` bold))
, (30, Endo (`withForeColor` black))
, (31, Endo (`withForeColor` red))
, (32, Endo (`withForeColor` green))
, (33, Endo (`withForeColor` yellow))
, (34, Endo (`withForeColor` blue))
, (35, Endo (`withForeColor` magenta))
, (36, Endo (`withForeColor` cyan))
, (37, Endo (`withForeColor` white))
]
-- | Lookup a code in 'codeMap' and return @mempty@ if it's not present
lookCode :: Int -> Endo Attr
lookCode c = fold $ lookup c codeMap
-- | A text segment paired with some attribute
data Segment = Segment
{ attribute :: Attr
, content :: Text
} deriving (Eq, Show)
lengthSegs :: [Segment] -> Int
lengthSegs = sum . map (Text.length . content)
takeSegs :: Int -> [Segment] -> [Segment]
takeSegs _ [] = []
takeSegs n _
| n <= 0 = []
takeSegs n (s@Segment {..} : ss)
| len >= n = [s {content = Text.take n content}]
| otherwise = s : takeSegs (n-len) ss
where
len = Text.length content
dropSegs :: Int -> [Segment] -> [Segment]
dropSegs _ [] = []
dropSegs n ss
| n <= 0 = ss
dropSegs n (s@Segment {..} : ss)
| len <= n = dropSegs (n-len) ss
| otherwise = s {content = Text.drop n content} : ss
where
len = Text.length content
-- | Parse a segment that has been preceded by an 'esc' sequence and does not
-- have any other occurrences of 'esc' inside
parseSegment :: Text -> Segment
parseSegment s
| Just (cs, rest) <- parseEsc s = Segment (mkAttr cs) rest
| otherwise = Segment defAttr s
where
mkAttr cs = foldMap lookCode cs `appEndo` defAttr
-- | Parse a text containing ANSI control codes
parseANSI :: Text -> [Segment]
parseANSI = map parseSegment . onHead fixHead . Text.splitOn esc . convTabs
where
-- Ensure that the text starts with an escape code
fixHead :: Text -> Text
fixHead h = case Text.breakOn esc h of
("", _) -> h -- Already starts with `esc`
(h1, _empty) -> Text.cons 'm' h1
-- No `esc` in the string (because `splitOn` makes sure that the
-- separator is either first in the segment or absent)