reflex-vty-1.3.0.0: test/Reflex/Vty/WidgetSpec.hs
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE ScopedTypeVariables #-}
module Reflex.Vty.WidgetSpec (spec) where
import Data.List (isInfixOf, isPrefixOf)
import qualified Data.Set as Set
import qualified Data.Text as T
import qualified Graphics.Vty as V
import Reflex
import Test.Hspec
import Reflex.Vty.Host
import Reflex.Vty.Test.AppHarness
import Reflex.Vty.Test.Snapshot (Grid (..), RenderSpan (..), imageText)
import Reflex.Vty.Widget
-- | Visible text of each grid row (rows are padded to the grid width).
gridText :: Grid -> [String]
gridText g = map (T.unpack . T.concat . map renderSpanText) (gridRows g)
spec :: Spec
spec = describe "Reflex.Vty.Widget" $ do
describe "key, keys, keyCombo, keyCombos" $ do
it "match exact keys and modifier sets and reject mismatches" $
withApp defaultVtyAppConfig (20, 6) keyApp $ \driver -> do
f1 <- stepInput driver (V.EvKey (V.KChar 'a') [])
frameObservation f1 `shouldBe` "key:a"
map (take 5) (take 1 (gridText (frameGrid f1))) `shouldBe` ["key:a"]
-- 'key' is modifier-less: 'a' with Shift must not produce an event.
f2 <- stepInput driver (V.EvKey (V.KChar 'a') [V.MShift])
frameObservation f2 `shouldBe` "key:a"
map (take 5) (take 1 (gridText (frameGrid f2))) `shouldBe` ["key:a"]
-- 'keys' matches each listed modifier-less key.
f3 <- stepInput driver (V.EvKey (V.KChar 'x') [])
frameObservation f3 `shouldBe` "keys:(KChar 'x',[])"
f4 <- stepInput driver (V.EvKey (V.KChar 'y') [])
frameObservation f4 `shouldBe` "keys:(KChar 'y',[])"
f5 <- stepInput driver (V.EvKey (V.KChar 'y') [V.MMeta])
frameObservation f5 `shouldBe` "keys:(KChar 'y',[])"
-- 'keyCombo' requires the exact modifier set.
f6 <- stepInput driver (V.EvKey (V.KChar 'c') [V.MShift])
frameObservation f6 `shouldBe` "combo:shift-c"
f7 <- stepInput driver (V.EvKey (V.KChar 'c') [])
frameObservation f7 `shouldBe` "combo:shift-c"
f8 <- stepInput driver (V.EvKey (V.KChar 'c') [V.MCtrl])
frameObservation f8 `shouldBe` "combo:shift-c"
-- 'keyCombos' matches any listed combination.
f9 <- stepInput driver (V.EvKey (V.KChar 'd') [V.MCtrl])
frameObservation f9 `shouldBe` "combos:(KChar 'd',[MCtrl])"
f10 <- stepInput driver (V.EvKey V.KEnter [])
frameObservation f10 `shouldBe` "combos:(KEnter,[])"
f11 <- stepInput driver (V.EvKey V.KEnter [V.MShift])
frameObservation f11 `shouldBe` "combos:(KEnter,[])"
f12 <- stepInput driver (V.EvKey (V.KChar 'd') [])
frameObservation f12 `shouldBe` "combos:(KEnter,[])"
stopApp driver
describe "filterKeys" $ do
it "drops keys the predicate rejects and passes everything else through unchanged" $
withApp defaultVtyAppConfig (20, 6) filterApp $ \driver -> do
f1 <- stepInput driver (V.EvKey (V.KChar 'q') [])
frameObservation f1 `shouldBe` ("none" :: T.Text, 0 :: Int)
f2 <- stepInput driver (V.EvKey (V.KChar 'q') [V.MCtrl])
frameObservation f2 `shouldBe` ("none", 0)
-- A non-rejected key passes (with its modifiers).
f3 <- stepInput driver (V.EvKey (V.KChar 'z') [V.MShift])
frameObservation f3 `shouldBe` ("EvKey (KChar 'z') [MShift]", 1)
-- Non-key events pass through unfiltered and unchanged. The
-- root consumes EvResize itself. Paste is the non-key event a
-- widget actually observes.
f4 <- stepInput driver (V.EvPaste "hi")
frameObservation f4 `shouldBe` ("EvPaste \"hi\"", 2)
stopApp driver
describe "gainedFocus and lostFocus" $ do
it "extract terminal focus events from the input stream" $
withApp defaultVtyAppConfig (20, 6) focusApp $ \driver -> do
f1 <- stepInput driver V.EvGainedFocus
frameObservation f1 `shouldBe` ("gained" :: T.Text)
map (take 6) (take 1 (gridText (frameGrid f1))) `shouldBe` ["gained"]
f2 <- stepInput driver V.EvLostFocus
frameObservation f2 `shouldBe` "lost"
map (take 4) (take 1 (gridText (frameGrid f2))) `shouldBe` ["lost"]
-- Keys are not focus events.
f3 <- stepInput driver (V.EvKey (V.KChar 'k') [])
frameObservation f3 `shouldBe` "lost"
stopApp driver
describe "pane input semantics" $ do
it "delivers keys only to the focused pane and renders each pane in its region" $
withApp defaultVtyAppConfig (20, 6) panesApp $ \driver -> do
fs0 <- driverFrames driver
case reverse fs0 of
(f0 : _) -> do
let firstRows = take 1 (gridText (frameGrid f0))
map (take 1) firstRows `shouldBe` ["L"]
map (take 1 . drop 10) firstRows `shouldBe` ["R"]
frameObservation f0
`shouldBe` (("none", Nothing), ("none", Nothing))
[] -> expectationFailure "expected an initial frame"
f1 <- stepInput driver (V.EvKey (V.KChar 'a') [])
frameObservation f1 `shouldBe` (("(KChar 'a',[])", Nothing), ("none", Nothing))
stopApp driver
it "includes the last column of a pane and hands the next column to its neighbor" $
withApp defaultVtyAppConfig (20, 6) panesApp $ \driver -> do
-- The left pane's rightmost column (x = 9) is inside the left
-- pane and outside the right one.
f1 <- stepInput driver (V.EvMouseDown 9 2 V.BLeft [])
frameObservation f1 `shouldBe` (("none", Just (9, 2)), ("none", Nothing))
stopApp driver
it "delivers a click on the neighbor pane's left edge to that neighbor" $
withApp defaultVtyAppConfig (20, 6) panesApp $ \driver -> do
-- One column further (x = 10): exactly the right pane's origin,
-- reported pane-locally as (0, 2) and invisible to the left pane.
f1 <- stepInput driver (V.EvMouseDown 10 2 V.BLeft [])
frameObservation f1 `shouldBe` (("none", Nothing), ("none", Just (0, 2)))
stopApp driver
it "drops clicks one past the region edge" $
withApp defaultVtyAppConfig (20, 6) panesApp $ \driver -> do
-- One past the right pane's right edge (x = 20 of a 20-wide root).
f1 <- stepInput driver (V.EvMouseDown 20 2 V.BLeft [])
frameObservation f1 `shouldBe` (("none", Nothing), ("none", Nothing))
stopApp driver
it "reports drags that begin inside the pane and continue outside it" $
withApp defaultVtyAppConfig (20, 6) panesApp $ \driver -> do
f1 <- stepInput driver (V.EvMouseDown 5 1 V.BLeft [])
frameObservation f1 `shouldBe` (("none", Just (5, 1)), ("none", Nothing))
-- Drag continues outside the left pane, into the right pane's area:
-- still delivered to the tracking (left) pane, never to the right.
f2 <- stepInput driver (V.EvMouseDown 15 1 V.BLeft [])
frameObservation f2 `shouldBe` (("none", Just (15, 1)), ("none", Nothing))
stopApp driver
it "ignores drags that begin outside the pane even when they enter it" $
withApp defaultVtyAppConfig (20, 6) panesApp $ \driver -> do
f1 <- stepInput driver (V.EvMouseDown 15 1 V.BLeft [])
frameObservation f1 `shouldBe` (("none", Nothing), ("none", Just (5, 1)))
-- Now the drag moves into the left pane. The left pane was not
-- tracking, so it receives no input.
f2 <- stepInput driver (V.EvMouseDown 5 1 V.BLeft [])
frameObservation f2 `shouldBe` (("none", Nothing), ("none", Just (-5, 1)))
stopApp driver
it "drops buttonless mouse-up unless a drag is being tracked" $
withApp defaultVtyAppConfig (20, 6) panesApp $ \driver -> do
f1 <- stepInput driver (V.EvMouseUp 5 1 Nothing)
frameObservation f1 `shouldBe` (("none", Nothing), ("none", Nothing))
-- After a tracked button-down, a buttonless up is delivered.
_ <- stepInput driver (V.EvMouseDown 5 1 V.BLeft [])
f3 <- stepInput driver (V.EvMouseUp 6 2 Nothing)
frameObservation f3 `shouldBe` (("none", Just (6, 2)), ("none", Nothing))
stopApp driver
it "drops mouse-up for the wrong button and stops tracking afterwards" $
withApp defaultVtyAppConfig (20, 6) panesApp $ \driver -> do
_ <- stepInput driver (V.EvMouseDown 5 1 V.BLeft [])
-- Up with the right button: not the tracked one, not delivered.
f2 <- stepInput driver (V.EvMouseUp 7 3 (Just V.BRight))
frameObservation f2 `shouldBe` (("none", Just (5, 1)), ("none", Nothing))
-- Tracking has ended: even the correct button no longer produces
-- an event.
f3 <- stepInput driver (V.EvMouseUp 8 4 (Just V.BLeft))
frameObservation f3 `shouldBe` (("none", Just (5, 1)), ("none", Nothing))
stopApp driver
it "keeps delivering keys through zero- and negative-width panes but renders nothing" $
withApp defaultVtyAppConfig (20, 6) zeroPaneApp $ \driver -> do
fs0 <- driverFrames driver
case reverse fs0 of
(f0 : _) -> do
mapM_ (\row -> row `shouldBe` replicate 20 ' ') (gridText (frameGrid f0))
frameObservation f0
`shouldBe` (("none", Nothing), ("none", Nothing))
[] -> expectationFailure "expected an initial frame"
-- Key delivery depends on focus, not on region size.
f1 <- stepInput driver (V.EvKey (V.KChar 'k') [])
frameObservation f1
`shouldBe` (("(KChar 'k',[])", Nothing), ("(KChar 'k',[])", Nothing))
-- A click exactly on the zero-width pane's left edge is outside it.
f2 <- stepInput driver (V.EvMouseDown 3 1 V.BLeft [])
frameObservation f2
`shouldBe` (("(KChar 'k',[])", Nothing), ("(KChar 'k',[])", Nothing))
stopApp driver
describe "pane coordinate translation" $ do
it "agrees between translated mouse input and absolute cursor/image output" $
withApp defaultVtyAppConfig (20, 6) cursorPaneApp $ \driver -> do
fs0 <- driverFrames driver
case reverse fs0 of
(f0 : _) -> do
-- The child image and cursor appear at the pane origin (5, 2).
frameCursor f0 `shouldBe` V.AbsoluteCursor 5 2
gridText (frameGrid f0) !! 2 !! 5 `shouldBe` 'X'
[] -> expectationFailure "expected an initial frame"
-- Clicking exactly on the rendered 'X' reports the pane-local
-- origin, matching where the image and cursor were drawn.
f1 <- stepInput driver (V.EvMouseDown 5 2 V.BLeft [])
frameObservation f1 `shouldBe` (Just (0, 0) :: Maybe (Int, Int))
f2 <- stepInput driver (V.EvMouseDown 7 3 V.BLeft [])
frameObservation f2 `shouldBe` Just (2, 1)
stopApp driver
it "drops clicks entirely outside the pane when nothing is being tracked" $
withApp defaultVtyAppConfig (20, 6) cursorPaneApp $ \driver -> do
-- One column left of the pane, before any drag begins.
f1 <- stepInput driver (V.EvMouseDown 4 2 V.BLeft [])
frameObservation f1 `shouldBe` (Nothing :: Maybe (Int, Int))
stopApp driver
describe "withinImage" $ do
it "crops to the region size" $
imageText (withinImage (Region 0 0 3 2) (V.string V.defAttr "hello"))
`shouldBe` ["hel"]
it "clamps zero-size regions to an empty image" $
V.imageWidth (withinImage (Region 0 0 0 5) (V.string V.defAttr "hello"))
`shouldBe` 0
it "treats negative sizes as zero" $
V.imageWidth (withinImage (Region 0 0 (-1) 5) (V.string V.defAttr "hello"))
`shouldBe` 0
it "clips rendered output through the host, including zero and negative widths" $
withApp defaultVtyAppConfig (20, 6) regionApp $ \driver -> do
fs0 <- driverFrames driver
case reverse fs0 of
(f0 : _) -> take 1 (gridText (frameGrid f0)) `shouldBe` ["abcdefghij "]
[] -> expectationFailure "expected an initial frame"
f1 <- stepCommand driver 4
take 1 (gridText (frameGrid f1)) `shouldBe` ["abcd "]
f2 <- stepCommand driver 0
take 1 (gridText (frameGrid f2)) `shouldBe` [replicate 20 ' ']
-- Negative widths are clamped, not crashes.
f3 <- stepCommand driver (-3)
take 1 (gridText (frameGrid f3)) `shouldBe` [replicate 20 ' ']
frameObservation f3 `shouldBe` ("-3" :: T.Text)
stopApp driver
describe "imagesInRegion and mapImages" $ do
it "crop and translate child images into the given region" $
withApp defaultVtyAppConfig (20, 6) clipApp $ \driver -> do
fs0 <- driverFrames driver
case reverse fs0 of
(f0 : _) -> do
let rows = gridText (frameGrid f0)
rows !! 0 `shouldBe` replicate 20 ' '
take 8 (rows !! 1) `shouldBe` " abcdef"
[] -> expectationFailure "expected an initial frame"
f1 <- stepCommand driver 3
take 5 (gridText (frameGrid f1) !! 1) `shouldBe` " abc"
f2 <- stepCommand driver 0
gridText (frameGrid f2) !! 1 `shouldBe` replicate 20 ' '
stopApp driver
describe "localRegion and localLayoutRegion" $ do
it "splits layout and viewport regions as requested" $
withApp defaultVtyAppConfig (20, 6) layoutApp $ \driver -> do
fs0 <- driverFrames driver
case reverse fs0 of
(f0 : _) ->
frameObservation f0
`shouldBe` ((Region 0 0 20 100, Region 0 0 20 6), Region 1 1 18 4)
[] -> expectationFailure "expected an initial frame"
stopApp driver
describe "captureImages" $ do
it "hides child images until re-emitted and keeps cursor and screen-mode requests" $
withApp defaultVtyAppConfig (20, 6) captureApp $ \driver -> do
fs0 <- driverFrames driver
case reverse fs0 of
(f0 : _) -> do
frameObservation f0 `shouldBe` ("hidden" :: T.Text)
frameCursor f0 `shouldBe` V.AbsoluteCursor 0 0
[] -> expectationFailure "expected an initial frame"
-- The alternate-screen request made inside the captured child
-- must still reach the terminal.
log0 <- driverOutputLog driver
log0 `shouldSatisfy` any (isInfixOf "?1049h")
-- Turning the marker on changes only the captured behavior: the
-- marker must stay invisible, but the cursor must persist.
f1 <- stepCommand driver 1
frameObservation f1 `shouldBe` "shown"
frameCursor f1 `shouldBe` V.AbsoluteCursor 0 0
gridText (frameGrid f1) !! 0 `shouldBe` replicate 20 ' '
-- Re-emitting draws exactly one correctly placed marker.
f2 <- stepCommand driver 2
frameObservation f2 `shouldBe` "reemitted"
let rows = gridText (frameGrid f2)
take 1 rows `shouldBe` ["MARK "]
concat rows `shouldSatisfy` (\s -> length (filter (== 'M') s) == 1)
stopApp driver
------------------------------------------------------------------------------
-- Applications under test
------------------------------------------------------------------------------
-- | Records which key matcher fired last and renders the label.
keyApp :: TestApp () T.Text
keyApp _cmd = do
aK <- key (V.KChar 'a')
xyK <- keys [V.KChar 'x', V.KChar 'y']
cCombo <- keyCombo (V.KChar 'c', [V.MShift])
dCombos <-
keyCombos (Set.fromList [(V.KChar 'd', [V.MCtrl]), (V.KEnter, [])])
let hit =
leftmost
[ "key:a" <$ aK
, (\k -> "keys:" <> T.pack (show k)) <$> xyK
, "combo:shift-c" <$ cCombo
, (\kc -> "combos:" <> T.pack (show kc)) <$> dCombos
]
lastHit <- holdDyn "none" hit
tellImages (fmap (\s -> [V.string V.defAttr (T.unpack s)]) (current lastHit))
pure (lastHit, never)
-- | A child under 'filterKeys' that records every event it still receives.
filterApp :: TestApp () (T.Text, Int)
filterApp _cmd = do
seen <- filterKeys (\(k, _ms) -> k /= V.KChar 'q') $ do
inp <- input
lastE <- holdDyn "none" (T.pack . show <$> inp)
nSeen <- foldDyn (\_ c -> c + 1) (0 :: Int) inp
pure ((,) <$> lastE <*> nSeen)
pure (seen, never)
-- | Records terminal focus changes and renders them.
focusApp :: TestApp () T.Text
focusApp _cmd = do
g <- gainedFocus
l <- lostFocus
d <- holdDyn "none" (leftmost ["gained" <$ g, "lost" <$ l])
tellImages (fmap (\s -> [V.string V.defAttr (T.unpack s)]) (current d))
pure (d, never)
-- | One pane child: last key, last mouse position (pane-local), and a
-- one-character marker at the pane origin.
paneChild
:: (Reflex t, MonadHold t m, HasInput t m, HasImageWriter t m)
=> Char
-> m (Dynamic t (T.Text, Maybe (Int, Int)))
paneChild c = do
inp <- input
k <- holdDyn "none" $ fforMaybe inp $ \case
V.EvKey key' mods -> Just (T.pack (show (key', mods)))
_ -> Nothing
m <- holdDyn Nothing $ fforMaybe inp $ \case
V.EvMouseDown x y _ _ -> Just (Just (x, y))
V.EvMouseUp x y _ -> Just (Just (x, y))
_ -> Nothing
tellImages (pure [V.string V.defAttr [c]])
pure ((,) <$> k <*> m)
-- | A focused left pane and an unfocused right pane, side by side.
panesApp :: TestApp () ((T.Text, Maybe (Int, Int)), (T.Text, Maybe (Int, Int)))
panesApp _cmd = do
l <- pane (pure (Region 0 0 10 6)) (pure True) (paneChild 'L')
r <- pane (pure (Region 10 0 10 6)) (pure False) (paneChild 'R')
pure ((,) <$> l <*> r, never)
-- | Two focused but degenerate (zero- and negative-width) panes.
zeroPaneApp :: TestApp () ((T.Text, Maybe (Int, Int)), (T.Text, Maybe (Int, Int)))
zeroPaneApp _cmd = do
z <- pane (pure (Region 3 1 0 2)) (pure True) (paneChild 'Z')
n <- pane (pure (Region 3 1 (-2) 2)) (pure True) (paneChild 'N')
pure ((,) <$> z <*> n, never)
-- | A pane whose child draws an 'X' at the pane origin and requests a
-- visible cursor there, reporting pane-local click coordinates.
cursorPaneApp :: TestApp () (Maybe (Int, Int))
cursorPaneApp _cmd = do
m <- pane (pure (Region 5 2 10 3)) (pure True) $ do
inp <- input
clicks <- holdDyn Nothing $ fforMaybe inp $ \case
V.EvMouseDown x y _ _ -> Just (Just (x, y))
_ -> Nothing
tellImages (pure [V.string V.defAttr "X"])
setCursor (pure (CursorState CursorVisible CursorStyleDefault (0, 0)))
pure clicks
pure (m, never)
-- | Renders text clipped by 'withinImage' to a command-chosen width.
regionApp :: TestApp Int T.Text
regionApp cmd = do
w <- holdDyn 20 cmd
tellImages
(fmap (\wi -> [withinImage (Region 0 0 wi 1) (V.string V.defAttr "abcdefghij")]) (current w))
pure (T.pack . show <$> w, never)
-- | Child images cropped and translated by 'imagesInRegion' via
-- 'mapImages', with the region width driven by commands.
clipApp :: TestApp Int T.Text
clipApp cmd = do
w <- holdDyn 6 cmd
let reg = (\wi -> Region 2 1 wi 2) <$> w
mapImages (imagesInRegion (current reg)) $
tellImages (pure [V.string V.defAttr "abcdefghij"])
pure (T.pack . show <$> w, never)
-- | Exercises 'localLayoutRegion' (layout inflated, viewport untouched)
-- and 'localRegion' (both shrink) and reports what the child observed.
layoutApp :: TestApp () ((Region, Region), Region)
layoutApp _cmd = do
pair <- localLayoutRegion (fmap (\r -> r {_region_height = 100})) $ do
a <- askRegion
b <- askViewport
pure ((,) <$> a <*> b)
shrunk <- localRegion (fmap shrinkBy2) askRegion
pure ((,) <$> pair <*> shrunk, never)
where
shrinkBy2 (Region l t w h) = Region (l + 1) (t + 1) (max 0 (w - 2)) (max 0 (h - 2))
-- | The child's images are captured: a marker drawn by the child only
-- appears once the parent re-emits the captured behavior. The child's
-- cursor and alternate-screen requests bypass the capture.
captureApp :: TestApp Int T.Text
captureApp cmd = do
((), captured) <- captureImages $ do
markOn <- holdDyn False ((== (1 :: Int)) <$> cmd)
tellImages (fmap (\b -> [V.string V.defAttr "MARK" | b]) (current markOn))
setCursor (pure (CursorState CursorVisible CursorStyleDefault (0, 0)))
enterAlternateScreen
let again = ffilter (== (2 :: Int)) cmd
reemit <- holdDyn [] (tag captured again)
tellImages (current reemit)
obs <-
holdDyn
"hidden"
(leftmost ["shown" <$ ffilter (== (1 :: Int)) cmd, "reemitted" <$ again])
pure (obs, never)