packages feed

nano-ui-0.1.0.1: test/integration/Cases/Modal.hs

module Cases.Modal
  ( runModalCloseDamageTest
  , runModalNoPhantomScrollTest
  , runModalOverlayTest
  , runModalFitsTextTest
  , runModalFractionalScaleNoScrollTest
  , runModalFillLabelFitsTest
  ) where

import Control.Monad (forM_, when)
import Data.IORef (IORef)
import Data.Text qualified as T
import NanoUI
import NanoUI.Testing
import NanoUI.Testing.Assert (assert, assertEq, assertGt, evalUi, withInput)
import NanoUI.Testing.Harness
  ( centerOf
  , checkIdleFullDamage
  , clickPair
  , keyInp
  , runClick
  , spanYOf
  , warmup2
  , withInputOff
  )

runModalOverlayTest :: Context -> IORef Int -> IO ()
runModalOverlayTest ctx failed = do
  let
    inp0 = withInput 320 200
    ui = column $ do
      outside <- button' "Outside"
      (dlg, mInside) <- modal True "Title" (button' "Inside")
      pure (outside, dlg, mInside)
    closedUi = column $ do
      _ <- button "Outside"
      (dlg, mInside) <- modal False "Title" (button' "Inside")
      pure (dlg, mInside)

  (dlgClosed, mInsideClosed) <- evalUi ctx inp0 closedUi
  assert failed (not (respClicked dlgClosed))
  assert failed (case mInsideClosed of Nothing -> True; _ -> False)
  closedSpans <- collectOverlayTextSpans ctx inp0
  assert failed (not (any (\(_, txt, _, _, _) -> "Title" `T.isInfixOf` txt) closedSpans))

  (_, _, mInside0) <- warmup2 ctx inp0 ui
  overlays <- collectOverlayTextSpans ctx inp0
  assert failed (any (\(_, txt, _, _, _) -> "Title" `T.isInfixOf` txt) overlays)
  assert failed (any (\(_, txt, _, _, _) -> "Inside" `T.isInfixOf` txt) overlays)
  assert failed (not (any (\(_, txt, _, _, _) -> T.strip txt == "X") overlays))

  case mInside0 of
    Nothing -> assert failed False
    Just inside -> do
      let (pressIn, releaseIn) = clickPair inp0 (centerOf inside)
      _ <- runFrame ctx pressIn ui
      ((_, _, mClicked), _, _, _) <- runFrame ctx releaseIn ui
      assert failed (maybe False respClicked mClicked)

      let (backdrop, _) = clickPair inp0 (V2 4 4)
      ((_, dlgHit, _), _, _, _) <- runFrame ctx backdrop ui
      assert failed (respClicked dlgHit)

      let esc = keyInp KeyEscape inp0
      ((_, dlgEsc, _), _, _, _) <- runFrame ctx esc ui
      assert failed (respClicked dlgEsc)
      consumed <- overlayConsumesQuit ctx esc
      assert failed consumed
      _ <- runFrame ctx esc closedUi
      leftover <- overlayConsumesQuit ctx esc
      assert failed (not leftover)

  let tallUi = modal True "Tall" $ do
        forM_ [1 .. 40 :: Int] (\i -> label (T.pack ("Row " <> show i)))
        button "Close"
  (dlgTall, _) <- warmup2 ctx inp0 tallUi
  assert failed (rectH (respRect dlgTall) <= 200)

runModalNoPhantomScrollTest :: Context -> IORef Int -> IO ()
runModalNoPhantomScrollTest ctx failed = do
  let inp0 = withInput 400 300
      ui = modal True "About" $ do
        _ <- label "Immediate-mode GUI for Haskell."
        rowWith fillW $ do
          _ <- spacer (Grow 1) Fit
          _ <- button "Close"
          pure ()
  (dlg, _) <- warmup2 ctx inp0 ui
  let Rect _ _ mw mh = respRect dlg
  assert failed (mw > 0 && mh > 0)
  off0 <- getScrollOffset ctx (respId dlg)
  let wheel = inp0 {inputMousePos = centerOf dlg, inputScroll = V2 0 1}
  _ <- runFrame ctx wheel ui
  off1 <- getScrollOffset ctx (respId dlg)
  assertEq failed off0 0
  assertEq failed off1 0

-- Opening and closing a modal each repaint the whole window on the next idle
-- frame.
runModalCloseDamageTest :: Context -> IORef Int -> IO ()
runModalCloseDamageTest ctx failed = do
  let ui = do
        (open, setOpen) <- useFlag False
        resp <- button' "Open"
        when (respClicked resp) (setOpen True)
        (dlg, _) <- modal open "Title" (label "body")
        when (respClicked dlg) (setOpen False)
        pure resp
      inp0 = withInputOff 320 240
      esc = keyInp KeyEscape inp0
      idle = inp0 {inputDeltaTime = 1}
  _ <- runFrame ctx inp0 ui
  (resp, _, _, _) <- runFrame ctx inp0 ui
  _ <- runClick ctx inp0 ui (centerOf resp)
  checkIdleFullDamage failed ctx idle idle ui
  _ <- runFrame ctx esc ui
  checkIdleFullDamage failed ctx idle idle ui

-- At a fractional display scale, a modal sized to its content does not scroll
-- when that content uses fixed sizes off the device-pixel grid (regression:
-- the solve rounded the measured sizes inside the modal before placement laid
-- it out from them, and snapping a child's origin pushed its bottom below its
-- measured bottom so content-sized parents grew level after level; the body
-- overflowed its viewport by more than the scroll tolerance).
runModalFractionalScaleNoScrollTest :: Context -> IORef Int -> IO ()
runModalFractionalScaleNoScrollTest _ failed =
  forM_ [(scale, rows, nested) | scale <- [1, 1.25, 1.5, 1.75], rows <- [4 .. 8 :: Int], nested <- [False, True]] $ \(scale, rows, nested) -> do
    base <- newContext
    let ctx = withFontMetrics base ((monospaceMetrics 12) {fmSnapScale = scale})
        inp = withInputOff 1000 1000
        field i = rowWith (fillW . fixedH 30 . alignMid) (label (T.pack ("Field " <> show i)))
        section = columnWith (fillW . gap 6) $ do
          label "Section"
          columnWith (fillW . gap 0) $ do
            spacer Fit (Fixed 3)
            forM_ [1 .. rows] field
            spacer Fit (Fixed 3)
        body = columnWith (gap 10) (section >> section >> section >> button "Close")
        -- As the arena's root, and inside other content as an app opens one.
        ui
          | nested = column (label "Behind" >> fst <$> modal True "Details" body)
          | otherwise = fst <$> modal True "Details" body
        -- Whether a wheel over the modal moves its first field.
        scrolls c i = do
          dlg <- warmup2 c i ui
          let wheel = i {inputMousePos = centerOf dlg, inputScroll = V2 0 3}
          spans0 <- collectOverlayTextSpans c i
          _ <- runFrame c wheel ui
          spans1 <- collectOverlayTextSpans c wheel
          assert failed (not (null (spanYOf "Field 1" spans0)))
          pure (spanYOf "Field 1" spans1 /= spanYOf "Field 1" spans0)
    fits <- scrolls ctx inp
    assertEq failed fits False
    -- The same body in a short window does scroll, so the check can fail.
    short <- newContext
    clipped <- scrolls (withFontMetrics short ((monospaceMetrics 12) {fmSnapScale = scale})) (withInputOff 1000 300)
    assertEq failed clipped True

-- A modal widens for a filling label instead of wrapping it, so the label
-- stays one line inside the modal (regression: the label reported no width, the
-- modal stayed at its minimum, and the wrapped body overflowed into a scroll).
runModalFitsTextTest :: Context -> IORef Int -> IO ()
runModalFitsTextTest ctx failed = do
  let inp = withInput 800 600
      sentence = T.pack "A sentence that is wider than the smallest modal allows."
      ui = fst <$> modal True "About" (muted sentence)
  dlg <- warmup2 ctx inp ui
  spans <- collectOverlayTextSpans ctx inp
  let Rect _ _ dw _ = respRect dlg
      whole = [r | (r, t, _, _, _) <- spans, t == sentence]
  assertEq failed (length whole) 1
  forM_ whole $ \(Rect _ _ tw _) -> assertGt failed (dw + 0.5) tw

-- A modal fits a body with a filling label set in a smaller font than the
-- base, with its last row in view (regression: placing the modal measured
-- every label in the base font, so the smaller label, sized for its own font,
-- wrapped onto a second line the modal had not measured; the modal scrolled
-- and clipped its buttons).
runModalFillLabelFitsTest :: Context -> IORef Int -> IO ()
runModalFillLabelFitsTest _ failed = forM_ [12, 17] $ \base -> do
  ctx <- (`withFontMetrics` monospaceMetrics base) <$> newContext
  -- Wide enough for the label on one line in its own font, but not in the
  -- base font.
  let inp = withInput 2000 800
      body =
        columnWith (gap 14 . minW 560 . \l -> l {layoutPadding = Padding 0 0 0 0}) $ do
          labelWith (tight . fillW) "How cabal should log in to Hackage for uploads:"
          _ <- radio ["cabal's config file (no login found in it)", "A username and password", "An API token"] (0 :: Int)
          labelWith (tight . fillW . fontSize 14) "Kept in memory for this session only. The password goes to cabal on its standard input."
          separator
          rowWith (fillW . gap 8 . alignMid . tight) $ do
            flex
            _ <- button "Cancel"
            button' "Use this login"
      ui = modal True "Hackage login" body
  (dlg, mOk) <- warmup2 ctx inp ui
  spans0 <- collectOverlayTextSpans ctx inp
  let wheel = inp {inputMousePos = centerOf dlg, inputScroll = V2 0 3}
  _ <- runFrame ctx wheel ui
  spans1 <- collectOverlayTextSpans ctx wheel
  assert failed (not (null (spanYOf "An API token" spans0)))
  assertEq failed (spanYOf "An API token" spans1) (spanYOf "An API token" spans0)
  case mOk of
    Nothing -> assert failed False
    Just ok -> do
      let Rect _ dy _ dh = respRect dlg
          Rect _ by _ bh = respRect ok
      assertGt failed (dy + dh + 0.5) (by + bh)