nano-ui-0.1.0.0: lib/NanoUI/Widgets/Behavior.hs
-- | Interaction hooks shared by widgets: 1D drags, drag reordering, arrow-key
-- navigation, click-outside and Escape dismissal, and the keyboard focus
-- check. Their state lives in the widget store.
module NanoUI.Widgets.Behavior
( DragAxis (..)
, keyedDragHeld
, useDrag1D
, useReorder
, useKeyNav
, keyboardFocused
, keyActivated
, KeyNav (..)
, useDismissable
, dragThresholdPx
)
where
import Control.Monad (when)
import Data.Hashable (Hashable, hash)
import Data.IORef (readIORef)
import Data.List (find)
import Effectful (Eff, type (:>))
import qualified Data.IntMap.Strict as IM
import NanoUI.Context
( Context (..)
, getFocusId
, getStore
, intKey
, isDisabled
, markEscapeConsumed
, getMenuPointerGesture
, pointerBlockedByModal
, Slot (..)
, slotKey
, modifyStore
)
import NanoUI.Id (WidgetId (..), enterKeyed, hashWidgetId, idContextWidgetId)
import NanoUI.Input
( Input (..)
, Key (..)
, inputChars
, inputKeys
, inputKeysElem
, inputKeysNull
, inputMouseDown
, inputMousePos
, inputMousePressed
, inputMouseReleased
, inputMouseRightPressed
)
import NanoUI.Monad (Ui, askContext, askInput, nextId, uiIO)
import NanoUI.Store (WidgetStore (..))
import NanoUI.Types (Rect (..), clamp01, rectHit, v2X, v2Y)
import qualified Data.Text as T
-- | Pointer slop in pixels before a held press counts as a drag.
dragThresholdPx :: Float
dragThresholdPx = 8
data DragAxis = DragAxisX | DragAxisY
deriving (Eq, Show)
-- | True when a prior keyed useDrag1D on this path is still held.
-- Peeks the keyed first-id without enterKeyed bumping parent siblingId.
keyedDragHeld :: (Hashable k, Ui :> es) => k -> Eff es Bool
keyedDragHeld k = do
ctx <- askContext
uiIO $ do
old <- readIORef (ctxIdContext ctx)
let wid = idContextWidgetId (snd (enterKeyed (fromIntegral (hash k)) old))
dragK = slotKey SlotDrag (intKey wid)
store <- getStore ctx
pure (IM.findWithDefault 0 dragK (storeInt store) /= 0)
-- | Clamped 1D drag. Maps pointer position on 'track' into [lo, hi].
useDrag1D ::
(Ui :> es) =>
DragAxis ->
Float ->
Float ->
Float ->
Rect ->
Eff es (Float, Bool)
useDrag1D axis lo hi current track = do
wid <- nextId
ctx <- askContext
inp <- askInput
let key = intKey wid
dragK = slotKey SlotDrag key
trackLen = case axis of
DragAxisX -> rectW track
DragAxisY -> rectH track
origin = case axis of
DragAxisX -> rectX track
DragAxisY -> rectY track
mouse = case axis of
DragAxisX -> v2X (inputMousePos inp)
DragAxisY -> v2Y (inputMousePos inp)
down = inputMouseDown inp
store <- uiIO (getStore ctx)
gesture <- uiIO (getMenuPointerGesture ctx)
let active0 = IM.findWithDefault 0 dragK (storeInt store) /= 0
hit = rectHit track (inputMousePos inp) && not gesture
active = down && not gesture && (active0 || hit)
frac =
if trackLen <= 0
then 0
else clamp01 ((mouse - origin) / trackLen)
next =
if active
then lo + frac * (hi - lo)
else current
when (active /= active0) $
uiIO $
modifyStore ctx $ \st ->
st
{ storeInt =
if active
then IM.insert dragK 1 (storeInt st)
else IM.delete dragK (storeInt st)
}
pure (next, active)
-- | Drag-and-drop reorder of a visible index list.
useReorder ::
(Ui :> es) =>
[Int] ->
[(Int, Rect)] ->
Eff es ([Int], Maybe Int)
useReorder order items = do
wid <- nextId
ctx <- askContext
inp <- askInput
let key = intKey wid
dragK = slotKey SlotDrag key
mouse = inputMousePos inp
down = inputMouseDown inp
press = inputMousePressed inp
release = inputMouseReleased inp
hit =
find
(\(_, r) -> rectHit r mouse)
items
store <- uiIO (getStore ctx)
let from0 = IM.findWithDefault (-1) dragK (storeInt store)
startX = IM.findWithDefault 0 (slotKey SlotDragW key) (storeFloat store)
dragging = if press then maybe (-1) fst hit else from0
nextDrag =
if release || not down
then -1
else dragging
-- Resolve the drop using the held source before clearing it on release.
moved =
not press && dragging >= 0 && abs (v2X mouse - startX) > dragThresholdPx
dropTo = if moved then fmap fst hit else Nothing
nextOrder =
case dropTo of
Just toCol | release -> moveItem order dragging toCol
_ -> order
when (nextDrag /= from0 || (press && nextDrag >= 0)) $
uiIO $
modifyStore ctx $ \st ->
st
{ storeInt = IM.insert dragK nextDrag (storeInt st)
, storeFloat =
IM.insert
(slotKey SlotDragW key)
(if press then v2X mouse else startX)
(storeFloat st)
}
pure (nextOrder, if nextDrag >= 0 then Just nextDrag else Nothing)
moveItem :: [Int] -> Int -> Int -> [Int]
moveItem xs from to
| from == to = xs
| otherwise =
let without = filter (/= from) xs
(pre, post) = break (== to) without
in pre ++ from : post
data KeyNav = KeyNav
{ knUp :: !Bool
, knDown :: !Bool
, knLeft :: !Bool
, knRight :: !Bool
, knEnter :: !Bool
, knSpace :: !Bool
}
deriving (Eq, Show)
-- | Focus alone does not grant keyboard input. A retained focus ID must still
-- respect disabled state and the modal currently being declared. Unfocused
-- controls avoid the store and modal checks entirely.
{-# INLINE keyboardFocused #-}
keyboardFocused :: Ui :> es => WidgetId -> Eff es Bool
keyboardFocused wid
| hashWidgetId wid == 0 = pure False
| otherwise = do
ctx <- askContext
focus <- uiIO (getFocusId ctx)
if focus /= wid
then pure False
else uiIO $ do
disabled <- isDisabled ctx wid
if disabled then pure False else not <$> pointerBlockedByModal ctx
-- | Arrow / Enter / Space while 'wid' is focused and eligible for input.
useKeyNav :: (Ui :> es) => WidgetId -> Eff es KeyNav
useKeyNav wid = do
inp <- askInput
let keys = inputKeys inp
none = KeyNav False False False False False False
if hashWidgetId wid == 0 || (inputKeysNull keys && T.null (inputChars inp))
then pure none
else do
eligible <- keyboardFocused wid
if not eligible
then pure none
else pure KeyNav
{ knUp = inputKeysElem KeyUp keys
, knDown = inputKeysElem KeyDown keys
, knLeft = inputKeysElem KeyLeft keys
, knRight = inputKeysElem KeyRight keys
, knEnter = inputKeysElem KeyEnter keys
, knSpace = T.any (== ' ') (inputChars inp)
}
-- | True when Enter or Space was pressed while @wid@ holds focus. Buttons,
-- checkboxes, and toggle switches treat this as a click.
{-# INLINE keyActivated #-}
keyActivated :: (Ui :> es) => WidgetId -> Eff es Bool
keyActivated wid = do
nav <- useKeyNav wid
pure (knEnter nav || knSpace nav)
-- | Escape and click-outside-rect dismiss. Consumes Escape when it fires.
useDismissable :: (Ui :> es) => Rect -> Eff es Bool
useDismissable panel = do
ctx <- askContext
inp <- askInput
let mouse = inputMousePos inp
inside = rectHit panel mouse
esc = inputKeysElem KeyEscape (inputKeys inp)
backdrop = (inputMousePressed inp || inputMouseRightPressed inp) && not inside
dismissed = esc || backdrop
when esc $ uiIO (markEscapeConsumed ctx)
pure dismissed