packages feed

nano-ui-0.1.0.0: test/integration/Cases/Tabs.hs

module Cases.Tabs
  ( runTabsClosableTest
  , runTabsDisabledTest
  , runTabsDamageTest
  , runTabsEmitTest
  , runTabsLazinessTest
  , runTabsScrollTest
  , runTabsStatePersistenceTest
  , runPanelBodySwapDamageTest
  , runTabResponseForwardingTest
  ) where

import Control.Monad (forM, forM_, replicateM)
import Data.IORef (IORef, modifyIORef', newIORef, readIORef, writeIORef)
import Data.Maybe (isJust)
import Data.Text qualified as T
import Data.Sequence qualified as Seq
import NanoUI
import NanoUI.Testing
import NanoUI.Testing.Assert (assert, assertEq, run2Frames, withInput)
import NanoUI.Testing.Harness
  ( assertSpansHas
  , clickPair
  , drawQuads
  , hasText
  , keyInp
  , runClick
  , spanCenter
  , warmup2
  , withInputOff
  )
import NanoUI.Context (Context (..))
import NanoUI.Emit qualified as Emit
import NanoUI.Layout.Arena (arenaCount, findNodeM, getRect, getText, getWidgetId)

data DummyTab = TabA | TabB | TabC
  deriving (Eq, Show)

fullWindowRect :: Input -> Rect
fullWindowRect inp =
  let Size w h = inputWindowSize inp
   in Rect 0 0 w h

-- Content replacement inside a floating window must repaint every pixel of
-- the new body: a clip that skipped any incoming row would leave the pane
-- rendering stale pixels from the previous body ("ghosting"). The stable
-- content slot and the incoming key rects (diffNew) must together cover all
-- five rows.
runPanelBodySwapDamageTest :: Context -> IORef Int -> IO ()
runPanelBodySwapDamageTest ctx failed = do
  let inp0 = withInput 400 300
      uiA = window True "TabWin" (columnWith (fixedH 300) (label "WIDE BODY ROW ONE"))
      uiB = window True "TabWin" (columnWith (fixedH 300) (column (replicateM 5 (label "row line") >> pure ())))
  _ <- warmup2 ctx inp0 uiA
  _ <- runFrame ctx inp0 uiA
  _ <- takeDamage ctx
  _ <- runFrame ctx inp0 uiB
  dmg <- takeDamage ctx
  assert failed (not (damageIsEmpty dmg))
  let dmgR = case dmg of
        DamageFull -> fullWindowRect inp0
        DamageClip r -> r
      Rect ddx ddy ddw ddh = dmgR
  spans <- collectOverlayTextSpans ctx inp0
  let rows = [(r, t) | (r, t, _, _, _) <- spans, "row line" `T.isInfixOf` t]
  assert failed (length rows == 5)
  forM_ rows $ \(Rect rx ry rw rh, _) -> do
    assert failed (rx >= ddx && ry >= ddy && rx + rw <= ddx + ddw && ry + rh <= ddy + ddh)

runTabsLazinessTest :: Context -> IORef Int -> IO ()
runTabsLazinessTest ctx failed = do
  evalCountA <- newIORef (0 :: Int)
  evalCountB <- newIORef (0 :: Int)
  evalCountC <- newIORef (0 :: Int)
  let inp = withInput 200 100
      ui = tabs TabB $ Seq.fromList
        [ tab TabA "A" (uiIO (modifyIORef' evalCountA (+ 1)) >> label "Body A")
        , tab TabB "B" (uiIO (modifyIORef' evalCountB (+ 1)) >> label "Body B")
        , tab TabC "C" (uiIO (modifyIORef' evalCountC (+ 1)) >> label "Body C")
        ]
  _ <- runFrame ctx inp ui
  cntA <- readIORef evalCountA
  cntB <- readIORef evalCountB
  cntC <- readIORef evalCountC
  assertEq failed cntA 0
  assertEq failed cntB 1
  assertEq failed cntC 0

data TabMsg = MsgSelect DummyTab | MsgClose DummyTab
  deriving (Eq, Show)

runTabsEmitTest :: Context -> IORef Int -> IO ()
runTabsEmitTest ctx failed = do
  let inp0 = withInput 300 100
      ui curTab = Emit.tabs curTab
        [ tab TabA "Alpha" (label "Body A")
        , tab TabB "Beta" (label "Body B")
        ]
        MsgSelect
  _ <- runFrame ctx inp0 (ui TabA)
  spans <- collectTextSpans ctx
  case [r | (r, txt, _, _, _) <- spans, "Beta" `T.isInfixOf` txt] of
    (r : _) -> do
      let (press, release) = clickPair inp0 (spanCenter r)
      _ <- runFrame ctx press (ui TabA)
      (_, msgs, _, _) <- runFrame ctx release (ui TabA)
      assertEq failed (decodeMessages msgs :: [TabMsg]) [MsgSelect TabB]
    [] -> assert failed False

-- Composite responses expose every flag of their widget response
-- (regression: TabResponse dropped respSubmitted).
runTabResponseForwardingTest :: Context -> IORef Int -> IO ()
runTabResponseForwardingTest _ failed = do
  let inner = mempty {rawRespSubmitted = True, rawRespRightPressed = True, rawRespChanged = True}
      tabResp = TabResponse inner Nothing TabA
      tableResp = TableResponse inner (SortCol 0 SortAsc) [] mempty
  assert failed (respSubmitted tabResp && respRightPressed tabResp && respChanged tabResp)
  assert failed (respSubmitted tableResp && respRightPressed tableResp && respChanged tableResp)

runTabsClosableTest :: Context -> IORef Int -> IO ()
runTabsClosableTest ctx failed = do
  let inp0 = withInput 300 100
      ui curTab = tabs' curTab
        [ closableTab TabA "Alpha" (label "Body A")
        , closableTab TabB "Beta" (label "Body B")
        ]
  _ <- runFrame ctx inp0 (ui TabA)
  mClose <- findCloseButtonRect ctx
  case mClose of
    Just r -> do
      tResp <- runClick ctx inp0 (ui TabA) (spanCenter r)
      assertEq failed (tabClosed tResp) (Just TabA)
      assertEq failed (tabActive tResp) TabA
    Nothing -> assert failed False

findCloseButtonRect :: Context -> IO (Maybe Rect)
findCloseButtonRect ctx = do
  let na = ctxNodeArena ctx
  found <- findNodeM na (fmap ("\215" `T.isInfixOf`) . getText na)
  forM found $ \i -> do
    (x, y, w, h) <- getRect na i
    pure (Rect x y w h)

-- The public disabled flag covers both the header and its close control,
-- including retained keyboard focus when an enabled tab becomes disabled.
runTabsDisabledTest :: Context -> IORef Int -> IO ()
runTabsDisabledTest _ failed = forM_ [TabTop, TabLeft] $ \orientation -> do
  ctx <- newContext
  let inp = withInputOff 400 240
      ui disabled = tabsConfigured' defaultTabsConfig {tabsOrientation = orientation} TabA
        [ (closableTab TabB "Disabled" (label "Body B")) {tabDisabled = disabled}
        , tab TabA "Enabled" (label "Body A")
        ]
      check response = do
        assertEq failed (tabActive response) TabA
        assertEq failed (tabClosed response) Nothing
        assert failed (not (respClicked response) && not (respChanged response))
  _ <- warmup2 ctx inp (ui False)
  let arena = ctxNodeArena ctx
  n <- arenaCount arena
  headers <- mapM (\i -> (,) <$> getText arena i <*> getWidgetId arena i) [0 .. n - 1]
  _ <- warmup2 ctx inp (ui True)
  spans <- collectTextSpans ctx
  case [r | (r, txt, _, _, _) <- spans, txt == "Disabled"] of
    r : _ -> check =<< runClick ctx inp (ui True) (spanCenter r)
    [] -> assert failed False
  closeRect <- findCloseButtonRect ctx
  case closeRect of
    Just r -> check =<< runClick ctx inp (ui True) (spanCenter r)
    Nothing -> assert failed False
  forM_ [wid | (txt, wid) <- headers, txt == "Disabled" || txt == "\215"] $ \wid -> do
    writeIORef (ctxFocusId ctx) wid
    (result, _, _, _) <- runFrame ctx (keyInp KeyEnter inp) (ui True)
    check result
  -- Re-enabling the same header preserves its identity and restores activation.
  _ <- warmup2 ctx inp (ui False)
  spansEnabled <- collectTextSpans ctx
  case [r | (r, txt, _, _, _) <- spansEnabled, txt == "Disabled"] of
    r : _ -> do
      response <- runClick ctx inp (ui False) (spanCenter r)
      assertEq failed (tabActive response) TabB
    [] -> assert failed False

runTabsStatePersistenceTest :: Context -> IORef Int -> IO ()
runTabsStatePersistenceTest ctx failed = do
  let inp0 = withInput 300 100
      ui curTab = tabs curTab
        [ tab TabA "A" $
            withKey ("tab-a" :: T.Text) $
              withKey ("flag" :: T.Text) $ do
                (flag, setFlag) <- useFlag False
                whenM (button "ToggleA") (setFlag (not flag))
                label (if flag then "FlagIsOn" else "FlagIsOff")
        , tab TabB "B" (label "OtherTab")
        ]
  _ <- runFrame ctx inp0 (ui TabA)
  spans0 <- collectTextSpans ctx
  case [r | (r, txt, _, _, _) <- spans0, "ToggleA" `T.isInfixOf` txt] of
    (r : _) -> do
      _ <- runClick ctx inp0 (ui TabA) (spanCenter r)
      _ <- runFrame ctx inp0 (ui TabA)
      spans1 <- collectTextSpans ctx
      assertSpansHas failed "FlagIsOn" spans1

      _ <- runFrame ctx inp0 (ui TabB)
      spans2 <- collectTextSpans ctx
      assertSpansHas failed "OtherTab" spans2
      assert failed (not (hasText "FlagIsOn" spans2))

      _ <- runFrame ctx inp0 (ui TabA)
      spans3 <- collectTextSpans ctx
      assertSpansHas failed "FlagIsOn" spans3
    [] -> assert failed False

runTabsDamageTest :: Context -> IORef Int -> IO ()
runTabsDamageTest ctx failed = do
  let inp0 = withInputOff 300 100
      ui curTab = tabs' curTab
        [ tab TabA "Alpha" (label "Body A with some text")
        , tab TabB "Beta" (label "Body B different widgets")
        ]
      covers dmg (Rect rx ry rw rh) = case dmg of
        DamageFull -> True
        DamageClip (Rect dx dy dw dh) -> rx >= dx && ry >= dy && rx + rw <= dx + dw && ry + rh <= dy + dh
  _ <- runFrame ctx inp0 (ui TabA)
  _ <- takeDamage ctx
  _ <- runFrame ctx inp0 (ui TabA)
  dIdle <- takeDamage ctx
  assert failed (dIdle /= DamageFull)
  spansIdle <- collectTextSpans ctx
  assertSpansHas failed "Body A" spansIdle

  spans <- collectTextSpans ctx
  case [r | (r, txt, _, _, _) <- spans, "Beta" `T.isInfixOf` txt] of
    (beta : _) -> do
      let (press, release) = clickPair inp0 (spanCenter beta)
      _ <- runFrame ctx press (ui TabA)
      (resp, _, _, _) <- runFrame ctx release (ui TabA)
      assert failed (respChanged resp && tabActive resp == TabB)
      spansSwitch <- collectTextSpans ctx
      assertSpansHas failed "Body B" spansSwitch
      assert failed (not (hasText "Body A" spansSwitch))
      let bodyB = [r | (r, txt, _, _, _) <- spansSwitch, "Body B" `T.isInfixOf` txt]
      dSwitch <- takeDamage ctx
      assert failed (not (null bodyB) && all (covers dSwitch) bodyB)

      _ <- runFrame ctx inp0 (ui TabB)
      dTabB <- takeDamage ctx
      assert failed (all (covers dTabB) bodyB)

      _ <- runFrame ctx inp0 (ui TabB)
      dSettled <- takeDamage ctx
      assert failed (dSettled /= DamageFull)
    [] -> assert failed False

-- Too-wide tab strips scroll instead of overflowing. The framework scroll
-- container owns the clip, the offset and the damage; the strip adds
-- chevron buttons on the left and right and pages the same offset with
-- them. Acceptance: scrolling left and right works (buttons, up/down wheel
-- notches mapped onto the horizontal offset) as well as left+right
-- (horizontal wheel, applied by the framework scroller itself); the tab
-- headers look exactly as they did before the strip could scroll (no
-- scroller well behind them); and no scrollbar appears.
runTabsScrollTest :: Context -> IORef Int -> IO ()
runTabsScrollTest _ failed = do
  let labels = ["Controls", "Graphics", "Typography", "Diagnostics", "LongestTabName"]
      mkTabs cur = tabBar cur [tab (i :: Int) l () | (i, l) <- zip [0 ..] labels]
      arrowRect ctx ch = do
        spans <- collectTextSpans ctx
        pure [r | (r, t, _, _, _) <- spans, T.any (== ch) t]
      chevrons = ['\8250', '\8249']

  -- Wide bar: everything fits and no arrows are drawn.
  wide <- newContext
  let wideInp = withInput 900 120
  _ <- runFrame wide wideInp (mkTabs 0)
  _ <- runFrame wide wideInp (mkTabs 0)
  wideSpans <- collectTextSpans wide
  forM_ labels $ \l -> assert failed (hasText l wideSpans)
  assert failed (not (T.any (`elem` chevrons) (T.concat [t | (_, t, _, _, _) <- wideSpans])))

  ctx <- newContext
  let inp = withInput 240 120
  _ <- runFrame ctx inp (mkTabs 0)
  _ <- runFrame ctx inp (mkTabs 0)
  -- The strip only pulls in its scroller once it has measured an overflow, so
  -- the arrow buttons show from the third frame on.
  _ <- runFrame ctx inp (mkTabs 0)
  spans0 <- collectTextSpans ctx
  assert failed (hasText "Controls" spans0)
  assert failed (not (hasText "LongestTabName" spans0))

  -- No scroller well: while the strip is scrollable it must not paint the
  -- input background, the input border, or any scrollbar track or thumb
  -- behind the headers. Only the tab buttons themselves (and the arrows)
  -- may paint in the bar. The checked region is the full window width and
  -- the bar's height (header 28 + 4 slack + 2 slop), derived from the input
  -- so a resize of the test window cannot silently shrink coverage.
  theme <- readIORef (ctxTheme ctx)
  (_, _, dd, _) <- run2Frames ctx inp (mkTabs 0)
  quads <- drawQuads dd
  let Size winW _ = inputWindowSize inp
      bar = Rect 0 0 winW 34
      inputSurface = themeInput theme
      forbidden =
        [ styleBg inputSurface
        , styleBorder inputSurface
        , scrollBarTrackColor inputSurface theme
        , scrollBarThumbColor inputSurface theme
        ]
      inBar = [(r, c) | (r, c) <- quads, isJust (rectIntersect r bar), c `elem` forbidden]
  assert failed (null inBar)

  -- Left and right buttons page the strip; the right arrow is pinned to the
  -- bar's far edge rather than trailing the last visible tab.
  mRight <- arrowRect ctx '\8250'
  case mRight of
    (r : _) -> do
      assert failed (rectX r + rectW r > 200)
      _ <- runClick ctx inp (mkTabs 0) (spanCenter r)
      _ <- runFrame ctx inp (mkTabs 0)
      spans1 <- collectTextSpans ctx
      assert failed (not (hasText "Controls" spans1))
      mLeft <- arrowRect ctx '\8249'
      case mLeft of
        (left : _) -> do
          _ <- runClick ctx inp (mkTabs 0) (spanCenter left)
          _ <- runFrame ctx inp (mkTabs 0)
          spans2 <- collectTextSpans ctx
          assert failed (hasText "Controls" spans2)
        _ -> assert failed False
    _ -> assert failed False

  -- Wheel up/down over the bar pages the window too (the strip maps the
  -- notches onto the horizontal offset).
  spans3 <- collectTextSpans ctx
  case [r | (r, t, _, _, _) <- spans3, "Controls" `T.isInfixOf` t] of
    (Rect cx cy cw ch : _) -> do
      let wheelDown = inp {inputMousePos = spanCenter (Rect cx cy cw ch), inputScroll = V2 0 20}
      _ <- runFrame ctx wheelDown (mkTabs 0)
      _ <- runFrame ctx inp (mkTabs 0)
      spans4 <- collectTextSpans ctx
      assert failed (not (hasText "Controls" spans4))

      -- Left+right wheel over the bar scrolls the same offset through the
      -- framework scroller. The vertical wheel pinned the offset at max, so
      -- wheeling right stays put (clamped at the end); wheeling left runs
      -- back to the start and re-shows the first tab, and further left
      -- notches clamp at zero instead of running past it. The deltas are
      -- coupled to the framework wheel step (scrollLineFor, 20px per notch
      -- on window hosts): V2 0 20 saturates at max, and +/-100 notches
      -- crosses the whole range regardless of the exact step.
      let wheelX d = inp {inputMousePos = spanCenter (Rect cx cy cw ch), inputScroll = V2 d 0}
      _ <- runFrame ctx (wheelX 10) (mkTabs 0)
      _ <- runFrame ctx inp (mkTabs 0)
      spans5 <- collectTextSpans ctx
      assert failed (not (hasText "Controls" spans5))
      _ <- runFrame ctx (wheelX (-100)) (mkTabs 0)
      _ <- runFrame ctx inp (mkTabs 0)
      spans6 <- collectTextSpans ctx
      assert failed (hasText "Controls" spans6)
      _ <- runFrame ctx (wheelX (-100)) (mkTabs 0)
      _ <- runFrame ctx inp (mkTabs 0)
      spans7 <- collectTextSpans ctx
      assert failed (hasText "Controls" spans7)
    _ -> assert failed False

  -- Tab-list changes recompute the reachable range: shrinking back under
  -- the width drops the scroller and both arrows; growing past it again
  -- re-engages them and re-clips the tail. (One header fits the 240-wide
  -- window; two would still overflow the ~180px viewport between arrows.)
  let shortLabels = ["Controls"]
      mkShort cur = tabBar cur [tab (i :: Int) l () | (i, l) <- zip [0 ..] shortLabels]
  _ <- runFrame ctx inp (mkShort 0)
  _ <- runFrame ctx inp (mkShort 0)
  _ <- runFrame ctx inp (mkShort 0)
  spansS <- collectTextSpans ctx
  forM_ shortLabels $ \l -> assert failed (hasText l spansS)
  mRightS <- arrowRect ctx '\8250'
  mLeftS <- arrowRect ctx '\8249'
  assert failed (null mRightS && null mLeftS)
  _ <- runFrame ctx inp (mkTabs 0)
  _ <- runFrame ctx inp (mkTabs 0)
  _ <- runFrame ctx inp (mkTabs 0)
  spansG <- collectTextSpans ctx
  mRightG <- arrowRect ctx '\8250'
  assert failed (not (null mRightG))
  assert failed (not (hasText "LongestTabName" spansG))