reflex-vty-1.0.0.0: src/Reflex/Vty/Canvas.hs
-- |
-- Module: Reflex.Vty.Canvas
-- Description: Per-cell compositing with transparency
--
-- A 'Canvas' is a 2D grid of @Maybe (Char, Attr)@ cells where 'Nothing'
-- means transparent. This allows compositing visual layers with
-- per-cell transparency — something 'V.Image' cannot do.
--
-- Typical usage: capture a child widget's images via 'captureImages',
-- convert to a 'Canvas' with 'imageToCanvas', composite overlay
-- 'Canvas'es on top with 'stack', then convert back to an 'Image' with
-- 'canvasToImage' and emit via 'tellImages'.
module Reflex.Vty.Canvas
( Canvas (..)
, canvasCellAt
, blankCanvas
, placeCanvas
, translate
, stack
, imageToCanvas
, canvasToImage
) where
import Data.List (foldl')
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
import qualified Data.Text as T
import qualified Data.Text.Lazy as TL
import Graphics.Text.Width (wcwidth)
import qualified Graphics.Vty as V
import Graphics.Vty.Image.Internal (Image (BGFill, Crop, EmptyImage, HorizJoin, HorizText, VertJoin))
-- | A 2D grid of cells. Cells not in the 'Map' are transparent
-- ('Nothing'). The 'Map' is keyed by @(x, y)@ where @(0,0)@ is the
-- top-left corner.
data Canvas = Canvas
{ canvasWidth :: !Int
, canvasHeight :: !Int
, canvasCells :: !(Map (Int, Int) (Char, V.Attr))
}
deriving (Eq, Show)
-- | A fully transparent canvas of the given dimensions.
blankCanvas :: Int -> Int -> Canvas
blankCanvas w h = Canvas w h Map.empty
-- | Look up the cell at @(x, y)@. Returns 'Nothing' for transparent cells.
canvasCellAt :: Int -> Int -> Canvas -> Maybe (Char, V.Attr)
canvasCellAt x y (Canvas _ _ cells) = Map.lookup (x, y) cells
-- | Place a source canvas onto a destination at offset @(dx, dy)@.
-- Transparent cells in the source do not overwrite the destination.
-- Cells outside the destination bounds are clipped.
placeCanvas :: Int -> Int -> Canvas -> Canvas -> Canvas
placeCanvas dx dy src dst =
dst {canvasCells = foldl' insert (canvasCells dst) visibleCells}
where
insert m (pos, cell) = Map.insert pos cell m
visibleCells =
[ ((dx + sx, dy + sy), cell)
| ((sx, sy), cell) <- Map.toList (canvasCells src)
, dx + sx >= 0
, dy + sy >= 0
, dx + sx < canvasWidth dst
, dy + sy < canvasHeight dst
]
-- | Shift a canvas by @(dx, dy)@. Dimensions are preserved; cells that
-- move outside bounds are lost, and newly empty cells are transparent.
translate :: Int -> Int -> Canvas -> Canvas
translate dx dy src =
src {canvasCells = Map.mapKeys (\(x, y) -> (x + dx, y + dy)) clipped}
where
clipped = Map.filterWithKey keep (canvasCells src)
keep (x, y) _ =
x + dx >= 0
&& y + dy >= 0
&& x + dx < canvasWidth src
&& y + dy < canvasHeight src
-- | Stack canvases in z-order (last = topmost). All canvases must have
-- the same dimensions; later canvases overlay earlier ones.
stack :: [Canvas] -> Canvas
stack [] = blankCanvas 0 0
stack (c : cs) = foldl' (\dst src -> placeCanvas 0 0 src dst) c cs
----------------------------------------------------------------------------
-- Conversions
----------------------------------------------------------------------------
-- | An attr with all fields 'KeepCurrent', used for transparent cells
-- when converting to an 'Image'.
transparentAttr :: V.Attr
transparentAttr =
V.Attr
{ V.attrStyle = V.KeepCurrent
, V.attrForeColor = V.KeepCurrent
, V.attrBackColor = V.KeepCurrent
, V.attrURL = V.KeepCurrent
}
-- | Convert a vty 'Image' to an opaque 'Canvas'. Every cell in the
-- image becomes a concrete @(Char, Attr)@ in the canvas; there are no
-- transparent cells.
imageToCanvas :: Image -> Canvas
imageToCanvas img =
Canvas
{ canvasWidth = V.imageWidth img
, canvasHeight = V.imageHeight img
, canvasCells = walkImage img 0 0 Map.empty
}
-- | Convert a 'Canvas' to a vty 'Image'. Transparent cells (not in the
-- 'Map') are rendered as spaces with 'transparentAttr' (KeepCurrent),
-- allowing underlying vty layers to show through.
canvasToImage :: Canvas -> Image
canvasToImage (Canvas w h cells)
| w <= 0 || h <= 0 = EmptyImage
| otherwise = V.vertCat (map renderRow [0 .. h - 1])
where
renderRow y = V.horizCat (map (uncurry V.text') (rowRuns y))
rowRuns y = mergeRuns [0 .. w - 1]
where
cellAt x = Map.findWithDefault (' ', transparentAttr) (x, y) cells
mergeRuns [] = []
mergeRuns (x : xs) = go (cellAt x) [fst (cellAt x)] xs
go (_, attr) chars (x : xs)
| attr == snd (cellAt x) = go (cellAt x) (fst (cellAt x) : chars) xs
| otherwise = (attr, T.pack (reverse chars)) : mergeRuns (x : xs)
go (_, attr) chars [] = [(attr, T.pack (reverse chars))]
----------------------------------------------------------------------------
-- Internal: Image tree walker
----------------------------------------------------------------------------
type CellMap = Map (Int, Int) (Char, V.Attr)
walkImage :: Image -> Int -> Int -> CellMap -> CellMap
walkImage img x y acc =
case img of
HorizText attr displayText _ _ ->
placeText attr (TL.unpack displayText) x y acc
HorizJoin partLeft partRight _ _ ->
let acc' = walkImage partLeft x y acc
in walkImage partRight (x + V.imageWidth partLeft) y acc'
VertJoin partTop partBottom _ _ ->
let acc' = walkImage partTop x y acc
in walkImage partBottom x (y + V.imageHeight partTop) acc'
BGFill outputWidth outputHeight ->
foldl'
(\m (dx, dy) -> Map.insertWith (\_ old -> old) (x + dx, y + dy) (' ', V.defAttr) m)
acc
[(dx, dy) | dx <- [0 .. outputWidth - 1], dy <- [0 .. outputHeight - 1]]
Crop croppedImage leftSkip topSkip outputWidth outputHeight ->
let innerCells = walkImage croppedImage 0 0 Map.empty
visible =
[ ((x + kx - leftSkip, y + ky - topSkip), cell)
| ((kx, ky), cell) <- Map.toList innerCells
, kx >= leftSkip
, kx < leftSkip + outputWidth
, ky >= topSkip
, ky < topSkip + outputHeight
]
in foldl' (\m (pos, cell) -> Map.insert pos cell m) acc visible
EmptyImage -> acc
placeText :: V.Attr -> String -> Int -> Int -> CellMap -> CellMap
placeText attr chars x y =
go chars 0
where
go [] _ acc = acc
go (c : cs) dx acc =
let w = wcwidth c
in if w <= 0
then go cs dx acc
else go cs (dx + w) (Map.insert (x + dx, y) (c, attr) acc)