packages feed

reflex-vty-1.3.0.0: test/Reflex/Vty/CanvasSpec.hs

{-# LANGUAGE OverloadedStrings #-}

module Reflex.Vty.CanvasSpec (spec) where

import Data.List.NonEmpty (NonEmpty ((:|)))
import qualified Data.Text as T
import Graphics.Text.Width (wcwidth)
import qualified Graphics.Vty as V
import Test.Hspec
import Test.QuickCheck (Gen, counterexample, forAll, suchThat)

import Reflex.Vty.Canvas
import Reflex.Vty.Style (HAlign (..), placeHorizontal)
import Reflex.Vty.Test.Generators (genDim, genLineText, runPlanProperty)
import Reflex.Vty.Test.Snapshot

red, blue, green :: V.Attr
red = V.withForeColor V.defAttr (V.ISOColor 1)
blue = V.withForeColor V.defAttr (V.ISOColor 4)
green = V.withForeColor V.defAttr (V.ISOColor 2)

cell :: Char -> CanvasCell
cell ch = CanvasCell 1 ((V.defAttr, T.singleton ch) :| [])

wideCell :: Char -> CanvasCell
wideCell ch = CanvasCell 2 ((V.defAttr, T.singleton ch) :| [])

-- | Render a canvas to row text through the independent oracle.
canvasText :: Canvas -> [String]
canvasText = imageText . canvasToImage

-- | Row text the oracle produces for a direct Vty picture. The
-- picture is built from layers; the first layer is topmost.
refLayers :: [V.Image] -> Int -> Int -> [String]
refLayers layers w h =
  map (concatMap (T.unpack . renderSpanText)) (gridRows (pictureToGrid (w, h) (V.picForLayers layers)))

spec :: Spec
spec = describe "Reflex.Vty.Canvas" $ do
  describe "blankCanvas" $ do
    it "is fully transparent" $ do
      let c = blankCanvas 3 3
      canvasCells c `shouldSatisfy` null
      canvasCellAt 1 1 c `shouldBe` Nothing

    it "converts to background fill that lets a lower image show through" $ do
      let top = canvasToImage (blankCanvas 2 1)
          pic = V.picForLayers [top, V.string blue "xy"]
      map (concatMap (T.unpack . renderSpanText)) (gridRows (pictureToGrid (2, 1) pic))
        `shouldBe` ["xy"]
      attrAt 0 0 (pictureToGrid (2, 1) pic) `shouldBe` Just (V.FixedAttr V.defaultStyleMask (Just (V.ISOColor 4)) Nothing Nothing)

  describe "canvasCellAt" $ do
    it "returns the covering glyph at a wide glyph's continuation column" $ do
      let c = imageToCanvas (V.string V.defAttr "a\30028b")
      canvasCellAt 1 0 c `shouldBe` Just (wideCell '\30028')
      canvasCellAt 2 0 c `shouldBe` Just (wideCell '\30028')
      canvasCellAt 0 0 c `shouldBe` Just (cell 'a')
      canvasCellAt 3 0 c `shouldBe` Just (cell 'b')

    it "returns Nothing out of bounds or on transparent cells" $ do
      let c = placeCanvas 0 0 (imageToCanvas (V.string red "b")) (blankCanvas 3 1)
      canvasCellAt (-1) 0 c `shouldBe` Nothing
      canvasCellAt 3 0 c `shouldBe` Nothing
      canvasCellAt 0 1 c `shouldBe` Nothing
      canvasCellAt 1 0 c `shouldBe` Nothing

  describe "imageToCanvas / canvasToImage roundtrips" $ do
    it "preserves ASCII text and attributes after conversion to a canvas" $ do
      let c = imageToCanvas (V.string red "ab")
      canvasCellAt 0 0 c `shouldBe` Just (CanvasCell 1 ((red, "a") :| []))
      canvasCellAt 1 0 c `shouldBe` Just (CanvasCell 1 ((red, "b") :| []))
      canvasText c `shouldBe` ["ab"]

    it "preserves mixed-width text without adding continuation cells" $ do
      let c = imageToCanvas (V.string V.defAttr "a\30028b")
      canvasCells c `shouldSatisfy` not . null
      canvasText c `shouldBe` ["a\30028b"]
      V.imageWidth (canvasToImage c) `shouldBe` 4

    it "preserves a combining mark inside its glyph" $ do
      let c = imageToCanvas (V.string V.defAttr "e\769")
      canvasCellAt 0 0 c `shouldBe` Just (CanvasCell 1 ((V.defAttr, "e\769") :| []))

    it "keeps different attrs across a combining sequence" $ do
      let img = V.horizCat [V.text' red "e", V.text' blue "\769", V.text' green "x"]
          c = imageToCanvas img
      canvasCellAt 0 0 c `shouldBe` Just (CanvasCell 1 ((red, "e") :| [(blue, "\769")]))
      canvasCellAt 1 0 c `shouldBe` Just (CanvasCell 1 ((green, "x") :| []))
      canvasText c `shouldBe` imageText img

    it "converts backgrounds opaquely, including trailing blanks" $ do
      let c = imageToCanvas (V.horizCat [V.string V.defAttr "ab", V.backgroundFill 3 1])
      canvasCellAt 2 0 c `shouldBe` Just (cell ' ')
      canvasCellAt 4 0 c `shouldBe` Just (cell ' ')
      canvasText c `shouldBe` ["ab   "]

    it "has no cells for a zero-area image" $ do
      imageToCanvas V.emptyImage `shouldBe` Canvas 0 0 (canvasCells (blankCanvas 0 0))
      V.imageWidth (canvasToImage (blankCanvas 0 5)) `shouldBe` 0
      V.imageHeight (canvasToImage (blankCanvas 5 0)) `shouldBe` 0

  describe "placeCanvas" $ do
    it "overlays opaque glyphs and leaves transparent holes" $ do
      let src = imageToCanvas (V.string red "b")
          dst = blankCanvas 3 1
          c = placeCanvas 0 0 src dst
      canvasCellAt 0 0 c `shouldBe` Just (CanvasCell 1 ((red, "b") :| []))
      canvasCellAt 2 0 c `shouldBe` Nothing
      -- The holes must let a lower image show through, like Vty layers.
      canvasText c `shouldBe` ["b  "]
      let layered = V.picForLayers [canvasToImage c, V.string blue "xyz"]
      map (concatMap (T.unpack . renderSpanText)) (gridRows (pictureToGrid (3, 1) layered))
        `shouldBe` ["byz"]

    it "clips source glyphs moving past the right edge, removing the combining suffix" $ do
      let src = imageToCanvas (V.string V.defAttr "\30028\769")
          c = placeCanvas 1 0 src (blankCanvas 2 1)
      canvasCellAt 1 0 c `shouldBe` Just (CanvasCell 1 ((V.defAttr, "\8230") :| []))
      canvasText c `shouldBe` [" \8230"]

    it "clips source glyphs entering from the left, retaining the combining suffix" $ do
      let src = imageToCanvas (V.string V.defAttr "\30028\769")
          c = placeCanvas (-1) 0 src (blankCanvas 2 1)
      canvasCellAt 0 0 c `shouldBe` Just (CanvasCell 1 ((V.defAttr, "\8230\769") :| []))
      canvasText c `shouldBe` ["\8230\769 "]

    it "drops width-one glyphs wholly outside" $ do
      let c = translate (-1) 0 (imageToCanvas (V.string V.defAttr "ab"))
      canvasText c `shouldBe` ["b "]

    it "overwriting the second column of a wide glyph matches Vty's clipping" $ do
      let dst = imageToCanvas (V.string V.defAttr "a\30028b")
          c = placeCanvas 2 0 (imageToCanvas (V.string V.defAttr "X")) dst
      canvasText c `shouldBe` refLayers [V.translate 2 (0 :: Int) (V.string V.defAttr "X"), V.string V.defAttr "a\30028b"] 4 1

    it "overwriting the first column of a wide glyph matches Vty's clipping" $ do
      let dst = imageToCanvas (V.string V.defAttr "a\30028b")
          c = placeCanvas 1 0 (imageToCanvas (V.string V.defAttr "X")) dst
      canvasText c `shouldBe` refLayers [V.translate 1 (0 :: Int) (V.string V.defAttr "X"), V.string V.defAttr "a\30028b"] 4 1

    it "removes the combining suffix from the retained half of an overwritten wide glyph" $ do
      let dst = imageToCanvas (V.string V.defAttr "a\30028\769b")
          c = placeCanvas 2 0 (imageToCanvas (V.string V.defAttr "X")) dst
      canvasText c
        `shouldBe` refLayers [V.translate 2 (0 :: Int) (V.string V.defAttr "X"), V.string V.defAttr "a\30028\769b"] 4 1

    it "moves wholly outside the bounds to nothing" $ do
      let c = translate 10 10 (imageToCanvas (V.string V.defAttr "ab"))
      canvasCells c `shouldSatisfy` null
      canvasText c `shouldBe` ["  "]

    it "composes three overlapping layers like Vty" $ do
      let bottom = imageToCanvas (V.string blue "abcd")
          mid = placeCanvas 1 0 (imageToCanvas (V.string green "XY")) bottom
          top = placeCanvas 3 0 (imageToCanvas (V.string V.defAttr "Z")) mid
      canvasText top
        `shouldBe` refLayers
          [ V.translate 3 (0 :: Int) (V.string V.defAttr "Z")
          , V.translate 1 (0 :: Int) (V.string green "XY")
          , V.string blue "abcd"
          ]
          4
          1

    it "onto a zero-size destination yields an empty canvas" $ do
      placeCanvas 0 0 (imageToCanvas (V.string V.defAttr "a")) (blankCanvas 0 0)
        `shouldBe` Canvas 0 0 (canvasCells (blankCanvas 0 0))

  describe "translate" $ do
    it "preserves dimensions" $ do
      let c = translate 1 1 (imageToCanvas (V.string V.defAttr "ab"))
      canvasWidth c `shouldBe` 2
      canvasHeight c `shouldBe` 1
      canvasCellAt 1 1 c `shouldBe` Nothing -- moved off the single row
    it "shifts glyphs within bounds" $ do
      let c = translate 1 0 (imageToCanvas (V.string V.defAttr "ab"))
      canvasCellAt 1 0 c `shouldBe` Just (cell 'a')
      canvasCellAt 2 0 c `shouldBe` Nothing -- 'b' moved past the 2-wide bounds
      canvasCellAt 0 0 c `shouldBe` Nothing

    it "clips a wide glyph at the right edge" $ do
      let c = translate 1 0 (imageToCanvas (V.string V.defAttr "a\30028"))
      canvasText c `shouldBe` [" a\8230"]

    it "clips a wide glyph at the left edge" $ do
      let c = translate (-1) 0 (imageToCanvas (V.string V.defAttr "a\30028"))
      canvasText c `shouldBe` ["\30028 "]

    it "drops content shifted past the bottom" $ do
      let c = translate 0 1 (imageToCanvas (V.string V.defAttr "a"))
      canvasCells c `shouldSatisfy` null

  describe "stack" $ do
    it "stacks nothing into an empty canvas" $ do
      stack [] `shouldBe` blankCanvas 0 0

    it "uses the first canvas's dimensions" $ do
      let c = stack [blankCanvas 2 1, imageToCanvas (V.string V.defAttr "abc")]
      canvasWidth c `shouldBe` 2
      canvasText c `shouldBe` ["ab"]

  describe "layer/translate/clip properties against direct Vty pictures" $ do
    it "opaque roundtrips preserve text, widths, and attributes" $
      runPlanProperty $
        forAll ((,) <$> genDim <*> positiveWidthText) $ \(w, txt) ->
          let img = V.string red (T.unpack txt)
              padded = placeHorizontal HAlignLeft w img
              c = imageToCanvas padded
              rendered = canvasToImage c
              gridC = imageToGrid rendered
              gridRef = imageToGrid padded
          in counterexample (show txt ++ " at width " ++ show w) $
               gridEquals gridC gridRef == Nothing

    it "translations match the identically cropped Vty picture" $
      runPlanProperty $
        forAll ((,,) <$> genDim <*> genDim <*> positiveWidthText) $ \(dx, dy, txt) ->
          let img = V.string red (T.unpack txt)
              w = V.imageWidth img
              h = V.imageHeight img
              c = translate dx dy (imageToCanvas img)
              gridC = imageToGrid (canvasToImage c)
              gridRef = pictureToGrid (w, h) (V.picForImage (V.translate dx dy img))
          in counterexample ("translate " ++ show (dx, dy) ++ " of " ++ show txt) $
               gridEquals gridC gridRef == Nothing

-- | Non-empty text carrying at least one positive-width base glyph.
positiveWidthText :: Gen T.Text
positiveWidthText =
  suchThat genLineText (\t -> not (T.null t) && T.any (\ch -> wcwidth ch > 0) t)