nano-ui-0.1.0.0: test/integration/Cases/CustomWidget.hs
module Cases.CustomWidget
( runCustomWidgetMeasureTest
, runCustomWidgetCursorTest
, runCustomWidgetInteractionTest
, runCustomWidgetQueuedClickTest
, runCustomWidgetContentDamageTest
, runCustomWidgetContentKeyTest
, runReferenceKnobTest
, runDropTargetTest
) where
import Control.Monad (forM_, void)
import Data.IORef (IORef, writeIORef)
import Data.Primitive.SmallArray qualified as SA
import NanoUI
import NanoUI.Context (Context (..))
import NanoUI.Testing
( UiCursorKind (..)
, cursorKindIs
, newContext
, runFrame
, takeDamage
)
import NanoUI.Testing.Assert (assert, assertEq, withInput)
import NanoUI.Testing.Harness (centerOf, clickPair, drawQuads, warmup2, withInputOff)
-- | Verifies custom intrinsic layout measurement via widgetMeasure hook, and
-- that the measurement reverts once the hook is gone.
runCustomWidgetMeasureTest :: Context -> IORef Int -> IO ()
runCustomWidgetMeasureTest ctx failed = do
let inp = withInput 400 400
ui measure = column $ do
fst <$> customWidget defaultCustomWidgetSpec
{ widgetMeasure = measure
, widgetLayout = defaultLayout
}
resp <- warmup2 ctx inp (ui (Just $ \_ _ -> (160, 48)))
let r = respRect resp
assert failed (rectW r == 160 && rectH r == 48)
plain <- warmup2 ctx inp (ui Nothing)
let Rect _ _ pw ph = respRect plain
assertEq failed (pw, ph) (32, 32)
-- | Verifies dynamic cursor resolution on custom widgets.
runCustomWidgetCursorTest :: Context -> IORef Int -> IO ()
runCustomWidgetCursorTest ctx failed = do
let inp0 = withInput 300 300
ui = column $ do
fst <$> customWidget defaultCustomWidgetSpec
{ widgetLayout = fixedWH 80 80 defaultLayout
, widgetCursor = Just (\_ -> UiCursorNsResize)
}
resp <- warmup2 ctx inp0 ui
let Rect rx ry rw rh = respRect resp
hoverInp = inp0 { inputMousePos = centerOf resp }
_ <- runFrame ctx hoverInp ui
hoverOk <- cursorKindIs ctx hoverInp UiCursorNsResize
assert failed hoverOk
let outInp = inp0 { inputMousePos = V2 (rx + rw + 50) (ry + rh + 50) }
_ <- runFrame ctx outInp ui
outOk <- cursorKindIs ctx outInp UiCursorNsResize
assert failed (not outOk)
-- | Verifies interaction state propagation (hover, press, click) and CustomDrawContext.
runCustomWidgetInteractionTest :: Context -> IORef Int -> IO ()
runCustomWidgetInteractionTest ctx failed = do
let inp0 = withInput 300 300
ui = column $ do
customWidget defaultCustomWidgetSpec
{ widgetLayout = fixedWH 80 40 defaultLayout
, widgetInteract = \resp cdc _ -> (resp, (cdcHovered cdc, cdcPressed cdc))
}
(resp0, _) <- warmup2 ctx inp0 ui
let pos = centerOf resp0
(pressInp, releaseInp) = clickPair inp0 pos
_ <- runFrame ctx pressInp ui
((respClick, (hovered, pressed)), _, _, _) <- runFrame ctx releaseInp ui
assert failed (respClicked respClick)
assert failed hovered
assert failed (not pressed)
-- | A click the frame queued for a widget whose pointer hit missed still
-- reaches custom widgets (regression: their default interaction rebuilt the
-- click from hover and release alone, dropping the queued click).
runCustomWidgetQueuedClickTest :: Context -> IORef Int -> IO ()
runCustomWidgetQueuedClickTest ctx failed = do
let inp0 = (withInput 300 300) {inputMousePos = V2 290 290}
ui = column $ do
fromCanvas <- canvas (fixedWH 80 40) (\_ -> pure ())
fromSpec <- fst <$> customWidget defaultCustomWidgetSpec {widgetLayout = fixedWH 80 40 defaultLayout}
pure [fromCanvas, fromSpec]
warm <- warmup2 ctx inp0 ui
forM_ (zip [0 :: Int ..] warm) $ \(i, resp0) -> do
writeIORef (ctxClickedId ctx) (respId resp0)
(resps, _, _, _) <- runFrame ctx inp0 ui
assert failed (map respClicked resps == [j == i | j <- [0 .. length resps - 1]])
-- | A custom widget whose drawing reads state from outside the spec repaints
-- when that state changes, though its rect and hover/press state stay the
-- same (regression: its ops were cached on those alone, so a table header
-- kept drawing its sort arrow after another column took the sort, and the
-- frame damaged nothing).
runCustomWidgetContentDamageTest :: Context -> IORef Int -> IO ()
runCustomWidgetContentDamageTest ctx failed = do
let inp = withInputOff 400 300
red = colorRGBA 255 0 0 255
blue = colorRGBA 0 0 255 255
ui on = column $ do
label "Other"
fst <$> customWidget defaultCustomWidgetSpec
{ widgetLayout = fixedWH 80 40 defaultLayout
, widgetDraw = \_ r -> runCanvas (drawRect r (if on then red else blue))
}
resp <- warmup2 ctx inp (ui False)
_ <- takeDamage ctx
(_, _, draw, _) <- runFrame ctx inp (ui True)
dmg <- takeDamage ctx
case dmg of
DamageClip clip -> assert failed (coversRect clip (respRect resp))
DamageFull -> assert failed False
quads <- drawQuads draw
assert failed (any ((== red) . snd) quads)
assert failed (not (any ((== blue) . snd) quads))
-- The built-in progress bar captures its fraction the same way.
let bar frac = column (progressBar' frac)
barResp <- warmup2 ctx inp (bar 0.2)
_ <- takeDamage ctx
_ <- runFrame ctx inp (bar 0.8)
barDmg <- takeDamage ctx
case barDmg of
DamageClip clip -> assert failed (coversRect clip (respRect barResp))
DamageFull -> assert failed False
-- | A content key is taken at its word: while it is unchanged the widget
-- neither rebuilds its ops nor repaints, a new key does both, and a theme
-- change rebuilds them even though the key did not move, since the ops can
-- read the theme.
runCustomWidgetContentKeyTest :: Context -> IORef Int -> IO ()
runCustomWidgetContentKeyTest ctx failed = do
let inp = withInputOff 400 300
red = colorRGBA 255 0 0 255
blue = colorRGBA 0 0 255 255
ui key on = column $ do
label "Other"
fst <$> customWidget defaultCustomWidgetSpec
{ widgetLayout = fixedWH 80 40 defaultLayout
, widgetContent = key
, widgetDraw = \_ r -> runCanvas (drawRect r (if on then red else blue))
}
resp <- warmup2 ctx inp (ui 1 False)
_ <- takeDamage ctx
-- Same key, different captured state: the ops it already has stand.
(_, _, keptDraw, _) <- runFrame ctx inp (ui 1 True)
keptDmg <- takeDamage ctx
keptQuads <- drawQuads keptDraw
assert failed (any ((== blue) . snd) keptQuads)
case keptDmg of
DamageClip clip -> assert failed (not (coversRect clip (respRect resp)))
DamageFull -> assert failed False
-- A new key rebuilds and repaints.
(_, _, freshDraw, _) <- runFrame ctx inp (ui 2 True)
freshDmg <- takeDamage ctx
freshQuads <- drawQuads freshDraw
assert failed (any ((== red) . snd) freshQuads)
case freshDmg of
DamageClip clip -> assert failed (coversRect clip (respRect resp))
DamageFull -> assert failed False
-- Disabling the widget repaints it: the ops rebuild in their disabled form,
-- and disabled is not one of the roles damage already follows.
let grey = colorRGBA 128 128 128 255
dimmable off = column $ do
label "Other"
disabledWhen off $ fst <$> customWidget defaultCustomWidgetSpec
{ widgetLayout = fixedWH 80 40 defaultLayout
, widgetContent = 4
, widgetDraw = \cdc r -> runCanvas (drawRect r (if cdcDisabled cdc then grey else blue))
}
disabledCtx <- newContext
dresp <- warmup2 disabledCtx inp (dimmable False)
_ <- takeDamage disabledCtx
(_, _, disabledDraw, _) <- runFrame disabledCtx inp (dimmable True)
disabledDmg <- takeDamage disabledCtx
disabledQuads <- drawQuads disabledDraw
assert failed (any ((== grey) . snd) disabledQuads)
case disabledDmg of
DamageClip clip -> assert failed (coversRect clip (respRect dresp))
DamageFull -> pure ()
-- A keyed widget that only moved still draws at its new place: paint
-- translates the ops it kept.
let moved lead = column $ do
spacer Fit (Fixed lead)
fst <$> customWidget defaultCustomWidgetSpec
{ widgetLayout = fixedWH 80 40 defaultLayout
, widgetContent = 5
, widgetDraw = \_ r -> runCanvas (drawRect r red)
}
settled <- warmup2 ctx inp (moved 40)
let Rect _ my _ _ = respRect settled
_ <- warmup2 ctx inp (moved 10)
(_, _, movedDraw, _) <- runFrame ctx inp (moved 40)
movedQuads <- drawQuads movedDraw
assert failed (any (\(Rect _ qy _ _, c) -> c == red && abs (qy - my) < 0.5) movedQuads)
-- Swapping the theme rebuilds a keyed widget that draws from the theme,
-- through either theme entry point.
let accent2 = colorRGBA 7 8 9 255
accent3 = colorRGBA 11 12 13 255
themedUi = column $ do
label "Other"
fst <$> customWidget defaultCustomWidgetSpec
{ widgetLayout = fixedWH 80 40 defaultLayout
, widgetContent = 3
, widgetDraw = \cdc r -> runCanvas (drawRect r (themeAccent (cdcTheme cdc)))
}
_ <- warmup2 ctx inp themedUi
theme0 <- getTheme ctx
setTheme ctx theme0 {themeAccent = accent2}
(_, _, themedDraw, _) <- runFrame ctx inp themedUi
themedQuads <- drawQuads themedDraw
assert failed (any ((== accent2) . snd) themedQuads)
ctx3 <- withTheme ctx theme0 {themeAccent = accent3}
(_, _, withThemeDraw, _) <- runFrame ctx3 inp themedUi
withThemeQuads <- drawQuads withThemeDraw
assert failed (any ((== accent3) . snd) withThemeQuads)
setTheme ctx theme0
-- | Whether a damage clip covers a widget's rect.
coversRect :: Rect -> Rect -> Bool
coversRect (Rect cx cy cw ch) (Rect x y w h) =
cx <= x && cy <= y && cx + cw >= x + w && cy + ch >= y + h
-- | Verifies the reference rotary knob widget.
runReferenceKnobTest :: Context -> IORef Int -> IO ()
runReferenceKnobTest ctx failed = do
let inp0 = withInput 300 300
ui = column $ knob' 0 100 25
(resp0, val0) <- warmup2 ctx inp0 ui
assert failed (val0 == 25)
let pos = centerOf resp0
dragStart = inp0 { inputMousePos = pos, inputMouseDown = True, inputMousePressed = True }
-- Drag upward (negative dy in screen coords) to increase knob value
dragUp = inp0 { inputMousePos = V2 (v2X pos) (v2Y pos - 30), inputMouseDown = True, inputMousePressed = False }
release = inp0 { inputMousePos = V2 (v2X pos) (v2Y pos - 30), inputMouseDown = False, inputMouseReleased = True }
_ <- runFrame ctx dragStart ui
((respDragged, valDragged), _, _, _) <- runFrame ctx dragUp ui
assert failed (valDragged > 25)
assert failed (respChanged respDragged)
void $ runFrame ctx release ui
-- | Verifies the composable drag-and-drop hook: hover, file, text, and bounds.
runDropTargetTest :: Context -> IORef Int -> IO ()
runDropTargetTest ctx failed = do
let inp0 = withInput 300 300
bounds = Rect 10 10 100 100
dropPoint = V2 60 60
ui = column (useDrop bounds)
dropsInp ds = inp0 {inputDrops = SA.smallArrayFromList ds}
_ <- warmup2 ctx inp0 ui
let beginInp = dropsInp [DropEvent DropBegin Nothing ""]
(tgtBegin, _, _, _) <- runFrame ctx beginInp ui
assert failed (not (dropHovered tgtBegin))
let hoverInp = dropsInp [DropEvent DropPosition (Just dropPoint) ""]
(tgtHover, _, _, _) <- runFrame ctx hoverInp ui
assert failed (dropHovered tgtHover)
assert failed (dropPosition tgtHover == Just dropPoint)
-- Payload coordinates are ignored; attribution follows the last drag position.
let dropInp =
dropsInp
[ DropEvent DropFile (Just dropPoint) "/tmp/a.txt"
, DropEvent DropText (Just dropPoint) "hello"
]
(tgtDrop, _, _, _) <- runFrame ctx dropInp ui
assert failed (dropReceived tgtDrop)
assert failed (dropFiles tgtDrop == ["/tmp/a.txt"])
assert failed (dropTexts tgtDrop == ["hello"])
-- A drop whose coordinates SDL reports as (0,0) (no final position seen)
-- still lands on the target the pointer was hovering.
let originDrop =
dropsInp
[ DropEvent DropFile (Just (V2 0 0)) "/tmp/origin.txt"
, DropEvent DropComplete Nothing ""
]
(tgtOrigin, _, _, _) <- runFrame ctx originDrop ui
assert failed (dropFiles tgtOrigin == ["/tmp/origin.txt"])
-- The completing drop clears hover state.
(tgtDone, _, _, _) <- runFrame ctx inp0 ui
assert failed (not (dropHovered tgtDone))
-- A fresh drag that moves outside the target no longer delivers to it.
_ <- runFrame ctx (dropsInp [DropEvent DropBegin Nothing ""]) ui
_ <- runFrame ctx (dropsInp [DropEvent DropPosition (Just (V2 250 250)) ""]) ui
let outInp =
dropsInp
[ DropEvent DropFile (Just (V2 250 250)) "/tmp/out.txt"
, DropEvent DropComplete Nothing ""
]
(tgtOut, _, _, _) <- runFrame ctx outInp ui
assert failed (not (dropReceived tgtOut))
assert failed (null (dropFiles tgtOut))