packages feed

nano-ui-0.1.0.0: lib/NanoUI/Frame/Input.hs

{-# LANGUAGE DataKinds #-}

module NanoUI.Frame.Input
  ( finalizeTabFocus
  , refreshHover
  , armPointerPress
  , disarmPointerPress
  , finalizePointerPress
  , finalizePointerRelease
  , finalizeTextInputFocus
  , finalizeSelectFocus
  , findTopWidgetUnderMouse
  , isInteractiveNode
  ) where

import Control.Applicative ((<|>))
import Control.Monad (unless, when)
import Data.IORef (readIORef, writeIORef)
import qualified Data.IntMap.Strict as IM
import Data.Maybe (isJust, isNothing)
import NanoUI.Context
  ( Context (..)
  , TextInputMenu (..)
  , WidgetStore (..)
  , damageWidget
  , getFocusables
  , getMenuPointerGesture
  , getStore
  , getTextInputMenu
  , intKey
  , isDisabled
  , markDirty
  , pointerBlockedByOverlay
  , setAnimationValue
  , setMenuPointerGesture
  , setStore
  , setTextInputMenu
  , startAnimation
  )
import NanoUI.Frame.Focus (filterModalFocusables, tabNext, tabNextFocusables)
import NanoUI.Frame.Hit
  ( findNodeByWidgetId
  , modalTreeOpen
  , nodeClippedHit
  , nodeInteractionHit
  , overlayHitAllowed
  , scrollHitRect
  )
import NanoUI.Frame.Redraw (probeHotId)
import NanoUI.Frame.Select (findSelectUnderMouse, overlayMenuOwnerAt)
import NanoUI.Frame.Spans (widgetHitRect)
import NanoUI.Frame.TextEdit (collapseTextFieldSelection)
import NanoUI.Id (WidgetId (..), hashWidgetId)
import NanoUI.Input
  ( Input (..)
  , Key (..)
  , inputKeysElem
  , inputModifiers
  , inputMousePos
  , inputMousePressed
  , inputMouseReleased
  , inputMouseRightPressed
  , inputMouseRightReleased
  , modShift
  )
import NanoUI.Layout.Arena
  ( NodeIdx
  , NodeType (..)
  , findNodeM
  , foldNodesM
  , getNodeType
  , getParent
  , getRect
  , getStyleIdx
  , getWidgetId
  )
import NanoUI.Monad (whenM)
import NanoUI.Types (DamageBounds (..), Rect (..), V2 (..), defaultDamageSlop, rectContains, rectH, rectW)
import NanoUI.WidgetText (buttonVisualStyle, isMenuBarStyle, isMenuItemStyle, isTabButtonStyle)

finalizeTabFocus :: Context -> Input -> IO ()
finalizeTabFocus ctx inp =
  when (inputKeysElem KeyTab (inputKeys inp)) $ do
    open <- modalTreeOpen ctx
    let shift = modShift (inputModifiers inp)
    cur <- readIORef (ctxFocusId ctx)
    next <-
      if not open
        then tabNextFocusables ctx cur shift
        else do
          focusables <- getFocusables ctx
          ids <- filterModalFocusables ctx (filter (/= WidgetId 0) focusables)
          pure (tabNext cur ids shift)
    when (hashWidgetId next /= 0) $ do
      -- Keyboard focus shows its ring until the next pointer press. Focus that
      -- stays put (a lone focusable) changes no focus rect, so damage it here.
      wasVisible <- readIORef (ctxFocusVisible ctx)
      when (next == cur && not wasVisible) $
        damageWidget ctx next (DamageInflated defaultDamageSlop)
      writeIORef (ctxFocusId ctx) next
      writeIORef (ctxFocusVisible ctx) True
      markDirty ctx

-- Flat menu buttons never animate: their hover highlight snaps on and off.
isMenuButtonWidget :: Context -> WidgetId -> IO Bool
isMenuButtonWidget ctx wid
  | hashWidgetId wid == 0 = pure False
  | otherwise =
      findNodeByWidgetId ctx wid >>= \case
        Nothing -> pure False
        Just idx -> do
          nt <- getNodeType (ctxNodeArena ctx) idx
          if nt /= NodeButton
            then pure False
            else do
              si <- getStyleIdx (ctxNodeArena ctx) idx
              pure (isMenuItemStyle si || isMenuBarStyle si)

refreshHover :: Context -> Input -> IO ()
refreshHover ctx inp = do
  prevHot <- readIORef (ctxLastHotId ctx)
  newHot <- probeHotId ctx (inputMousePos inp)
  writeIORef (ctxHotId ctx) newHot
  writeIORef (ctxLastHotId ctx) newHot
  when (prevHot /= newHot) $ do
    prevMenu <- isMenuButtonWidget ctx prevHot
    newMenu <- isMenuButtonWidget ctx newHot
    when (hashWidgetId prevHot /= 0 && not prevMenu) $ startAnimation ctx prevHot 1 0 0.12
    when (hashWidgetId newHot /= 0 && not newMenu) $ startAnimation ctx newHot 0 1 0.12

-- | Remember where a press landed, before the UI builds: widgets resolve their
-- click against this point, so a release that drifted onto a neighbour fires
-- nowhere. Runs every frame; 'disarmPointerPress' clears it once the button
-- comes up and the frame has consumed the release.
armPointerPress :: Context -> Input -> IO ()
armPointerPress ctx inp = do
  let here = Just (inputMousePos inp)
  when (inputMousePressed inp) $ writeIORef (ctxPressPos ctx) here
  when (inputMouseRightPressed inp) $ writeIORef (ctxRightPressPos ctx) here

disarmPointerPress :: Context -> Input -> IO ()
disarmPointerPress ctx inp = do
  when (inputMouseReleased inp) $ writeIORef (ctxPressPos ctx) Nothing
  when (inputMouseRightReleased inp) $ writeIORef (ctxRightPressPos ctx) Nothing

-- Same walk as refreshHover: later nodes paint first, earlier widget hits win.
finalizePointerPress :: Context -> Input -> IO ()
finalizePointerPress ctx inp =
  when (inputMousePressed inp) $ do
    -- A pointer press hides the keyboard focus ring.
    writeIORef (ctxFocusVisible ctx) False
    gesture <- getMenuPointerGesture ctx
    if gesture
      then writeIORef (ctxActiveId ctx) (WidgetId 0)
      else do
        let mouse = inputMousePos inp
        mMenu <- overlayMenuOwnerAt ctx mouse
        case mMenu of
          Just _ -> do
            setMenuPointerGesture ctx True
            writeIORef (ctxActiveId ctx) (WidgetId 0)
          Nothing -> do
            mWid <- findTopWidgetUnderMouse ctx mouse isInteractiveNode
            case mWid of
              Nothing -> pure ()
              Just wid ->
                whenM (not <$> isDisabled ctx wid) $
                  writeIORef (ctxActiveId ctx) wid

-- | The widget of a wanted type under @mouse@ that hover would pick: the
-- first in arena order, since earlier siblings paint over later ones.
findTopWidgetUnderMouse :: Context -> V2 -> (NodeType -> Bool) -> IO (Maybe WidgetId)
findTopWidgetUnderMouse ctx mouse wanted = do
  let na = ctxNodeArena ctx
  mIdx <-
    findNodeM na $ \idx -> do
      nt <- getNodeType na idx
      if not (wanted nt)
        then pure False
        else do
          (x, y, w, h) <- getRect na idx
          rect <- widgetHitRect ctx nt idx x y w h
          if rectW rect > 0 && rectH rect > 0
            then do
              hit <- nodeClippedHit ctx idx rect mouse
              if hit then overlayHitAllowed ctx idx mouse else pure False
            else pure False
  traverse (getWidgetId na) mIdx

isInteractiveNode :: NodeType -> Bool
isInteractiveNode nt =
  nt == NodeButton
    || nt == NodeCheckbox
    || nt == NodeRadio
    || nt == NodeTree
    || nt == NodeSlider
    || nt == NodeSelect
    || nt == NodeColorPicker
    || nt == NodeTextInput
    || nt == NodeTextArea
    || nt == NodeDrawing

-- Clicks are finalized against solved layout rects; widgets only track press state.
-- Radio/tab selection is written here. Clickable widgets use the same solved
-- hit; if in-UI prev-rect tests missed, ctxClickedId fires next frame.
finalizePointerRelease :: Context -> Input -> IO ()
finalizePointerRelease ctx inp =
  when (inputMouseReleased inp) $ do
    let mouse = inputMousePos inp
        na = ctxNodeArena ctx
    gesture <- getMenuPointerGesture ctx
    mMenu <- overlayMenuOwnerAt ctx mouse
    if gesture || isJust mMenu
      then do
        writeIORef (ctxActiveId ctx) (WidgetId 0)
        setMenuPointerGesture ctx False
      else do
        active <- readIORef (ctxActiveId ctx)
        when (hashWidgetId active /= 0) $ do
          releasedClicked <- readIORef (ctxReleaseClickedId ctx)
          -- Every node carrying the active id takes the release; the first
          -- one decides whether the pointer came up over the widget.
          let release over idx = do
                wid <- getWidgetId na idx
                if wid /= active
                  then pure over
                  else do
                    nt <- getNodeType na idx
                    (x, y, w, h) <- getRect na idx
                    visible <- nodeClippedHit ctx idx (Rect x y w h) mouse
                    when visible $ do
                      case nt of
                        NodeRadio -> getStyleIdx na idx >>= setParentSelection ctx idx
                        NodeButton -> do
                          packed <- getStyleIdx na idx
                          when (isTabButtonStyle packed) $
                            setParentSelection ctx idx (buttonVisualStyle packed `div` 4)
                        _ -> pure ()
                      when (postsLayoutClick nt && releasedClicked /= active) $ do
                        uiHit <- inUiClickHit ctx active mouse
                        unless uiHit $ writeIORef (ctxClickedId ctx) active
                    pure (over <|> Just visible)
          releasedOver <- foldNodesM na release Nothing
          writeIORef (ctxActiveId ctx) (WidgetId 0)
          when (releasedOver == Just True) $
            setAnimationValue ctx active 1

-- Radio options and tab buttons keep their selection on the parent group.
setParentSelection :: Context -> NodeIdx -> Int -> IO ()
setParentSelection ctx idx selected = do
  parent <- getParent (ctxNodeArena ctx) idx
  when (parent >= 0) $ do
    store <- getStore ctx
    groupWid <- getWidgetId (ctxNodeArena ctx) parent
    setStore ctx store {storeInt = IM.insert (intKey groupWid) selected (storeInt store)}

postsLayoutClick :: NodeType -> Bool
postsLayoutClick nt =
  nt == NodeButton || nt == NodeTree || nt == NodeSelect || nt == NodeCheckbox

inUiClickHit :: Context -> WidgetId -> V2 -> IO Bool
inUiClickHit ctx wid mouse = do
  disabled <- isDisabled ctx wid
  blocked <- pointerBlockedByOverlay ctx mouse
  if disabled || blocked
    then pure False
    else do
      mrect <- scrollHitRect ctx wid
      case mrect of
        Nothing -> pure False
        Just r ->
          findNodeByWidgetId ctx wid >>= \case
            Nothing -> pure (rectContains r mouse)
            Just idx -> nodeInteractionHit ctx idx r mouse

-- Focus text inputs using solved layout rects so the caret appears on first press.
-- A press on an open dropdown overlay (select menu or a focused combo's
-- suggestions) must not clear focus first: the combo's dropdown is visible
-- exactly while its field holds focus, and the select finalizers below need
-- the owner still resolvable to route the pick.
finalizeTextInputFocus :: Context -> Input -> IO ()
finalizeTextInputFocus ctx inp =
  when (inputMousePressed inp) $ do
    mMenu <- getTextInputMenu ctx
    let mouse = inputMousePos inp
    mDrop <- overlayMenuOwnerAt ctx mouse
    let onMenu = maybe False (\menu -> rectContains (textInputMenuRect menu) mouse) mMenu
    when (not onMenu && isNothing mDrop) $ do
      prevFocus <- readIORef (ctxFocusId ctx)
      mFocused <- findTextInputUnderMouse ctx mouse
      case mFocused of
        Nothing -> do
          when (prevFocus /= WidgetId 0) $ markDirty ctx
          collapseTextFieldSelection ctx prevFocus
          writeIORef (ctxFocusId ctx) (WidgetId 0)
          setTextInputMenu ctx Nothing
        Just wid -> do
          writeIORef (ctxFocusId ctx) wid
          when (prevFocus /= wid) $ markDirty ctx

finalizeSelectFocus :: Context -> Input -> IO ()
finalizeSelectFocus ctx inp =
  when (inputMousePressed inp) $ do
    let mouse = inputMousePos inp
    mOpen <- findSelectUnderMouse ctx mouse
    -- A press on a select's own field that just closed its dropdown leaves no
    -- open dropdown under the pointer, but the select keeps focus all the same.
    mWid <- maybe (findTopWidgetUnderMouse ctx mouse (== NodeSelect)) (pure . Just) mOpen
    case mWid of
      Nothing -> pure ()
      Just wid ->
        whenM (not <$> isDisabled ctx wid) $ do
          prev <- readIORef (ctxFocusId ctx)
          writeIORef (ctxFocusId ctx) wid
          when (prev /= wid) $ markDirty ctx

findTextInputUnderMouse :: Context -> V2 -> IO (Maybe WidgetId)
findTextInputUnderMouse ctx mouse = do
  let na = ctxNodeArena ctx
  mIdx <-
    findNodeM na $ \idx -> do
      nt <- getNodeType na idx
      if nt /= NodeTextInput && nt /= NodeTextArea
        then pure False
        else do
          (x, y, w, h) <- getRect na idx
          rect <- widgetHitRect ctx nt idx x y w h
          hit <- nodeClippedHit ctx idx rect mouse
          if hit then overlayHitAllowed ctx idx mouse else pure False
  mWid <- traverse (getWidgetId na) mIdx
  -- A press on a disabled field lands on nothing: it takes focus from
  -- whichever field had it and gives it to none.
  case mWid of
    Just wid -> do
      disabled <- isDisabled ctx wid
      pure (if disabled then Nothing else Just wid)
    Nothing -> pure Nothing