packages feed

granite-0.6.0.0: src/Granite/Render/Terminal.hs

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

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

Terminal backend.
-}
module Granite.Render.Terminal (
    renderScene,
    Canvas (..),
    Array2D,
    newCanvas,
    setDotC,
    fillDotsC,
    lineDotsC,
    renderCanvas,
    toBit,
    getA2D,
    setA2D,
    newA2D,
) where

import Data.Bits ((.|.))
import Data.Char (chr)
import Data.List qualified as List
import Data.Maybe (isNothing)
import Data.Text (Text)
import Data.Text qualified as Text

import Granite.Color (Color, ansiOff, ansiOn, paint)
import Granite.Internal.Util (setAt, updateAt)
import Granite.Render.Scene (
    Mark (..),
    Point (..),
    Rect (..),
    Scene (..),
    Style (..),
    TextAnchor (..),
    TextStyle (..),
    pxPerChar,
    pxPerLine,
 )

renderScene :: Scene -> Text
renderScene scene =
    let wChars = max 1 (ceiling (sceneWidth scene / pxPerChar))
        hChars = max 1 (ceiling (sceneHeight scene / pxPerLine))
        grid0 = replicate hChars (replicate wChars (' ', Nothing :: Maybe Color))
        canvas0 = newCanvas wChars hChars
        (grid1, canvas1) = List.foldl' (drawMark wChars hChars) (grid0, canvas0) (sceneMarks scene)
        grid2 = mergeCanvas grid1 canvas1
     in Text.unlines (map renderRuns grid2)

mergeCanvas :: [[(Char, Maybe Color)]] -> Canvas -> [[(Char, Maybe Color)]]
mergeCanvas grid canvas =
    [ zipWith merge row [0 :: Int ..]
    | (y, row) <- zip [0 :: Int ..] grid
    , let merge (ch, mc) x =
            case (ch, mc) of
                (' ', Nothing) ->
                    let bits = getA2D (buffer canvas) x y
                        col = getA2D (cbuf canvas) x y
                        glyph = if bits == 0 then ' ' else chr (0x2800 + bits)
                     in (glyph, col)
                _ -> (ch, mc)
    ]

drawMark ::
    Int ->
    Int ->
    ([[(Char, Maybe Color)]], Canvas) ->
    Mark ->
    ([[(Char, Maybe Color)]], Canvas)
drawMark wChars hChars (grid, canvas) mark = case mark of
    MRect (Rect x y w h) sty ->
        let col = styleFill sty
            cx0 = clampInt 0 (wChars - 1) (floor (x / pxPerChar))
            cy0 = clampInt 0 (hChars - 1) (floor (y / pxPerLine))
            cx1 = clampInt 0 (wChars - 1) (floor ((x + w - 1) / pxPerChar))
            cy1 = clampInt 0 (hChars - 1) (floor ((y + h - 1) / pxPerLine))
            grid' =
                List.foldl'
                    ( \g cy ->
                        List.foldl'
                            (\g' cx -> setCell g' cx cy ('█', col))
                            g
                            [cx0 .. cx1]
                    )
                    grid
                    [cy0 .. cy1]
         in (grid', canvas)
    MText (Point x y) txt ts ->
        let cy = clampInt 0 (hChars - 1) (floor (y / pxPerLine))
            width = Text.length txt
            startCol = case textAnchor ts of
                AnchorStart -> floor (x / pxPerChar)
                AnchorMiddle -> floor (x / pxPerChar) - width `div` 2
                AnchorEnd -> floor (x / pxPerChar) - width
            grid' = placeChars grid wChars cy startCol (Text.unpack txt) (textFill ts)
         in (grid', canvas)
    MCircle (Point x y) r sty ->
        let col = styleFill sty
            xDotC = round (x * 2 / pxPerChar)
            yDotC = round (y * 4 / pxPerLine)
            rDotX = max 1 (round (r * 2 / pxPerChar))
            rDotY = max 1 (round (r * 4 / pxPerLine))
            canvas' =
                fillDotsC
                    (xDotC - rDotX, yDotC - rDotY)
                    (xDotC + rDotX, yDotC + rDotY)
                    ( \dx dy ->
                        let ddx = fromIntegral (dx - xDotC) / fromIntegral rDotX :: Double
                            ddy = fromIntegral (dy - yDotC) / fromIntegral rDotY :: Double
                         in ddx * ddx + ddy * ddy <= 1
                    )
                    col
                    canvas
         in (grid, canvas')
    MPolyline pts sty ->
        let col = styleFill sty
            toDot (Point x y) =
                ( round (x * 2 / pxPerChar)
                , round (y * 4 / pxPerLine)
                )
            dots = map toDot pts
            pairs = zip dots (drop 1 dots)
            canvas' =
                List.foldl'
                    (\c ((x0, y0), (x1, y1)) -> lineDotsC (x0, y0) (x1, y1) col c)
                    canvas
                    pairs
         in (grid, canvas')
    MPolygon pts sty ->
        let stroke = case styleStroke sty of
                Just c -> Just c
                Nothing -> styleFill sty
            outline =
                if null pts
                    then pts
                    else pts <> [head pts]
         in drawMark
                wChars
                hChars
                (grid, canvas)
                (MPolyline outline sty{styleStroke = stroke})
    MAxisLine (Point x1 y1) (Point x2 y2) sty ->
        let col = styleStroke sty
            grid' = drawAxisLine wChars hChars x1 y1 x2 y2 col grid
         in (grid', canvas)
    MArc (Point cx cy) r a0 a1 sty ->
        let nSeg = 32 :: Int
            ang i = a0 + (a1 - a0) * fromIntegral i / fromIntegral nSeg
            pts =
                [ Point (cx + r * cos (ang i)) (cy + r * sin (ang i))
                | i <- [0 .. nSeg]
                ]
         in drawMark wChars hChars (grid, canvas) (MPolyline pts sty)
    MPath _ _ -> (grid, canvas)
    MGroup ms ->
        List.foldl' (drawMark wChars hChars) (grid, canvas) ms

placeChars ::
    [[(Char, Maybe Color)]] ->
    Int ->
    Int ->
    Int ->
    String ->
    Color ->
    [[(Char, Maybe Color)]]
placeChars grid wChars cy startCol s col
    | cy < 0 || cy >= length grid = grid
    | otherwise =
        let row0 = grid !! cy
            row' = applyChars row0 wChars startCol s
         in setAt grid cy row'
  where
    applyChars row _ _ [] = row
    applyChars row w c (ch : rest)
        | c < 0 || c >= w = applyChars row w (c + 1) rest
        | otherwise =
            let row' = setAt row c (ch, Just col)
             in applyChars row' w (c + 1) rest

setCell ::
    [[(Char, Maybe Color)]] ->
    Int ->
    Int ->
    (Char, Maybe Color) ->
    [[(Char, Maybe Color)]]
setCell grid cx cy cell =
    updateAt grid cy (\row -> setAt row cx cell)

clampInt :: Int -> Int -> Int -> Int
clampInt low high x = max low (min high x)

drawAxisLine ::
    Int ->
    Int ->
    Double ->
    Double ->
    Double ->
    Double ->
    Maybe Color ->
    [[(Char, Maybe Color)]] ->
    [[(Char, Maybe Color)]]
drawAxisLine wChars hChars x1 y1 x2 y2 col grid
    | abs (y2 - y1) < 1e-6 =
        let cy = clampInt 0 (hChars - 1) (floor (y1 / pxPerLine))
            cxL = clampInt 0 (wChars - 1) (floor (min x1 x2 / pxPerChar))
            cxR = clampInt 0 (wChars - 1) (floor (max x1 x2 / pxPerChar))
         in List.foldl' (\g cx -> writeAxisCell g cx cy '─' col) grid [cxL .. cxR]
    | abs (x2 - x1) < 1e-6 =
        let cx = clampInt 0 (wChars - 1) (floor (x1 / pxPerChar))
            cyT = clampInt 0 (hChars - 1) (floor (min y1 y2 / pxPerLine))
            cyB = clampInt 0 (hChars - 1) (floor (max y1 y2 / pxPerLine))
         in List.foldl' (\g cy -> writeAxisCell g cx cy '│' col) grid [cyT .. cyB]
    | otherwise = grid

writeAxisCell ::
    [[(Char, Maybe Color)]] ->
    Int ->
    Int ->
    Char ->
    Maybe Color ->
    [[(Char, Maybe Color)]]
writeAxisCell grid cx cy ch col =
    let existing = case grid `atRow` cy >>= (`atCol` cx) of
            Just (c, _) -> c
            Nothing -> ' '
        finalCh = combineAxis existing ch
     in setCell grid cx cy (finalCh, col)
  where
    atRow rs i
        | i < 0 || i >= length rs = Nothing
        | otherwise = Just (rs !! i)
    atCol cs i
        | i < 0 || i >= length cs = Nothing
        | otherwise = Just (cs !! i)

combineAxis :: Char -> Char -> Char
combineAxis existing new
    | existing == '│' && new == '─' = '┼'
    | existing == '─' && new == '│' = '┼'
    | existing == '┼' = '┼'
    | otherwise = new

renderRuns :: [(Char, Maybe Color)] -> Text
renderRuns = go
  where
    go [] = Text.empty
    go xs@((_, Nothing) : _) =
        let (plain, rest) = span (\(_, mc) -> case mc of Nothing -> True; _ -> False) xs
         in Text.pack (map fst plain) <> go rest
    go ((ch, Just c) : rest) =
        let (run, after) =
                span (\(_, mc) -> mc == Just c || isNothing mc) rest
            chunk = ch : map fst run
         in if all (== ' ') chunk
                then Text.pack chunk <> go after
                else ansiOn c <> Text.pack chunk <> ansiOff <> go after

data Array2D a = A2D Int Int (Arr a)

getA2D :: Array2D a -> Int -> Int -> a
getA2D (A2D w _ xs) x y = indexA xs (y * w + x)

setA2D :: Array2D a -> Int -> Int -> a -> Array2D a
setA2D (A2D w h xs) x y v =
    let i = y * w + x
     in A2D w h (setArr xs i v)

newA2D :: Int -> Int -> a -> Array2D a
newA2D w h v = A2D w h (fromList (replicate (w * h) v))

toBit :: Int -> Int -> Int
toBit ry rx = case (ry, rx) of
    (0, 0) -> 1
    (1, 0) -> 2
    (2, 0) -> 4
    (3, 0) -> 64
    (0, 1) -> 8
    (1, 1) -> 16
    (2, 1) -> 32
    (3, 1) -> 128
    _ -> 0

data Canvas = Canvas
    { cW :: Int
    , cH :: Int
    , buffer :: Array2D Int
    , cbuf :: Array2D (Maybe Color)
    }

newCanvas :: Int -> Int -> Canvas
newCanvas w h = Canvas w h (newA2D w h 0) (newA2D w h Nothing)

setDotC :: Canvas -> Int -> Int -> Maybe Color -> Canvas
setDotC c xDot yDot mcol
    | xDot < 0 || yDot < 0 || xDot >= cW c * 2 || yDot >= cH c * 4 = c
    | otherwise =
        let (cx, rx) = xDot `divMod` 2
            (cy, ry) = yDot `divMod` 4
            b = toBit ry rx
            m = getA2D (buffer c) cx cy
            c' = c{buffer = setA2D (buffer c) cx cy (m .|. b)}
         in case mcol of
                Nothing -> c'
                Just col -> c'{cbuf = setA2D (cbuf c) cx cy (Just col)}

fillDotsC ::
    (Int, Int) ->
    (Int, Int) ->
    (Int -> Int -> Bool) ->
    Maybe Color ->
    Canvas ->
    Canvas
fillDotsC (x0, y0) (x1, y1) p mcol c0 =
    let xs = [max 0 x0 .. min (cW c0 * 2 - 1) x1]
        ys = [max 0 y0 .. min (cH c0 * 4 - 1) y1]
     in List.foldl'
            (\c y -> List.foldl' (\c' x -> if p x y then setDotC c' x y mcol else c') c xs)
            c0
            ys

lineDotsC :: (Int, Int) -> (Int, Int) -> Maybe Color -> Canvas -> Canvas
lineDotsC (x0, y0) (x1, y1) mcol c0 =
    let dx = abs (x1 - x0)
        sx = if x0 < x1 then 1 else -1
        dy = negate (abs (y1 - y0))
        sy = if y0 < y1 then 1 else -1
        go x y err c
            | x == x1 && y == y1 = setDotC c x y mcol
            | otherwise =
                let e2 = 2 * err
                    (x', err') = if e2 >= dy then (x + sx, err + dy) else (x, err)
                    (y', err'') = if e2 <= dx then (y + sy, err' + dx) else (y, err')
                 in go x' y' err'' (setDotC c x y mcol)
     in go x0 y0 (dx + dy) c0

renderCanvas :: Canvas -> Text
renderCanvas (Canvas w h a colA) =
    let glyph 0 = ' '
        glyph m = chr (0x2800 + m)
        rows =
            fmap
                ( \y -> flip fmap [0 .. w - 1] $ \x ->
                    let m = getA2D a x y
                        ch = glyph m
                        mc = getA2D colA x y
                     in maybe (Text.singleton ch) (`paint` ch) mc
                )
                [0 .. h - 1]
     in Text.unlines (fmap Text.concat rows)

data Arr a
    = E
    | N Int Int (Arr a) a (Arr a)

sizeA :: Arr a -> Int
sizeA E = 0
sizeA (N sz _ _ _ _) = sz

heightA :: Arr a -> Int
heightA E = 0
heightA (N _ h _ _ _) = h

mk :: Arr a -> a -> Arr a -> Arr a
mk l x r = N sz h l x r
  where
    sl = sizeA l
    sr = sizeA r
    hl = heightA l
    hr = heightA r
    sz = 1 + sl + sr
    h = 1 + max hl hr

rotateL :: Arr a -> Arr a
rotateL (N _ _ l x (N _ _ rl y rr)) = mk (mk l x rl) y rr
rotateL _ = error "rotateL: malformed tree"

rotateR :: Arr a -> Arr a
rotateR (N _ _ (N _ _ ll y lr) x r) = mk ll y (mk lr x r)
rotateR _ = error "rotateR: malformed tree"

balance :: Arr a -> Arr a
balance t@(N _ _ l x r)
    | heightA l > heightA r + 1 =
        case l of
            N _ _ ll _ lr ->
                if heightA ll >= heightA lr
                    then rotateR t
                    else rotateR (mk (rotateL l) x r)
            _ -> t
    | heightA r > heightA l + 1 =
        case r of
            N _ _ rl _ rr ->
                if heightA rr >= heightA rl
                    then rotateL t
                    else rotateL (mk l x (rotateR r))
            _ -> t
    | otherwise = mk l x r
balance t = t

indexA :: Arr a -> Int -> a
indexA t i =
    case t of
        E -> error ("index out of bounds: " <> show i)
        N _ _ l x r ->
            let sl = sizeA l
             in if i < 0 || i >= 1 + sl + sizeA r
                    then error ("index out of bounds: " <> show i)
                    else
                        if i < sl
                            then indexA l i
                            else
                                if i == sl
                                    then x
                                    else indexA r (i - sl - 1)

setArr :: Arr a -> Int -> a -> Arr a
setArr t i y =
    case t of
        E -> error ("index out of bounds when setting: " <> show i)
        N _ _ l x r ->
            let sl = sizeA l
             in if i < 0 || i >= 1 + sl + sizeA r
                    then error ("index out of bounds: " <> show i)
                    else
                        if i < sl
                            then balance (mk (setArr l i y) x r)
                            else
                                if i == sl
                                    then mk l y r
                                    else balance (mk l x (setArr r (i - sl - 1) y))

fromList :: [a] -> Arr a
fromList xs = fst (build (length xs) xs)
  where
    build :: Int -> [a] -> (Arr a, [a])
    build 0 ys = (E, ys)
    build n ys =
        let (l, ys1) = build (n `div` 2) ys
            (x, ys2) = case ys1 of
                [] -> error "IMPOSSIBLE"
                (v : vs) -> (v, vs)
            (r, ys3) = build (n - n `div` 2 - 1) ys2
         in (mk l x r, ys3)