reflex-vty-1.3.0.0: test/Reflex/Vty/CompositionSpec.hs
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE ScopedTypeVariables #-}
-- | Specs for composition-level behavior. Real widget trees run through
-- the production host ('Reflex.Vty.Test.AppHarness.withApp'). Each test
-- asserts the observable widget state and the rendered output ('Grid').
module Reflex.Vty.CompositionSpec (spec) where
import Control.Monad (forM_, void)
import Control.Monad.Fix (MonadFix)
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import qualified Graphics.Vty as V
import Reflex
import Test.Hspec
import Control.Monad.NodeId (MonadNodeId)
import Data.Text.Zipper (fromText, value)
import Reflex.Vty.Host
import Reflex.Vty.Style (withForeground)
import Reflex.Vty.Test.AppHarness
import Reflex.Vty.Test.Snapshot (Grid (..), RenderSpan (..), attrAt)
import Reflex.Vty.Theme
import Reflex.Vty.Widget
import Reflex.Vty.Widget.Box
import Reflex.Vty.Widget.Input.Mouse (MouseDown (..), mouseDown)
import Reflex.Vty.Widget.Input.Text
import Reflex.Vty.Widget.Layout
import Reflex.Vty.Widget.Scroll
import Reflex.Vty.Widget.Split
import Reflex.Vty.Widget.Text (fill)
spec :: Spec
spec = describe "Reflex.Vty composition" $ do
nestedPaneSpec
focusIsolationSpec
simultaneousModificationSpec
nestedAllocationSpec
scrollLayoutInputSpec
splitDragSpec
captureReplacementSpec
contextIsolationSpec
smallBoundarySpec
----------------------------------------------------------------------------
-- Grid helpers
----------------------------------------------------------------------------
gridRowText :: Int -> Grid -> String
gridRowText y g
| y < 0 || y >= gridHeight g = ""
| otherwise = T.unpack . T.concat . map renderSpanText $ gridRows g !! y
-- | 'gridRowText' with the render padding's trailing spaces stripped.
gridRowTrimmed :: Int -> Grid -> String
gridRowTrimmed y g = reverse (dropWhile (== ' ') (reverse (gridRowText y g)))
-- | The subrectangle of the grid as strings, @x0 y0 w h@.
gridSubLines :: Int -> Int -> Int -> Int -> Grid -> [String]
gridSubLines x0 y0 w h g =
[take w (drop x0 (gridRowText y g)) | y <- [y0 .. y0 + h - 1]]
countChar :: Char -> Grid -> Int
countChar c g = length (filter (== c) (concat [gridRowText y g | y <- [0 .. gridHeight g - 1]]))
----------------------------------------------------------------------------
-- 1. Nested pane equivalence
----------------------------------------------------------------------------
innerField
:: ( Reflex t
, MonadHold t m
, MonadFix m
, HasInput t m
, HasFocusReader t m
, HasTheme t m
, HasDisplayRegion t m
, HasImageWriter t m
)
=> m (Dynamic t (Text, (Int, Int)))
innerField = do
md <- mouseDown V.BLeft
clicks <- holdDyn (99, 99) (_mouseDown_coordinates <$> md)
ti <- textInput def {_textInputConfig_initialValue = fromText "abcdef"}
pure (zipDyn (_textInput_value ti) clicks)
-- | A field inside pane (3,2,12,5) which itself contains pane (1,1,8,3):
-- the field's global origin is (4,3).
nestedPaneApp :: TestApp () (Text, (Int, Int))
nestedPaneApp _ = do
d <-
pane (pure (Region 3 2 12 5)) (pure True) $
pane (pure (Region 1 1 8 3)) (pure True) innerField
pure (d, never)
-- | The same field placed directly at the summed origin.
flatPaneApp :: TestApp () (Text, (Int, Int))
flatPaneApp _ = do
d <- pane (pure (Region 4 3 8 3)) (pure True) innerField
pure (d, never)
nestedPaneSpec :: Spec
nestedPaneSpec = describe "nested pane equivalence" $ do
it "translates clicks through both panes like a single summed origin" $ do
nested <- withApp defaultVtyAppConfig (20, 8) nestedPaneApp $ \driver -> do
f1 <- stepInput driver (V.EvMouseDown 5 4 V.BLeft []) -- global (5,4) -> local (1,1)
frameObservation f1 `shouldBe` ("abcdef", (1, 1))
_ <- stepInput driver (V.EvMouseDown 6 3 V.BLeft []) -- -> local (2,0): cursor after "ab"
f3 <- stepInput driver (V.EvKey (V.KChar 'X') [])
-- Clicking at column 2 puts the zipper cursor before 'c'. The
-- keystroke inserts there; it does not overwrite.
frameObservation f3 `shouldBe` ("abXcdef", (2, 0))
sub <- gridSubLines 4 3 8 3 . frameGrid <$> lastFrame driver
stopApp driver
pure (frameObservation f3, sub)
flat <- withApp defaultVtyAppConfig (20, 8) flatPaneApp $ \driver -> do
_ <- stepInput driver (V.EvMouseDown 5 4 V.BLeft [])
_ <- stepInput driver (V.EvMouseDown 6 3 V.BLeft [])
f3 <- stepInput driver (V.EvKey (V.KChar 'X') [])
sub <- gridSubLines 4 3 8 3 . frameGrid <$> lastFrame driver
stopApp driver
pure (frameObservation f3, sub)
nested `shouldBe` flat
-- The shared content subrectangle really contains the typed text
-- (compare content, not unrelated padding).
case snd nested of
(top : _) -> take 6 top `shouldBe` "abXcde"
[] -> expectationFailure "expected at least one content row"
lastFrame :: AppDriver c o -> IO (Frame o)
lastFrame driver = do
fs <- driverFrames driver
case reverse fs of
(f : _) -> pure f
[] -> fail "expected at least one frame"
----------------------------------------------------------------------------
-- 2. Focus isolation
----------------------------------------------------------------------------
twoInputApp :: TestApp () (Text, Text)
twoInputApp _ = do
(t1, t2) <- initManager_ $ do
tabNavigation
row $ do
a <- textInputTile (textInput def) (pure 9)
b <- textInputTile (textInput def) (pure 9)
pure (a, b)
pure (zipDyn (_textInput_value t1) (_textInput_value t2), never)
focusIsolationSpec :: Spec
focusIsolationSpec = describe "focus isolation in a managed row" $ do
it "delivers typing, paste, and BackTab only to the focused tile" $
withApp defaultVtyAppConfig (20, 3) twoInputApp $ \driver -> do
f0 <- stepInput driver (V.EvKey (V.KChar 'z') [])
frameObservation f0 `shouldBe` ("", "") -- nothing focused yet
_ <- stepInput driver (V.EvKey (V.KChar '\t') []) -- select first tile
f1 <- stepInput driver (V.EvKey (V.KChar 'a') [])
frameObservation f1 `shouldBe` ("a", "")
_ <- stepInput driver (V.EvKey (V.KChar '\t') []) -- select second tile
f2 <- stepInput driver (V.EvPaste (TE.encodeUtf8 "界"))
frameObservation f2 `shouldBe` ("a", "界")
_ <- stepInput driver (V.EvKey V.KBackTab []) -- back to first tile
f4 <- stepInput driver (V.EvKey (V.KChar 'b') [])
frameObservation f4 `shouldBe` ("ab", "界")
take 2 (gridRowText 0 (frameGrid f4)) `shouldBe` "ab"
stopApp driver
it "a click moves the cursor and does not duplicate typed input" $
withApp defaultVtyAppConfig (20, 3) twoInputApp $ \driver -> do
_ <- stepInput driver (V.EvKey (V.KChar '\t') [])
_ <- stepInput driver (V.EvKey (V.KChar 'a') [])
_ <- stepInput driver (V.EvKey (V.KChar 'b') [])
_ <- stepInput driver (V.EvMouseDown 0 0 V.BLeft []) -- cursor to col 0
f <- stepInput driver (V.EvKey (V.KChar 'c') [])
frameObservation f `shouldBe` ("cab", "")
take 3 (gridRowText 0 (frameGrid f)) `shouldBe` "cab"
stopApp driver
----------------------------------------------------------------------------
-- 3. Simultaneous UI input and caller modification
----------------------------------------------------------------------------
data SimCmd = SimBoth | SimModifyOnly
deriving (Eq)
simApp :: TestApp SimCmd (Text, Maybe Text, Int)
simApp cmd = do
let syntheticY = V.EvKey (V.KChar 'y') [] <$ fforMaybe cmd (\c -> if c == SimBoth then Just () else Nothing)
-- Replace the whole zipper. The final value is the modify result,
-- even when a UI keystroke occurs in the same frame.
modE = fforMaybe cmd $ \case
SimBoth -> Just (const (fromText "SET"))
SimModifyOnly -> Just (const (fromText "MOD"))
ti <-
localInput (\e -> leftmost [e, syntheticY]) $
textInput def {_textInputConfig_modify = modE}
n <- count (_textInput_userInput ti)
lastUI <- holdDyn Nothing (Just . value <$> _textInput_userInput ti)
pure ((,,) <$> _textInput_value ti <*> lastUI <*> n, never)
simultaneousModificationSpec :: Spec
simultaneousModificationSpec =
describe "simultaneous UI input and _textInputConfig_modify" $
it "modify sets the final value; userInput contains the UI-only result" $
withApp defaultVtyAppConfig (10, 3) simApp $ \driver -> do
f0 <- lastFrame driver
frameObservation f0 `shouldBe` ("", Nothing, 0)
f1 <- stepCommand driver SimBoth
frameObservation f1 `shouldBe` ("SET", Just "y", 1)
take 3 (gridRowText 0 (frameGrid f1)) `shouldBe` "SET"
f2 <- stepCommand driver SimModifyOnly
frameObservation f2 `shouldBe` ("MOD", Just "y", 1) -- no new userInput
take 3 (gridRowText 0 (frameGrid f2)) `shouldBe` "MOD"
stopApp driver
----------------------------------------------------------------------------
-- 4. Nested allocation
----------------------------------------------------------------------------
data AllocCmd = AllocReorder | AllocDropA
-- | Paint a one-character label at the child region's origin.
label
:: (Reflex t, Monad m, HasImageWriter t m)
=> Char
-> m ()
label c = tellImages (pure [V.string V.defAttr [c]])
-- | Run a 'Dynamic' of child widgets, replacing the child on updates.
switchWidget :: (Reflex t, Adjustable t m, MonadSample t m) => Dynamic t (m ()) -> m ()
switchWidget d = do
d0 <- sample (current d)
_ <- runWithReplace d0 (updated d)
pure ()
allocApp :: TestApp AllocCmd [Char]
allocApp cmd = do
kidsD <- holdDyn ['A', 'B', 'C'] $
fforMaybe cmd $ \case
AllocReorder -> Just ['C', 'A', 'B']
AllocDropA -> Just ['B', 'C']
initManager_ . col . switchWidget . ffor kidsD $ \ls -> do
-- Children get regions in list order. Each 'C' takes one full-width
-- flex slot. Each run of non-'C' children forms one fixed-3 row:
-- A is fixed 5 wide, and B takes the rest.
let place [] = pure ()
place ('C' : rest) = grout flex (label 'C') >> place rest
place rest = do
let (rowKids, rest') = span (/= 'C') rest
axis (pure Orientation_Row) (fixed (pure 3)) $
forM_ rowKids $ \c ->
grout (if c == 'A' then fixed (pure 5) else flex) (label c)
place rest'
place ls
pure (kidsD, never)
nestedAllocationSpec :: Spec
nestedAllocationSpec = describe "nested allocation" $ do
it "allocates fixed 5, flex, and outer flex in order" $
withApp defaultVtyAppConfig (20, 10) allocApp $ \driver -> do
f0 <- lastFrame driver
frameObservation f0 `shouldBe` ['A', 'B', 'C']
-- A fixed 5x3 at (0,0), B flex 15x3 at (5,0), C flex 20x7 at (0,3)
take 1 (gridRowText 0 (frameGrid f0)) `shouldBe` "A"
take 1 (drop 5 (gridRowText 0 (frameGrid f0))) `shouldBe` "B"
take 1 (gridRowText 3 (frameGrid f0)) `shouldBe` "C"
stopApp driver
it "reorders, resizes, and removes a held child" $
withApp defaultVtyAppConfig (20, 10) allocApp $ \driver -> do
f1 <- stepCommand driver AllocReorder
frameObservation f1 `shouldBe` ['C', 'A', 'B']
take 1 (gridRowText 0 (frameGrid f1)) `shouldBe` "C" -- C flex on top, rows 0-6
take 1 (gridRowText 7 (frameGrid f1)) `shouldBe` "A" -- fixed row now at rows 7-9
take 1 (drop 5 (gridRowText 7 (frameGrid f1))) `shouldBe` "B"
f2 <- resizeApp driver (10, 10)
take 1 (gridRowText 0 (frameGrid f2)) `shouldBe` "C"
take 1 (gridRowText 7 (frameGrid f2)) `shouldBe` "A"
take 1 (drop 5 (gridRowText 7 (frameGrid f2))) `shouldBe` "B"
f3 <- stepCommand driver AllocDropA
frameObservation f3 `shouldBe` ['B', 'C']
take 1 (gridRowText 0 (frameGrid f3)) `shouldBe` "B" -- B flexes to full width
take 1 (gridRowText 3 (frameGrid f3)) `shouldBe` "C"
stopApp driver
----------------------------------------------------------------------------
-- 5. Scroll / layout / input
----------------------------------------------------------------------------
data ScrollCmd = ScrollAppend | ScrollFollow
deriving (Eq)
scrollApp :: TestApp ScrollCmd ((Int, Int), Int)
scrollApp cmd = do
appends <- count (fforMaybe cmd (\c -> if c == ScrollAppend then Just () else Nothing))
let rowsD = (8 +) <$> appends
followD <-
holdDyn
(Just ScrollToBottom_Maintain)
(Just ScrollToBottom_Always <$ fforMaybe cmd (\c -> if c == ScrollFollow then Just () else Nothing))
let mkContent = do
md <- mouseDown V.BLeft
clicks <- holdDyn (99, 99) (_mouseDown_coordinates <$> md)
tellImages $
ffor (current rowsD) $ \n ->
[V.vertCat [V.string V.defAttr (show i) | i <- [1 .. n]]]
pure (void (updated rowsD), clicks)
(_, clicks) <-
pane (pure (Region 0 0 12 4)) (pure True) $
scrollable
def
{ _scrollableConfig_scrollToBottom = current followD
, _scrollableConfig_scrollbarVisibility = ScrollbarHidden
}
mkContent
pure (zipDyn clicks rowsD, never)
scrollBoxApp :: TestApp () ((Int, Int), Int)
scrollBoxApp _ = do
let mkContent = do
md <- mouseDown V.BLeft
clicks <- holdDyn (99, 99) (_mouseDown_coordinates <$> md)
tellImages (pure [V.vertCat [V.string V.defAttr (show i) | i <- [1 .. 8 :: Int]]])
pure (never, clicks)
(_, clicks) <-
initManager_ . tile flex . boxStatic singleBoxStyle $
scrollable
def {_scrollableConfig_scrollbarVisibility = ScrollbarHidden}
mkContent
pure (zipDyn clicks (pure (8 :: Int)), never)
scrollLayoutInputSpec :: Spec
scrollLayoutInputSpec = describe "scrollable composition" $ do
it "scrolls, translates clicks to content coordinates, and follows appends" $
withApp defaultVtyAppConfig (12, 4) scrollApp $ \driver -> do
f0 <- lastFrame driver
mapM_
(\(y, e) -> gridRowTrimmed y (frameGrid f0) `shouldBe` e)
[(0, "1"), (1, "2"), (2, "3"), (3, "4")]
_ <- stepInput driver (V.EvKey V.KDown [])
f2 <- stepInput driver (V.EvKey V.KDown [])
mapM_
(\(y, e) -> gridRowTrimmed y (frameGrid f2) `shouldBe` e)
[(0, "3"), (1, "4"), (2, "5"), (3, "6")]
fc <- stepInput driver (V.EvMouseDown 0 1 V.BLeft [])
frameObservation fc `shouldBe` ((0, 3), 8) -- viewport (0,1) -> content y=3
fA <- stepCommand driver ScrollAppend
gridRowTrimmed 0 (frameGrid fA) `shouldBe` "3" -- Maintain keeps view
frameObservation fA `shouldBe` ((0, 3), 9)
_ <- stepInput driver (V.EvKey V.KDown [])
_ <- stepInput driver (V.EvKey V.KDown [])
fB <- stepInput driver (V.EvKey V.KDown []) -- now at the bottom
gridRowTrimmed 0 (frameGrid fB) `shouldBe` "6"
_ <- stepCommand driver ScrollFollow
fF <- stepCommand driver ScrollAppend -- at bottom: follow the append
gridRowTrimmed 0 (frameGrid fF) `shouldBe` "7"
gridRowTrimmed 3 (frameGrid fF) `shouldBe` "10"
stopApp driver
it "inside a tile and a box: one-cell inset, no double translation" $
withApp defaultVtyAppConfig (12, 8) scrollBoxApp $ \driver -> do
_ <- stepInput driver (V.EvMouseDown 1 1 V.BLeft [])
_ <- stepInput driver (V.EvMouseUp 1 1 (Just V.BLeft))
_ <- stepInput driver (V.EvKey V.KDown [])
_ <- stepInput driver (V.EvKey V.KDown [])
f <- stepInput driver (V.EvMouseDown 1 2 V.BLeft []) -- box inset (1,1), scroll offset 2
take 1 (drop 1 (gridRowText 1 (frameGrid f))) `shouldBe` "3"
frameObservation f `shouldBe` ((0, 3), 8)
stopApp driver
----------------------------------------------------------------------------
-- 6. Drag and resize of splitVDrag
----------------------------------------------------------------------------
splitApp :: TestApp () ()
splitApp _ = do
void $ splitVDrag (fill (pure '-')) (fill (pure 'A')) (fill (pure 'B'))
pure (pure (), never)
-- | Drag from row @fromY@ to row @toY@ and return the final frame.
dragDivider :: AppDriver () () -> Int -> Int -> IO (Frame ())
dragDivider driver fromY toY = do
_ <- stepInput driver (V.EvMouseDown 3 fromY V.BLeft [])
_ <- stepInput driver (V.EvMouseDown 3 toY V.BLeft [])
stepInput driver (V.EvMouseUp 3 toY (Just V.BLeft))
splitDragSpec :: Spec
splitDragSpec = describe "splitVDrag drag and resize" $ do
it "starts at half height, drags, and re-drags after resize" $
withApp defaultVtyAppConfig (20, 10) splitApp $ \driver -> do
f0 <- lastFrame driver
gridRowText 5 (frameGrid f0) `shouldBe` replicate 20 '-' -- divider row 5
gridRowText 0 (frameGrid f0) `shouldBe` replicate 20 'A' -- A rows 0..4
gridRowText 9 (frameGrid f0) `shouldBe` replicate 20 'B' -- B rows 6..9
_ <- stepInput driver (V.EvMouseDown 3 5 V.BLeft [])
moving <- stepInput driver (V.EvMouseDown 3 7 V.BLeft [])
gridRowText 7 (frameGrid moving) `shouldBe` replicate 20 '-'
f1 <- stepInput driver (V.EvMouseUp 3 7 (Just V.BLeft))
gridRowText 7 (frameGrid f1) `shouldBe` replicate 20 '-'
gridRowText 6 (frameGrid f1) `shouldBe` replicate 20 'A'
gridRowText 8 (frameGrid f1) `shouldBe` replicate 20 'B'
f2 <- resizeApp driver (20, 20) -- fraction 0.7 keeps divider at row 14
gridRowText 14 (frameGrid f2) `shouldBe` replicate 20 '-'
gridRowText 13 (frameGrid f2) `shouldBe` replicate 20 'A'
gridRowText 15 (frameGrid f2) `shouldBe` replicate 20 'B'
f3 <- dragDivider driver 14 3 -- repeated drag
gridRowText 3 (frameGrid f3) `shouldBe` replicate 20 '-'
gridRowText 2 (frameGrid f3) `shouldBe` replicate 20 'A'
gridRowText 4 (frameGrid f3) `shouldBe` replicate 20 'B'
stopApp driver
it "clamps out-of-bounds drags without negative extents" $
withApp defaultVtyAppConfig (20, 10) splitApp $ \driver -> do
f1 <- dragDivider driver 5 100 -- far below the viewport
gridRowText 9 (frameGrid f1) `shouldBe` replicate 20 '-' -- clamped to last row
gridRowText 8 (frameGrid f1) `shouldBe` replicate 20 'A' -- B has zero height
f2 <- dragDivider driver 9 (-1) -- above the viewport
gridRowText 0 (frameGrid f2) `shouldBe` replicate 20 '-' -- clamped to row 0
gridRowText 1 (frameGrid f2) `shouldBe` replicate 20 'B' -- A has zero height
gridHeight (frameGrid f2) `shouldBe` 10 -- nothing shrank negatively
stopApp driver
it "works at tiny heights" $ do
withApp defaultVtyAppConfig (20, 1) splitApp $ \driver -> do
f <- lastFrame driver
gridRowText 0 (frameGrid f) `shouldBe` replicate 20 '-'
stopApp driver
withApp defaultVtyAppConfig (20, 2) splitApp $ \driver -> do
f <- lastFrame driver
gridRowText 0 (frameGrid f) `shouldBe` replicate 20 'A'
gridRowText 1 (frameGrid f) `shouldBe` replicate 20 '-'
stopApp driver
----------------------------------------------------------------------------
-- 7. Capture and replacement
----------------------------------------------------------------------------
data CapCmd = CapEmit | CapRemove
deriving (Eq)
data CapMode = CapHeld | CapShown | CapGone
deriving (Eq)
-- | A managed child: paints a marker plus its own 'x'-press count,
-- requests a terminal cursor, and owns a focus tile.
capChild
:: ( Reflex t
, MonadHold t m
, MonadFix m
, MonadNodeId m
, MonadSample t m
, PostBuild t m
, HasInput t m
, HasImageWriter t m
, HasDisplayRegion t m
, HasFocusReader t m
, HasCursor t m
, HasFocus t m
, HasLayout t m
, HasTheme t m
)
=> m ()
capChild = do
_ <- tile' flex $ do
xE <- key (V.KChar 'x')
n <- count xE
tellImages $ ffor (current n) $ \k -> [V.string V.defAttr ('M' : show k)]
setCursor (pure (CursorState CursorVisible CursorStyleDefault (0, 0)))
pure ()
capApp :: TestApp CapCmd Bool
capApp cmd = do
modeD <-
holdDyn CapHeld $
leftmost
[ CapShown <$ fforMaybe cmd (\c -> if c == CapEmit then Just () else Nothing)
, CapGone <$ fforMaybe cmd (\c -> if c == CapRemove then Just () else Nothing)
]
removed <- holdUniqDyn ((== CapGone) <$> modeD)
initManager_ $ do
tabNavigation
switchWidget $ ffor removed $ \gone ->
if gone
then blank
else do
(_, images) <- captureImages capChild
tellImages $ (\mode imgs -> if mode == CapShown then imgs else []) <$> current modeD <*> images
pure ((== CapGone) <$> modeD, never)
captureReplacementSpec :: Spec
captureReplacementSpec =
describe "capture and replacement of a managed child" $
it "keeps the image, re-emits once, and leaves no old image or stale handler" $
withApp defaultVtyAppConfig (10, 4) capApp $ \driver -> do
f0 <- lastFrame driver
countChar 'M' (frameGrid f0) `shouldBe` 0 -- marker captured, not painted
frameCursor f0 `shouldBe` V.AbsoluteCursor 0 0 -- cursor request still applies
_ <- stepInput driver (V.EvKey (V.KChar '\t') [])
f1 <- stepInput driver (V.EvKey (V.KChar 'x') [])
countChar 'M' (frameGrid f1) `shouldBe` 0 -- still held
f2 <- stepCommand driver CapEmit -- re-emit exactly once
countChar 'M' (frameGrid f2) `shouldBe` 1
-- Re-emitting the captured images preserves the child's state.
take 2 (gridRowText 0 (frameGrid f2)) `shouldBe` "M1"
f3 <- stepInput driver (V.EvKey (V.KChar 'x') [])
countChar 'M' (frameGrid f3) `shouldBe` 1
take 2 (gridRowText 0 (frameGrid f3)) `shouldBe` "M2"
f4 <- stepCommand driver CapRemove -- replace/remove the child
countChar 'M' (frameGrid f4) `shouldBe` 0 -- no old image
f5 <- stepInput driver (V.EvKey (V.KChar 'x') []) -- no stale handler
countChar 'M' (frameGrid f5) `shouldBe` 0
frameGrid f5 `shouldBe` frameGrid f4 -- nothing changed at all
frameObservation f5 `shouldBe` True
stopApp driver
----------------------------------------------------------------------------
-- 8. Context isolation (themes, cursors)
----------------------------------------------------------------------------
data ThemeCmd = ThemeFlip
flippedTheme :: Theme
flippedTheme =
defTheme
{ -- textInput paints its text with the ambient default style
-- ('themeAttr') and its cursor span with '_theme_textInputCursor'.
_theme_default = withForeground (V.rgbColor 200 0 0) def
, _theme_textInputCursor = withForeground (V.rgbColor 200 0 0) def
}
themeApp :: TestApp ThemeCmd (Text, Text)
themeApp cmd = do
t1 <- holdDyn defTheme (flippedTheme <$ cmd)
ti1 <-
pane (pure (Region 0 0 20 4)) (pure True) $
localTheme (const (current t1)) $
textInput def {_textInputConfig_initialValue = fromText "a"}
ti2 <-
pane (pure (Region 0 5 20 4)) (pure False) $
localTheme (const (pure defTheme)) $
textInput def {_textInputConfig_initialValue = fromText "b"}
pure (zipDyn (_textInput_value ti1) (_textInput_value ti2), never)
wideCursorApp :: TestApp () ()
wideCursorApp _ = do
-- The cursor widget sits in a contained inner pane. The terminal
-- cursor must add the viewport origins of both panes.
pane (pure (Region 2 2 10 3)) (pure True) $
pane (pure (Region 1 1 8 3)) (pure True) $ do
tellImages (pure [V.string V.defAttr "a\30028|"])
setCursor (pure (CursorState CursorVisible CursorStyleDefault (3, 0)))
pure (pure (), never)
contextIsolationSpec :: Spec
contextIsolationSpec = describe "context isolation" $ do
it "a local theme change restyles only its own subtree, across a resize" $
withApp defaultVtyAppConfig (20, 10) themeApp $ \driver -> do
f0 <- lastFrame driver
frameObservation f0 `shouldBe` ("a", "b")
let g0 = frameGrid f0
attr0 <- pure (attrAt 0 0 g0)
f1 <- stepCommand driver ThemeFlip
let g1 = frameGrid f1
attrAt 0 0 g1 `shouldNotBe` attr0 -- text attribute changed
attrAt 1 0 g1 `shouldNotBe` attrAt 1 0 g0 -- image cursor changed
attrAt 0 5 g1 `shouldBe` attrAt 0 5 g0 -- second box untouched
frameObservation f1 `shouldBe` ("a", "b")
f2 <- resizeApp driver (30, 10)
let g2 = frameGrid f2
attrAt 0 0 g2 `shouldBe` attrAt 0 0 g1 -- unchanged by the resize
attrAt 1 0 g2 `shouldBe` attrAt 1 0 g1
attrAt 0 5 g2 `shouldBe` attrAt 0 5 g1
f3 <- stepInput driver (V.EvKey (V.KChar 'z') []) -- still focused, values intact
frameObservation f3 `shouldBe` ("az", "b")
stopApp driver
it "setCursor over a wide glyph reports cell coordinates from the pane origin" $
withApp defaultVtyAppConfig (16, 5) wideCursorApp $ \driver -> do
f0 <- lastFrame driver
-- Inner pane's composed viewport origin is (2,2)+(1,1) = (3,3);
-- local (3,0) is 3 *cells* from that origin, so the cell is '|'
-- (character indexing would put it one cell further right).
frameCursor f0 `shouldBe` V.AbsoluteCursor 6 3
take 3 (drop 3 (gridRowText 3 (frameGrid f0))) `shouldBe` "a\30028|"
stopApp driver
----------------------------------------------------------------------------
-- Small boundaries
----------------------------------------------------------------------------
narrowInputApp :: TestApp () Text
narrowInputApp _ = do
ti <- textInput def
pure (_textInput_value ti, never)
smallBoundarySpec :: Spec
smallBoundarySpec = describe "small boundaries" $ do
it "wraps typed text and follows the cursor in a one-column viewport" $
withApp defaultVtyAppConfig (1, 2) narrowInputApp $ \driver -> do
_ <- stepInput driver (V.EvKey (V.KChar 'a') [])
f <- stepInput driver (V.EvKey (V.KChar 'b') [])
frameObservation f `shouldBe` "ab"
-- The insertion cursor wraps to a third row; a two-row viewport
-- follows it, showing the last character and its blank cursor row.
gridRowText 0 (frameGrid f) `shouldBe` "b"
gridRowText 1 (frameGrid f) `shouldBe` " "
top <- stepInput driver (V.EvKey V.KHome [])
gridRowText 0 (frameGrid top) `shouldBe` "a"
gridRowText 1 (frameGrid top) `shouldBe` "b"
stopApp driver
it "a zero-height terminal produces an empty but well-formed grid" $
withApp defaultVtyAppConfig (20, 0) splitApp $ \driver -> do
f <- lastFrame driver
gridHeight (frameGrid f) `shouldBe` 0
stopApp driver