packages feed

granite-0.7.0.0: src/Granite/Color.hs

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE PatternSynonyms #-}
{-# LANGUAGE Strict #-}

{- |
Module      : Granite.Color
Copyright   : (c) 2025
License     : MIT
Maintainer  : mschavinda@gmail.com

Color primitives shared by the terminal and SVG backends.

'Color' is a 24-bit RGB value. The classic ANSI palette names ('Black',
'BrightBlue', 'Default', …) are provided as bidirectional pattern synonyms
that expand to fixed RGB triples, so they may be used both as expressions
and in patterns exactly as before.

The two backends deliberately diverge on how a colour is realised:

  * the SVG backend renders the RGB exactly via 'colorHex';
  * the terminal backend approximates via 'ansiCode', which maps an
    arbitrary colour to the nearest of the 16 ANSI slots.

Because 'Color' is now a plain RGB triple, its /derived/ 'Show' prints
@Color r g b@ rather than the alias name.
-}
module Granite.Color (
    Color (
        ..,
        Black,
        Red,
        Green,
        Yellow,
        Blue,
        Magenta,
        Cyan,
        White,
        BrightBlack,
        BrightRed,
        BrightGreen,
        BrightYellow,
        BrightBlue,
        BrightMagenta,
        BrightCyan,
        BrightWhite,
        Default
    ),
    parseHex,
    ansiCode,
    ansiOn,
    ansiOff,
    paint,
    colorHex,
    paletteColors,
    pieColors,
) where

import Data.Char (digitToInt, isHexDigit)
import Data.List (minimumBy)
import Data.Ord (comparing)
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Word (Word8)
import Numeric (showHex)

-- | A 24-bit RGB colour.
data Color = Color !Word8 !Word8 !Word8
    deriving (Eq, Show, Read)

-- The classic ANSI palette, as bidirectional pattern-synonym aliases over
-- fixed RGB triples (the historical 'colorHex' values).

pattern Black :: Color
pattern Black = Color 0x2c 0x3e 0x50

pattern Red :: Color
pattern Red = Color 0xc0 0x39 0x2b

pattern Green :: Color
pattern Green = Color 0x27 0xae 0x60

pattern Yellow :: Color
pattern Yellow = Color 0xf3 0x9c 0x12

pattern Blue :: Color
pattern Blue = Color 0x29 0x80 0xb9

pattern Magenta :: Color
pattern Magenta = Color 0x8e 0x44 0xad

pattern Cyan :: Color
pattern Cyan = Color 0x16 0xa0 0x85

pattern White :: Color
pattern White = Color 0xec 0xf0 0xf1

pattern BrightBlack :: Color
pattern BrightBlack = Color 0x7f 0x8c 0x8d

pattern BrightRed :: Color
pattern BrightRed = Color 0xe7 0x4c 0x3c

pattern BrightGreen :: Color
pattern BrightGreen = Color 0x2e 0xcc 0x71

pattern BrightYellow :: Color
pattern BrightYellow = Color 0xf1 0xc4 0x0f

pattern BrightBlue :: Color
pattern BrightBlue = Color 0x34 0x98 0xdb

pattern BrightMagenta :: Color
pattern BrightMagenta = Color 0x9b 0x59 0xb6

pattern BrightCyan :: Color
pattern BrightCyan = Color 0x1a 0xbc 0x9c

pattern BrightWhite :: Color
pattern BrightWhite = Color 0xbd 0xc3 0xc7

pattern Default :: Color
pattern Default = Color 0x55 0x55 0x55

{- | ANSI escape code for a colour. The 16 named slots (plus 'Default') return
their canonical code; any other colour maps to the nearest ANSI slot.
-}
ansiCode :: Color -> Int
ansiCode Black = 30
ansiCode Red = 31
ansiCode Green = 32
ansiCode Yellow = 33
ansiCode Blue = 34
ansiCode Magenta = 35
ansiCode Cyan = 36
ansiCode White = 37
ansiCode BrightBlack = 90
ansiCode BrightRed = 91
ansiCode BrightGreen = 92
ansiCode BrightYellow = 93
ansiCode BrightBlue = 94
ansiCode BrightMagenta = 95
ansiCode BrightCyan = 96
ansiCode BrightWhite = 97
ansiCode Default = 39
ansiCode (Color r g b) = nearestAnsiCode r g b

{- | Map an arbitrary RGB colour to the nearest of the 16 ANSI slots, using the
canonical (VGA) ANSI palette as anchors — kept consistent with the terminal
chrome quantisation in "Granite.Render.Chrome".
-}
nearestAnsiCode :: Word8 -> Word8 -> Word8 -> Int
nearestAnsiCode r g b =
    snd
        (minimumBy (comparing fst) [(dist anchor, code) | (anchor, code) <- ansiAnchors])
  where
    ri = fromIntegral r :: Int
    gi = fromIntegral g
    bi = fromIntegral b
    dist (ar, ag, ab) = sq (ar - ri) + sq (ag - gi) + sq (ab - bi)
    sq x = x * x

ansiAnchors :: [((Int, Int, Int), Int)]
ansiAnchors =
    [ ((0, 0, 0), 30)
    , ((170, 0, 0), 31)
    , ((0, 170, 0), 32)
    , ((170, 85, 0), 33)
    , ((0, 0, 170), 34)
    , ((170, 0, 170), 35)
    , ((0, 170, 170), 36)
    , ((170, 170, 170), 37)
    , ((85, 85, 85), 90)
    , ((255, 85, 85), 91)
    , ((85, 255, 85), 92)
    , ((255, 255, 85), 93)
    , ((85, 85, 255), 94)
    , ((255, 85, 255), 95)
    , ((85, 255, 255), 96)
    , ((255, 255, 255), 97)
    ]

-- | Opening ANSI escape sequence selecting a colour (its nearest ANSI slot).
ansiOn :: Color -> Text
ansiOn c = "\ESC[" <> Text.pack (show (ansiCode c)) <> "m"

-- | ANSI reset escape sequence.
ansiOff :: Text
ansiOff = "\ESC[0m"

-- | Wrap a character in ANSI on/off sequences. Spaces pass through unchanged.
paint :: Color -> Char -> Text
paint c ch = if ch == ' ' then " " else ansiOn c <> Text.singleton ch <> ansiOff

-- | Exact @#rrggbb@ rendering of a colour, used by the SVG backend.
colorHex :: Color -> Text
colorHex (Color r g b) = "#" <> hex2 r <> hex2 g <> hex2 b
  where
    hex2 :: Word8 -> Text
    hex2 w =
        let s = showHex w ""
         in Text.pack (if length s < 2 then '0' : s else s)

{- | Parse a @"#rrggbb"@ (or @"rrggbb"@) string into a 'Color'. Returns
'Nothing' on malformed input.
-}
parseHex :: Text -> Maybe Color
parseHex t =
    case Text.unpack (Text.dropWhile (== '#') t) of
        cs@[r1, r2, g1, g2, b1, b2]
            | all isHexDigit cs ->
                Just (Color (byte r1 r2) (byte g1 g2) (byte b1 b2))
        _ -> Nothing
  where
    byte hi lo = fromIntegral (digitToInt hi * 16 + digitToInt lo)

-- | Default palette for layered charts (scatter, line, bars, …).
paletteColors :: [Color]
paletteColors =
    [ BrightBlue
    , BrightMagenta
    , BrightCyan
    , BrightGreen
    , BrightYellow
    , BrightRed
    , BrightWhite
    , BrightBlack
    ]

-- | Palette used by pie / box plots (categorical, more saturated).
pieColors :: [Color]
pieColors =
    [ BrightRed
    , BrightGreen
    , BrightYellow
    , BrightBlue
    , BrightMagenta
    , BrightCyan
    , BrightWhite
    , BrightBlack
    ]