packages feed

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

{-# LANGUAGE DataKinds #-}

-- | Floating window input: dragging by the title bar, edge and inner-east
-- resizing, the resize cursor, and persisting window placement.
module NanoUI.Frame.Window
  ( lookupWindowPos
  , lookupWindowSize
  , persistWindowPositions
  , updateWindowDrag
  , updateWindowResize
  , WindowResizeEdge (..)
  , windowResizeCursorKind
  ) where

import Control.Monad (when)
import qualified Data.IntMap.Strict as IM
import Data.Maybe (fromMaybe, isJust)
import NanoUI.Context
  ( Context (..)
  , WidgetStore (..)
  , WindowResizeDrag (..)
  , WindowResizeEdge (..)
  , damageWidget
  , getStore
  , getWindowDrag
  , getWindowResize
  , intKey
  , markDirty
  , setStore
  , slotKey
  , Slot (..)
  , InteractionState (..)
  , modifyInteraction
  )
import NanoUI.Font (ScrollBarSlot (..))
import NanoUI.Frame.Hit (findNodeByWidgetId, nodeInSubtree, topmostOverlayAtMouse)
import NanoUI.Frame.Input (findTopWidgetUnderMouse, isInteractiveNode)
import NanoUI.Frame.Redraw (probeHotId)
import NanoUI.Frame.Scroll.Geometry (scrollChromeLane)
import NanoUI.Id (WidgetId (..), hashWidgetId)
import NanoUI.Input (Input (..), UiCursorKind (..), inputMouseDown, inputMousePos, inputMousePressed)
import NanoUI.Layout.Arena
  ( NodeIdx
  , NodeType (..)
  , findChildM
  , findNodeRevM
  , foldNodesM
  , getDirection
  , getFirstChild
  , getMinMax
  , getNextSibling
  , getNodeType
  , getNodeValue
  , getPadding
  , getRect
  , getWidgetId
  )
import NanoUI.Layout.Solve (placeWindowNode, scrollBarSlotOf)
import NanoUI.Style (Padding (..))
import NanoUI.Types (DamageBounds (..), Rect (..), V2 (..), haloDamageSlop, rectContains, rectInflate)

topmostWindowAtResizeHalo :: Context -> V2 -> IO (Maybe NodeIdx)
topmostWindowAtResizeHalo ctx mouse =
  findNodeRevM (ctxNodeArena ctx) $ \idx -> do
    nt <- getNodeType (ctxNodeArena ctx) idx
    if nt /= NodeWindow
      then pure False
      else do
        (x, y, w, h) <- getRect (ctxNodeArena ctx) idx
        if w <= 0 || h <= 0
          then pure False
          else do
            let rect = Rect x y w h
            if rectContains (rectInflate windowResizeHandleFor rect) mouse
              then pure True
              else windowInnerEastResizeHit ctx idx rect mouse

windowInnerEastResizeHit :: Context -> NodeIdx -> Rect -> V2 -> IO Bool
windowInnerEastResizeHit ctx winIdx (Rect x _ w _) mouse@(V2 mx _) = do
  pad <- getPadding (ctxNodeArena ctx) winIdx
  if mx < x + w - padR pad || mx > x + w
    then pure False
    else do
      mLane <- windowBodyScrollLane ctx winIdx
      pure (not (maybe False (`rectContains` mouse) mLane))

lookupWindowPos :: Context -> WidgetId -> IO (Maybe (Float, Float))
lookupWindowPos ctx wid = do
  store <- getStore ctx
  pure (IM.lookup (intKey wid) (storePoint store))

lookupWindowSize :: Context -> WidgetId -> IO (Maybe (Float, Float))
lookupWindowSize ctx wid = do
  store <- getStore ctx
  pure (IM.lookup (slotKey SlotWinSize (intKey wid)) (storePoint store))

persistWindowPositions :: Context -> IO ()
persistWindowPositions ctx = do
  store0 <- getStore ctx
  let na = ctxNodeArena ctx
      record acc idx = do
        nt <- getNodeType na idx
        if nt /= NodeWindow
          then pure acc
          else do
            wid <- getWidgetId na idx
            (x, y, w, h) <- getRect na idx
            let k = intKey wid
                sizeKey = slotKey SlotWinSize k
                points = storePoint acc
            -- Keep an unchanged map as is, so the store comparison below
            -- short-circuits on pointer equality.
            pure $
              if IM.lookup k points == Just (x, y) && IM.lookup sizeKey points == Just (w, h)
                then acc
                else acc {storePoint = IM.insert k (x, y) (IM.insert sizeKey (w, h) points)}
  store1 <- foldNodesM na record store0
  when (store1 /= store0) $ setStore ctx store1

updateWindowDrag :: Context -> Input -> IO Bool
updateWindowDrag ctx inp = do
  resizing <- isJust <$> getWindowResize ctx
  if resizing
    then pure False
    else do
      drag <- getWindowDrag ctx
      case drag of
        Just (wid, gx, gy)
          | inputMouseDown inp -> do
              let V2 mx my = inputMousePos inp
              store <- getStore ctx
              setStore ctx (store {storePoint = IM.insert (intKey wid) (mx - gx, my - gy) (storePoint store)})
              damageWidget ctx wid (DamageInflated haloDamageSlop)
              markDirty ctx
              pure True
          | otherwise -> do
              modifyInteraction ctx (\s -> s {isWindowDrag = Nothing})
              pure False
        Nothing
          | inputMousePressed inp -> tryStartWindowDrag ctx (inputMousePos inp)
          | otherwise -> pure False

windowResizeHandleFor :: Float
windowResizeHandleFor = 12

-- Handles sit outside the window. The right pad strip also resizes beside the bar.
windowResizeEdgeAt :: Rect -> V2 -> Maybe WindowResizeEdge
windowResizeEdgeAt (Rect x y w h) (V2 mx my) =
  let s = windowResizeHandleFor
      onL = mx >= x - s && mx < x
      onR = mx > x + w && mx <= x + w + s
      onT = my >= y - s && my < y
      onB = my > y + h && my <= y + h + s
   in if not (onL || onR || onT || onB)
        then Nothing
        else
          Just $
            case (onT, onB, onL, onR) of
              (True, _, True, _) -> ResizeNW
              (True, _, _, True) -> ResizeNE
              (_, True, True, _) -> ResizeSW
              (_, True, _, True) -> ResizeSE
              (True, _, _, _) -> ResizeN
              (_, True, _, _) -> ResizeS
              (_, _, True, _) -> ResizeW
              _ -> ResizeE

innerEastCornerEdge :: Padding -> Rect -> Float -> WindowResizeEdge
innerEastCornerEdge pad (Rect _ y _ h) my =
  let s = windowResizeHandleFor
      minBand = 6
      topBand = max minBand (min s (padT pad))
      botBand = max minBand (min s (padB pad))
   in if my >= y && my < y + topBand
        then ResizeNE
        else if my > y + h - botBand && my <= y + h then ResizeSE else ResizeE

-- | Lane of the window body's scrollbar while its content overflows.
windowBodyScrollLane :: Context -> NodeIdx -> IO (Maybe Rect)
windowBodyScrollLane ctx winIdx = do
  let na = ctxNodeArena ctx
  mBody <-
    findChildM na winIdx $ \ci -> do
      nt <- getNodeType na ci
      if nt /= NodeScrollContainer
        then pure False
        else do
          slot <- scrollBarSlotOf na ci
          if slot /= ScrollBarWindow
            then pure False
            else do
              (_, _, _, h) <- getRect na ci
              pad <- getPadding na ci
              contentSize <- getNodeValue na ci
              pure (contentSize > h - padT pad - padB pad)
  traverse
    ( \ci -> do
        (x, y, w, h) <- getRect na ci
        pad <- getPadding na ci
        dir <- getDirection na ci
        pure (scrollChromeLane ScrollBarWindow dir x y w h pad)
    )
    mBody

windowInnerResizeEdgeAt :: Context -> NodeIdx -> Rect -> V2 -> IO (Maybe WindowResizeEdge)
windowInnerResizeEdgeAt ctx winIdx winRect@(Rect x y w h) mouse@(V2 mx my) = do
  hit <- windowInnerEastResizeHit ctx winIdx winRect mouse
  if hit
    then do
      pad <- getPadding (ctxNodeArena ctx) winIdx
      pure (Just (innerEastCornerEdge pad winRect my))
    else do
      let cornerW = min 16 (w / 3)
          cornerH = min 16 (h / 3)
          botH = min 6 (h / 3)
          inBotRightCorner = mx >= x + w - cornerW && mx <= x + w && my >= y + h - cornerH && my <= y + h
          inBotEdge = mx >= x && mx <= x + w && my >= y + h - botH && my <= y + h
      pure $
        if inBotRightCorner
          then Just ResizeSE
          else if inBotEdge then Just ResizeS else Nothing

windowResizeEdgeFor :: Context -> NodeIdx -> Rect -> V2 -> IO (Maybe WindowResizeEdge)
windowResizeEdgeFor ctx winIdx winRect mouse =
  case windowResizeEdgeAt winRect mouse of
    Just edge -> pure (Just edge)
    Nothing -> windowInnerResizeEdgeAt ctx winIdx winRect mouse

cursorForResizeEdge :: WindowResizeEdge -> UiCursorKind
cursorForResizeEdge = \case
  ResizeN -> UiCursorNsResize
  ResizeS -> UiCursorNsResize
  ResizeE -> UiCursorEwResize
  ResizeW -> UiCursorEwResize
  ResizeNW -> UiCursorNwseResize
  ResizeSE -> UiCursorNwseResize
  ResizeNE -> UiCursorNeswResize
  ResizeSW -> UiCursorNeswResize

resizeFromEdge :: WindowResizeDrag -> V2 -> Float -> Float -> (Float, Float, Float, Float)
resizeFromEdge wrd (V2 mx my) winW winH =
  let !dx = mx - wrdGrabX wrd
      !dy = my - wrdGrabY wrd
      !minW = max (wrdMinW wrd) 1.0
      !minH = max (wrdMinH wrd) 1.0
      !maxW = min (wrdMaxW wrd) winW
      !maxH = min (wrdMaxH wrd) winH
      !right0 = wrdStartX wrd + wrdStartW wrd
      !bottom0 = wrdStartY wrd + wrdStartH wrd
      edge = wrdEdge wrd
      !fromE = edge `elem` [ResizeE, ResizeNE, ResizeSE]
      !fromW = edge `elem` [ResizeW, ResizeNW, ResizeSW]
      !fromS = edge `elem` [ResizeS, ResizeSE, ResizeSW]
      !fromN = edge `elem` [ResizeN, ResizeNE, ResizeNW]
      !w0
        | fromE = wrdStartW wrd + dx
        | fromW = wrdStartW wrd - dx
        | otherwise = wrdStartW wrd
      !h0
        | fromS = wrdStartH wrd + dy
        | fromN = wrdStartH wrd - dy
        | otherwise = wrdStartH wrd
      !w = max minW (min maxW w0)
      !h = max minH (min maxH h0)
      !x0 = if fromW then right0 - w else wrdStartX wrd
      !y0 = if fromN then bottom0 - h else wrdStartY wrd
      !x = max 0 (min x0 (max 0 (winW - w)))
      !y = max 0 (min y0 (max 0 (winH - h)))
   in (w, h, x, y)

updateWindowResize :: Context -> Input -> Float -> Float -> IO Bool
updateWindowResize ctx inp winW winH = do
  drag <- getWindowResize ctx
  case drag of
    Just wrd
      | inputMouseDown inp -> do
          let (nw, nh, nx, ny) = resizeFromEdge wrd (inputMousePos inp) winW winH
              key = intKey (wrdWidget wrd)
          store <- getStore ctx
          setStore ctx (store {storePoint = IM.insert (slotKey SlotWinSize key) (nw, nh) (IM.insert key (nx, ny) (storePoint store))})
          relayoutWindow ctx winW winH (wrdWidget wrd) nw nh
          damageWidget ctx (wrdWidget wrd) (DamageInflated haloDamageSlop)
          markDirty ctx
          pure True
      | otherwise -> do
          modifyInteraction ctx (\s -> s {isWindowResize = Nothing})
          pure False
    Nothing
      | inputMousePressed inp -> tryStartWindowResize ctx (inputMousePos inp)
      | otherwise -> pure False

relayoutWindow :: Context -> Float -> Float -> WidgetId -> Float -> Float -> IO ()
relayoutWindow ctx winW winH wid nw nh = do
  mIdx <- findNodeByWidgetId ctx wid
  case mIdx of
    Nothing -> pure ()
    Just idx -> do
      mpos <- lookupWindowPos ctx wid
      (x, y, _, _) <- getRect (ctxNodeArena ctx) idx
      placeWindowNode (ctxNodeArena ctx) (ctxFontMetrics ctx) winW winH idx nw nh (const (fromMaybe (x, y) mpos))

-- | Resize edge under @mouse@ for the topmost window whose halo holds it,
-- unless the halo is blocked or the pointer is on the title bar or one of its
-- controls.
resizeEdgeTarget :: Context -> V2 -> IO (Maybe (NodeIdx, Rect, WindowResizeEdge))
resizeEdgeTarget ctx mouse = do
  mWin <- topmostWindowAtResizeHalo ctx mouse
  case mWin of
    Nothing -> pure Nothing
    Just idx -> do
      (x, y, w, h) <- getRect (ctxNodeArena ctx) idx
      let rect = Rect x y w h
      -- The halo covers the window interior, so find the edge first and run
      -- the hover probe and node scans only when there is one.
      mEdge <- windowResizeEdgeFor ctx idx rect mouse
      case mEdge of
        Nothing -> pure Nothing
        Just edge -> do
          mTitle <- windowTitleRect ctx idx
          if maybe False (`rectContains` mouse) mTitle
            then pure Nothing
            else do
              blocked <- resizeHaloBlocked ctx mouse idx
              overControl <- if blocked then pure False else windowTitleHasInteractive ctx idx mouse
              pure (if blocked || overControl then Nothing else Just (idx, rect, edge))

tryStartWindowResize :: Context -> V2 -> IO Bool
tryStartWindowResize ctx mouse@(V2 mx my) = do
  mTarget <- resizeEdgeTarget ctx mouse
  case mTarget of
    Nothing -> pure False
    Just (idx, Rect x y w h, edge) -> do
      wid <- getWidgetId (ctxNodeArena ctx) idx
      (minW, minH, maxW, maxH) <- getMinMax (ctxNodeArena ctx) idx
      modifyInteraction ctx $ \s ->
        s
          { isWindowResize =
              Just
                WindowResizeDrag
                  { wrdWidget = wid
                  , wrdEdge = edge
                  , wrdGrabX = mx
                  , wrdGrabY = my
                  , wrdStartX = x
                  , wrdStartY = y
                  , wrdStartW = w
                  , wrdStartH = h
                  , wrdMinW = minW
                  , wrdMinH = minH
                  , wrdMaxW = maxW
                  , wrdMaxH = maxH
                  }
          }
      markDirty ctx
      pure True

windowResizeCursorKind :: Context -> Input -> IO (Maybe UiCursorKind)
windowResizeCursorKind ctx inp = do
  mDrag <- getWindowResize ctx
  case mDrag of
    Just wrd
      | inputMouseDown inp -> pure (Just (cursorForResizeEdge (wrdEdge wrd)))
      | otherwise -> pure Nothing
    Nothing -> fmap (\(_, _, edge) -> cursorForResizeEdge edge) <$> resizeEdgeTarget ctx (inputMousePos inp)

-- Halo must not steal hits from page widgets or another window's interior.
resizeHaloBlocked :: Context -> V2 -> NodeIdx -> IO Bool
resizeHaloBlocked ctx mouse winIdx = do
  mInside <- topmostOverlayAtMouse ctx mouse
  case mInside of
    Just other | other /= winIdx -> pure True
    _ -> do
      hot <- probeHotId ctx mouse
      if hashWidgetId hot == 0
        then pure False
        else do
          mHot <- findNodeByWidgetId ctx hot
          case mHot of
            Nothing -> pure False
            Just hotIdx -> not <$> nodeInSubtree ctx hotIdx winIdx

tryStartWindowDrag :: Context -> V2 -> IO Bool
tryStartWindowDrag ctx mouse@(V2 mx my) = do
  mTop <- topmostOverlayAtMouse ctx mouse
  case mTop of
    Nothing -> pure False
    Just idx -> do
      nt <- getNodeType (ctxNodeArena ctx) idx
      mTitle <- if nt == NodeWindow then windowTitleRect ctx idx else pure Nothing
      case mTitle of
        Just title | rectContains title mouse -> do
          overClose <- windowTitleHasInteractive ctx idx mouse
          if overClose
            then pure False
            else do
              wid <- getWidgetId (ctxNodeArena ctx) idx
              (wx, wy, _, _) <- getRect (ctxNodeArena ctx) idx
              modifyInteraction ctx (\s -> s {isWindowDrag = Just (wid, mx - wx, my - wy)})
              markDirty ctx
              pure True
        _ -> pure False

-- | Title bar: the window's topmost child, stretched up to the window top.
windowTitleRect :: Context -> NodeIdx -> IO (Maybe Rect)
windowTitleRect ctx idx = do
  (_, wy, _, _) <- getRect (ctxNodeArena ctx) idx
  fc <- getFirstChild (ctxNodeArena ctx) idx
  mBest <- go fc Nothing
  pure $ case mBest of
    Nothing -> Nothing
    Just (Rect cx cy cw ch) ->
      let topY = min wy cy
       in Just (Rect cx topY cw ((cy - topY) + ch))
  where
    go ci best
      | ci < 0 = pure best
      | otherwise = do
          (x, y, w, h) <- getRect (ctxNodeArena ctx) ci
          ns <- getNextSibling (ctxNodeArena ctx) ci
          let here = Rect x y w h
          go ns $ case best of
            Just b@(Rect _ by _ _) | y >= by -> Just b
            _ -> Just here

windowTitleHasInteractive :: Context -> NodeIdx -> V2 -> IO Bool
windowTitleHasInteractive ctx idx mouse = do
  mWid <- findTopWidgetUnderMouse ctx mouse isInteractiveNode
  case mWid of
    Nothing -> pure False
    Just wid -> do
      mNode <- findNodeByWidgetId ctx wid
      maybe (pure False) (\wi -> nodeInSubtree ctx wi idx) mNode