packages feed

reflex-vty-1.3.0.0: test/Reflex/Vty/Widget/TextSpec.hs

{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE ScopedTypeVariables #-}

-- |
-- Description: Behavior-covering specs for the text rendering widgets,
-- driven through the production host.
module Reflex.Vty.Widget.TextSpec (spec) where

import Data.Text (Text)
import qualified Data.Text as T
import qualified Graphics.Vty as V
import Reflex
import Test.Hspec

import Data.Text.Zipper (TextAlignment (..), textWidth)
import Reflex.Vty.Host
import Reflex.Vty.Test.AppHarness
import Reflex.Vty.Test.Snapshot (Grid (..), RenderSpan (..), attrAt, imageText)
import Reflex.Vty.Widget
import Reflex.Vty.Widget.Layout
import Reflex.Vty.Widget.Text

-- | Commands driving the dynamic inputs of the text widgets.
data TextCmd
  = SetText Text
  | SetFill Char
  deriving (Eq, Show)

spec :: Spec
spec = describe "Reflex.Vty.Widget.Text" $ do
  describe "rendered through the production host" $ do
    it "renders text, display, fill, and rich text in their layout rows" $
      withApp defaultVtyAppConfig (20, 7) textApp $ \driver -> do
        fs <- driverFrames driver
        case reverse fs of
          (f : _) -> do
            frameObservation f `shouldBe` "alpha beta"
            gridRow (frameGrid f) 0 `shouldBe` fullRow 20 "alpha beta"
            gridRow (frameGrid f) 1 `shouldBe` fullRow 20 ""
            gridRow (frameGrid f) 5 `shouldBe` fullRow 20 "10"
            gridRow (frameGrid f) 6 `shouldBe` replicate 20 '#'
          [] -> expectationFailure "expected an initial frame"
        stopApp driver

    it "re-renders every row when the value changes via command" $
      withApp defaultVtyAppConfig (20, 7) textApp $ \driver -> do
        f <- stepCommand driver (SetText "wrap this longer line")
        frameObservation f `shouldBe` "wrap this longer line"
        gridRow (frameGrid f) 0 `shouldBe` fullRow 20 "wrap this longer"
        gridRow (frameGrid f) 1 `shouldBe` fullRow 20 "line"
        gridRow (frameGrid f) 5 `shouldBe` fullRow 20 "21"
        stopApp driver

    it "re-wraps to the new width on resize" $
      withApp defaultVtyAppConfig (20, 7) textApp $ \driver -> do
        _ <- stepCommand driver (SetText "wrap this longer line")
        f <- resizeApp driver (8, 7)
        frameObservation f `shouldBe` "wrap this longer line"
        gridRow (frameGrid f) 0 `shouldBe` fullRow 8 "wrap"
        gridRow (frameGrid f) 1 `shouldBe` fullRow 8 "this"
        gridRow (frameGrid f) 5 `shouldBe` fullRow 8 "21"
        gridRow (frameGrid f) 6 `shouldBe` replicate 8 '#'
        stopApp driver

    it "tracks a dynamic fill character" $
      withApp defaultVtyAppConfig (20, 7) textApp $ \driver -> do
        f <- stepCommand driver (SetFill 'x')
        gridRow (frameGrid f) 6 `shouldBe` replicate 20 'x'
        stopApp driver

    it "applies rich text attributes to the rendered cells" $
      withApp defaultVtyAppConfig (20, 7) textApp $ \driver -> do
        fs <- driverFrames driver
        case reverse fs of
          (f : _) -> do
            let mAttr = attrAt 0 4 (frameGrid f)
            mAttr `shouldSatisfy` maybe False (\a -> V.hasStyle (V.fixedStyle a) V.bold)
            attrAt 0 5 (frameGrid f) `shouldSatisfy` maybe False (\a -> V.fixedStyle a == V.defaultStyleMask)
          [] -> expectationFailure "expected an initial frame"
        stopApp driver

    it "renders center and right alignment as unpadded wrapped rows" $
      withApp defaultVtyAppConfig (20, 7) textApp $ \driver -> do
        -- wrapTextImage renders only the wrapped text of each line; the
        -- computed alignment offsets are not turned into padding.
        fs <- driverFrames driver
        case reverse fs of
          (f : _) -> do
            gridRow (frameGrid f) 2 `shouldBe` fullRow 20 "alpha beta"
            gridRow (frameGrid f) 3 `shouldBe` fullRow 20 "alpha beta"
            gridRow (frameGrid f) 2 `shouldBe` gridRow (frameGrid f) 0
          [] -> expectationFailure "expected an initial frame"
        stopApp driver

  describe "wrapTextImage" $ do
    it "wraps words to the given width" $
      imageText (wrapTextImage TextAlignment_Left 8 V.defAttr "aaa bb ccc")
        `shouldBe` ["aaa bb", "ccc   "]

    it "splits on explicit newlines before wrapping" $
      imageText (wrapTextImage TextAlignment_Left 20 V.defAttr "a\nbb cc dd")
        `shouldBe` ["a       ", "bb cc dd"]

    it "accounts for wide characters when measuring width" $ do
      -- "世界" fills the 4-column width exactly; "ab" wraps to the
      -- second row and is padded out to the same width.
      let img = wrapTextImage TextAlignment_Left 4 V.defAttr "世界ab"
      imageText img `shouldBe` ["世界", "ab  "]
      V.imageWidth img `shouldBe` 4
      V.imageHeight img `shouldBe` 2

    it "renders alignment variants as the same wrapped rows" $ do
      let rows alignment = imageText (wrapTextImage alignment 6 V.defAttr "one two three")
      rows TextAlignment_Center `shouldBe` rows TextAlignment_Left
      rows TextAlignment_Right `shouldBe` rows TextAlignment_Left
      rows TextAlignment_Left `shouldBe` ["one  ", "two  ", "three"]

    it "returns no rows for a non-positive width" $
      imageText (wrapTextImage TextAlignment_Left 0 V.defAttr "abc") `shouldBe` []

-- | A stack of text widgets in fixed layout rows:
--
-- * rows 0-1: 'text' (wraps to two rows)
-- * row 2: 'textWithAlignment' centered
-- * row 3: 'textWithAlignment' right-aligned
-- * row 4: 'richText' with a bold attribute
-- * row 5: 'display' of the text length
-- * row 6: 'fill' with a dynamic character
textApp :: TestApp TextCmd Text
textApp cmd = do
  let setTexts = fforMaybe cmd $ \c -> case c of
        SetText t -> Just t
        SetFill _ -> Nothing
      setFills = fforMaybe cmd $ \c -> case c of
        SetFill ch -> Just ch
        SetText _ -> Nothing
  t <- holdDyn "alpha beta" setTexts
  fillChar <- holdDyn '#' setFills
  _ <- initManager_ $
    col $ do
      grout (fixed (pure 2)) $ text (current t)
      grout (fixed (pure 1)) $ textWithAlignment TextAlignment_Center (current t)
      grout (fixed (pure 1)) $ textWithAlignment TextAlignment_Right (current t)
      grout (fixed (pure 1)) $
        richText
          RichTextConfig {_richTextConfig_attributes = pure (V.withStyle V.defAttr V.bold)}
          (current t)
      grout (fixed (pure 1)) $ display (T.length <$> current t)
      grout (fixed (pure 1)) $ fill (current fillChar)
  pure (t, never)

----------------------------------------------------------------------------
-- Helpers
----------------------------------------------------------------------------

-- | The text of the @y@-th rendered row of a grid.
gridRow :: Grid -> Int -> String
gridRow g i = case drop i (rowStrings g) of
  (r : _) -> r
  [] -> ""

rowStrings :: Grid -> [String]
rowStrings = map (concatMap (T.unpack . renderSpanText)) . gridRows

-- | Pad a row string with trailing blanks to a full terminal row of the
-- given display width.
fullRow :: Int -> String -> String
fullRow w s = s ++ replicate (w - textWidth (T.pack s)) ' '