packages feed

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

{-# LANGUAGE DataKinds #-}

module NanoUI.Frame.Cursor
  ( UiCursorKind (..)
  , uiCursorKind
  , pointerCursorWanted
  , cursorKindIs
  ) where

import Data.IORef (readIORef)
import qualified Data.IntMap.Strict as IM
import Data.Maybe (fromMaybe, isJust)
import NanoUI.Context
  ( Context (..)
  , CustomDrawContext (..)
  , WidgetStore (..)
  , getFocusId
  , getHotId
  , getScrollDrag
  , getStore
  , intKey
  , isDisabled
  , isSelectOpen
  , lookupCustomCursor
  , widgetTheme
  , getsInteraction
  , InteractionState (..)
  )
import NanoUI.Font (FontMetrics, sliderHandleSlack, sliderTrackBounds)
import NanoUI.Frame.Hit (findNodeByWidgetId, nodePointVisible, scrollHitRect)
import NanoUI.Frame.Scroll (ScrollBarLayout (..), scrollBarsFor)
import NanoUI.Frame.Select (overlayMenuOwnerAt, selectDropRect)
import NanoUI.Frame.TextArea.Content (isMouseOnTextAreaScrollBarAt)
import NanoUI.Frame.TextEdit.Menu (textEditMenuCursorKind, textFieldWidgetAtMouse)
import NanoUI.Frame.TextInput (nodeTextFieldGeom, searchClearHit)
import NanoUI.Frame.Window (windowResizeCursorKind)
import NanoUI.Id (WidgetId (..), hashWidgetId)
import NanoUI.Input
  ( Input (..)
  , UiCursorKind (..)
  , grabDragKind
  , grabHoverKind
  , inputMouseDown
  , inputMousePos
  )
import NanoUI.Layout.Arena
  ( DirTag (..)
  , NodeIdx
  , NodeType (..)
  , findChildM
  , findNodeM
  , getDirection
  , getNodeType
  , getOptions
  , getParent
  , getRect
  , getStyleIdx
  , getWidgetId
  , isScrollNode
  )
import NanoUI.Types (Rect (..), V2 (..), rectContains)
import NanoUI.WidgetText (numericStepperRects, textInputNumericMode)
import NanoUI.WidgetText (isTableHeaderStyle)

uiCursorKind :: Context -> Input -> IO UiCursorKind
uiCursorKind ctx inp = do
  -- The first query with an opinion wins; later ones do not run.
  mKind <-
    foldr
      (\query rest -> query >>= maybe rest (pure . Just))
      (pure Nothing)
      [ textEditMenuCursorKind ctx inp
      , selectDropdownCursorKind ctx inp
      , windowResizeCursorKind ctx inp
      , tableColResizeCursorKind ctx inp
      , scrollThumbCursorKind ctx inp
      , textFieldHoverCursorKind ctx inp
      ]
  case mKind of
    Just k -> pure k
    Nothing -> do
      let mouse = inputMousePos inp
      active <- readIORef (ctxActiveId ctx)
      activeKind <- cursorKindAt ctx active mouse inp
      if activeKind /= UiCursorDefault
        then pure activeKind
        else do
          hot <- getHotId ctx
          cursorKindAt ctx hot mouse inp

selectDropdownCursorKind :: Context -> Input -> IO (Maybe UiCursorKind)
selectDropdownCursorKind ctx inp = do
  let mouse = inputMousePos inp
      na = ctxNodeArena ctx
  dropPress <- getsInteraction ctx isSelectDropPress
  store <- getStore ctx
  mSel <-
    findNodeM na $ \idx -> do
      nt <- getNodeType na idx
      if nt /= NodeSelect
        then pure False
        else do
          wid <- getWidgetId na idx
          opts <- getOptions na idx
          (x, y, w, h) <- getRect na idx
          let dropRect = selectDropRect x y w h (length opts)
          pure ((isSelectOpen store (intKey wid) || dropPress) && rectContains dropRect mouse)
  if isJust mSel
    then pure (Just UiCursorPointer)
    else
      -- A focused combo's dropdown (visible while its field holds focus) is
      -- not a select: pointer over its menu like the select's. The text-input
      -- menu case inside overlayMenuOwnerAt is unreachable here, since
      -- textEditMenuCursorKind runs first in uiCursorKind.
      (UiCursorPointer <$) <$> overlayMenuOwnerAt ctx mouse

scrollThumbCursorKind :: Context -> Input -> IO (Maybe UiCursorKind)
scrollThumbCursorKind ctx inp = do
  mDrag <- getScrollDrag ctx
  if inputMouseDown inp && isJust mDrag
    then pure (Just UiCursorGrabbing)
    else do
      onThumb <- scrollThumbHit ctx (inputMousePos inp)
      pure (if onThumb then Just (grabHoverKind True inp) else Nothing)

-- Field well, not the label. Independent of focus and hot. A search field's
-- clear button raises the pointer cursor; everywhere else over a field is text.
textFieldHoverCursorKind :: Context -> Input -> IO (Maybe UiCursorKind)
textFieldHoverCursorKind ctx inp = do
  let mouse = inputMousePos inp
  mWid <- textFieldWidgetAtMouse ctx mouse
  case mWid of
    Nothing -> pure Nothing
    Just wid -> do
      onClear <- searchClearHit ctx wid mouse
      onStepper <- numericStepperHit ctx wid mouse
      pure (Just (if onClear || onStepper then UiCursorPointer else UiCursorText))

-- | Whether the pointer is over a numeric field's stepper, which takes the
-- pointer cursor rather than the text cursor.
numericStepperHit :: Context -> WidgetId -> V2 -> IO Bool
numericStepperHit ctx wid mouse =
  findNodeByWidgetId ctx wid >>= \case
    Nothing -> pure False
    Just idx -> do
      si <- getStyleIdx (ctxNodeArena ctx) idx
      if not (textInputNumericMode si)
        then pure False
        else do
          (x, y, w, h) <- getRect (ctxNodeArena ctx) idx
          let (up, down) = numericStepperRects x y w h
          pure (rectContains up mouse || rectContains down mouse)

scrollThumbHit :: Context -> V2 -> IO Bool
scrollThumbHit ctx mouse =
  fmap isJust . findNodeM na $ \idx -> do
    nt <- getNodeType na idx
    if nt /= NodeTextArea && not (isScrollNode nt)
      then pure False
      else do
        wid <- getWidgetId na idx
        any (\(_, layout, _) -> rectContains (sbThumb layout) mouse) <$> scrollBarsFor ctx idx wid
  where
    na = ctxNodeArena ctx

cursorKindAt :: Context -> WidgetId -> V2 -> Input -> IO UiCursorKind
cursorKindAt ctx wid mouse inp
  | hashWidgetId wid == 0 = pure UiCursorDefault
  | otherwise = do
      disabled <- isDisabled ctx wid
      if disabled
        then pure UiCursorDefault
        else do
          mCursorFn <- lookupCustomCursor ctx wid
          case mCursorFn of
            Just cursorFn -> do
              visible <- widgetVisibleAt ctx wid mouse
              if not visible
                then pure UiCursorDefault
                else do
                  active <- readIORef (ctxActiveId ctx)
                  hot <- getHotId ctx
                  focused <- (== wid) <$> getFocusId ctx
                  theme <- widgetTheme ctx wid
                  let cdc =
                        CustomDrawContext
                          { cdcHovered = hot == wid
                          , cdcPressed = active == wid
                          , cdcFocused = focused
                          , cdcActive = active == wid
                          , cdcDisabled = disabled
                          , cdcTheme = theme
                          , cdcFont = ctxFontMetrics ctx
                          }
                  pure (cursorFn cdc)
            Nothing -> do
              -- Resolve the node through the arena's id index rather than
              -- building a type table of every widget for two lookups.
              mNodeType <- findNodeByWidgetId ctx wid >>= traverse (getNodeType (ctxNodeArena ctx))
              case mNodeType of
                Just NodeButton -> widgetPointerCursor ctx wid mouse
                Just NodeCheckbox -> widgetPointerCursor ctx wid mouse
                Just NodeRadio -> widgetPointerCursor ctx wid mouse
                Just NodeTree -> widgetPointerCursor ctx wid mouse
                Just NodeSelect -> selectCursorKind ctx wid mouse
                Just NodeColorPicker -> pure UiCursorPointer
                Just NodeTextInput -> textInputCursorKind ctx wid mouse
                Just NodeTextArea -> textAreaCursorKind ctx wid mouse
                Just NodeSlider -> sliderCursorKind ctx wid mouse inp
                _ -> pure UiCursorDefault

selectCursorKind :: Context -> WidgetId -> V2 -> IO UiCursorKind
selectCursorKind ctx wid mouse = do
  visible <- widgetVisibleAt ctx wid mouse
  if not visible
    then pure UiCursorDefault
    else do
      mrect <- scrollHitRect ctx wid
      pure (if maybe False (`rectContains` mouse) mrect then UiCursorPointer else UiCursorDefault)

widgetVisibleAt :: Context -> WidgetId -> V2 -> IO Bool
widgetVisibleAt ctx wid mouse = do
  mIdx <- findNodeByWidgetId ctx wid
  case mIdx of
    Nothing -> pure False
    Just idx -> nodePointVisible ctx idx mouse

widgetPointerCursor :: Context -> WidgetId -> V2 -> IO UiCursorKind
widgetPointerCursor ctx wid mouse = do
  visible <- widgetVisibleAt ctx wid mouse
  pure (if visible then UiCursorPointer else UiCursorDefault)

sliderCursorKind :: Context -> WidgetId -> V2 -> Input -> IO UiCursorKind
sliderCursorKind ctx wid mouse inp = do
  active <- readIORef (ctxActiveId ctx)
  if active == wid && inputMouseDown inp
    then pure UiCursorGrabbing
    else do
      visible <- widgetVisibleAt ctx wid mouse
      if not visible
        then pure UiCursorDefault
        else do
          mrect <- scrollHitRect ctx wid
          pure $
            case mrect of
              Nothing -> UiCursorDefault
              Just (Rect x y w h) ->
                let Rect tx ty tw th = sliderTrackBounds x y w h
                    hitRect = Rect tx (ty - sliderHandleSlack) tw (th + 2 * sliderHandleSlack)
                 in grabDragKind (rectContains hitRect mouse) False inp

textInputCursorKind :: Context -> WidgetId -> V2 -> IO UiCursorKind
textInputCursorKind ctx wid mouse = do
  visible <- widgetVisibleAt ctx wid mouse
  if not visible
    then pure UiCursorDefault
    else do
      mIdx <- findNodeByWidgetId ctx wid
      mrect <- scrollHitRect ctx wid
      case (mIdx, mrect) of
        (Just idx, Just (Rect x y w h)) -> do
          (field, _) <- nodeTextFieldGeom ctx idx x y w h
          onStepper <- numericStepperHit ctx wid mouse
          pure $
            if onStepper
              then UiCursorPointer
              else if rectContains field mouse then UiCursorText else UiCursorDefault
        _ -> pure UiCursorDefault

textAreaCursorKind :: Context -> WidgetId -> V2 -> IO UiCursorKind
textAreaCursorKind ctx wid mouse = do
  mIdx <- findNodeByWidgetId ctx wid
  case mIdx of
    Nothing -> pure UiCursorDefault
    Just idx -> do
      onScroll <- isMouseOnTextAreaScrollBarAt ctx idx mouse
      if onScroll
        then pure UiCursorDefault
        else
          textFieldCursorKind ctx wid mouse $ \_ x y w h ->
            Rect x y w h

textFieldCursorKind ::
  Context ->
  WidgetId ->
  V2 ->
  (FontMetrics -> Float -> Float -> Float -> Float -> Rect) ->
  IO UiCursorKind
textFieldCursorKind ctx wid mouse fieldAt = do
  visible <- widgetVisibleAt ctx wid mouse
  if not visible
    then pure UiCursorDefault
    else do
      mrect <- scrollHitRect ctx wid
      pure $
        case mrect of
          Just (Rect x y w h)
            | rectContains (fieldAt (ctxFontMetrics ctx) x y w h) mouse -> UiCursorText
          _ -> UiCursorDefault

tableColResizeCursorKind :: Context -> Input -> IO (Maybe UiCursorKind)
tableColResizeCursorKind ctx inp = do
  store <- getStore ctx
  let dragging = any (\n -> n <= -1000 && n > -2000) (IM.elems (storeInt store))
      na = ctxNodeArena ctx
      V2 mx my = inputMousePos inp
  if inputMouseDown inp && dragging
    then pure (Just UiCursorEwResize)
    else do
      mEdge <-
        findNodeM na $ \idx -> do
          nt <- getNodeType na idx
          if nt /= NodeButton
            then pure False
            else do
              si <- getStyleIdx na idx
              if not (isTableHeaderStyle si)
                then pure False
                else do
                  (x, y, w, h) <- getRect na idx
                  -- The resize cursor spans the whole column height
                  -- (header plus body cells down to the body
                  -- scroller's bottom edge), matching the drag grab
                  -- zone: tableBodyScrollerBottom locates the same
                  -- body scroller whose rect the grab zone anchors
                  -- on (its prev-frame value, readable at build
                  -- time), so the two zones cannot disagree.
                  yBot <- fromMaybe (y + h) <$> tableBodyScrollerBottom ctx idx
                  pure (my >= y && my <= yBot && abs (mx - (x + w)) <= 4 && w > 0 && h > 0)
      pure (UiCursorEwResize <$ mEdge)

-- | Bottom edge of a table's body scroller, located structurally from one
-- of its header buttons: walk up to the first ancestor that has a direct
-- Column-direction scroll-container child (the pane column built by
-- tableSplitPanes) and take that child's rect bottom. Runs post-solve, so
-- the rect is current-frame. Nothing when no such scroller exists (the
-- caller falls back to the header button's own bottom).
tableBodyScrollerBottom :: Context -> NodeIdx -> IO (Maybe Float)
tableBodyScrollerBottom ctx = goUp
  where
    na = ctxNodeArena ctx
    goUp i = do
      p <- getParent na i
      if p < 0
        then pure Nothing
        else do
          mScroller <-
            findChildM na p $ \c -> do
              nt <- getNodeType na c
              if isScrollNode nt
                then (== DirColumn) <$> getDirection na c
                else pure False
          case mScroller of
            Just sc -> do
              (_, sy, _, sh) <- getRect na sc
              pure (Just (sy + sh))
            Nothing -> goUp p

pointerCursorWanted :: Context -> Input -> IO Bool
pointerCursorWanted ctx inp = cursorKindIs ctx inp UiCursorPointer

cursorKindIs :: Context -> Input -> UiCursorKind -> IO Bool
cursorKindIs ctx inp want = (== want) <$> uiCursorKind ctx inp