packages feed

nano-ui-0.1.0.0: lib/NanoUI/Frame/Scroll/Geometry.hs

{-# LANGUAGE DataKinds #-}

-- | Scrollbar geometry: gutters, viewport clips, and track and thumb layout.
module NanoUI.Frame.Scroll.Geometry
  ( ScrollPolicy (..)
  , ScrollConfig (..)
  , defaultScrollConfig
  , ScrollBarLayout (..)
  , scrollContentClip
  , scrollViewportClip2D
  , scrollChromeLane
  , scrollBarLayout
  , scrollBarLayouts2D
  , scrollAxisRange
  , scrollOffsetFromThumb
  , padContentClip
  , encodeScrollConfig
  , decodeScrollConfig
  , scrollConfigNative2D
  , scrollDefault1D
  , scrollVerticalAuto
  , scrollVerticalHidden
  , scrollHorizontalHidden
  , scrollAxisGutter
  , scrollGutters2D
  , scrollChromeSuppressed
  , scrollWheelSuppressed
  , scrollLineFor
  , scrollAxisOverflows
  , scrollChromeActive
  , isScrollStyle2D
  , tagClippedSpans
  , padTextClipRect
  , borderContentClip
  ) where

import Data.Bits ((.&.), shiftL, shiftR)
import Data.Maybe (mapMaybe)
import Data.Text (Text)
import NanoUI.Font
  ( ScrollBarSlot (..)
  , scrollBarGap
  , scrollBarGeomFor
  , scrollBarGutter
  , scrollBarSideGap
  , scrollLayoutGutter
  )
import NanoUI.Types (Color, Rect (..), V2 (..), rectH, rectIntersect, rectW, rectX, rectY, v2X, v2Y)
import NanoUI.Layout.Arena (DirTag (..))
import NanoUI.Style (Direction (..), Padding (..), Style (..), styleBorderWidth, windowPad)

-- | Axis scrollbar visibility and interaction policy.
data ScrollPolicy
  = ScrollAuto
  | ScrollAlways
  | ScrollNone
  | ScrollHidden
  deriving (Eq, Show, Enum, Bounded)

-- | 2D scroll configuration (policy per axis).
data ScrollConfig = ScrollConfig
  { scrollPolicyX :: !ScrollPolicy
  , scrollPolicyY :: !ScrollPolicy
  , scrollClamp :: !Bool
  -- | A bare scroller paints no well of its own: no input background, no
  -- border, no window fill. Only the clipped children render, so a strip that
  -- borrows the scroller for its offset and clip (tab headers) looks exactly
  -- like it did before it started scrolling. Chrome policies still apply on
  -- top: 'ScrollHidden' plus bare is the fully chrome-less scroller.
  , scrollBare :: !Bool
  }
  deriving (Eq, Show)

defaultScrollConfig :: ScrollConfig
defaultScrollConfig =
  ScrollConfig
    { scrollPolicyX = ScrollAuto
    , scrollPolicyY = ScrollAuto
    , scrollClamp = True
    , scrollBare = False
    }

scrollConfigNative2D :: ScrollConfig -> Bool
scrollConfigNative2D cfg =
  scrollAxisActive (scrollPolicyX cfg) && scrollAxisActive (scrollPolicyY cfg)
  where
    scrollAxisActive = \case
      ScrollNone -> False
      _ -> True

encodeScrollConfig :: ScrollConfig -> Int
encodeScrollConfig cfg =
  policyBits (scrollPolicyX cfg)
    + shiftL (policyBits (scrollPolicyY cfg)) 2
    + (if scrollClamp cfg then 16 else 0)
    + (if scrollBare cfg then 32 else 0)
  where
    policyBits = \case
      ScrollAuto -> 0
      ScrollAlways -> 1
      ScrollNone -> 2
      ScrollHidden -> 3

decodeScrollConfig :: Int -> ScrollConfig
decodeScrollConfig bits =
  ScrollConfig
    { scrollPolicyX = decodePolicy (bits .&. 3)
    , scrollPolicyY = decodePolicy (shiftR bits 2 .&. 3)
    , scrollClamp = bits .&. 16 /= 0
    , scrollBare = bits .&. 32 /= 0
    }
  where
    decodePolicy 1 = ScrollAlways
    decodePolicy 2 = ScrollNone
    decodePolicy 3 = ScrollHidden
    decodePolicy _ = ScrollAuto

scrollDefault1D :: Direction -> ScrollConfig
scrollDefault1D Column = scrollVerticalAuto
scrollDefault1D Row = scrollHorizontalAuto

scrollVerticalAuto :: ScrollConfig
scrollVerticalAuto = ScrollConfig ScrollNone ScrollAuto True False

scrollHorizontalAuto :: ScrollConfig
scrollHorizontalAuto = ScrollConfig ScrollAuto ScrollNone True False

scrollVerticalHidden :: ScrollConfig
scrollVerticalHidden = ScrollConfig ScrollNone ScrollHidden True False

scrollHorizontalHidden :: ScrollConfig
scrollHorizontalHidden = ScrollConfig ScrollHidden ScrollNone True False

-- | Cross-axis gutter for one bar. @trailPad@ is the scroller's padding on
-- the bar's side (right for the vertical bar, bottom for the horizontal one).
scrollAxisGutter ::
  ScrollPolicy ->
  ScrollBarSlot ->
  Float ->
  Float ->
  Float ->
  Float
scrollAxisGutter policy slot trailPad contentSize innerMain =
  case policy of
    ScrollNone -> 0
    ScrollHidden -> 0
    ScrollAuto -> scrollLayoutGutter slot trailPad contentSize innerMain
    ScrollAlways -> scrollBarGutter slot trailPad

-- Vertical bar takes width. Horizontal bar takes height. Second pass
-- covers the corner case where one bar makes the other axis overflow.
scrollGutters2D ::
  ScrollBarSlot ->
  ScrollConfig ->
  Padding ->
  Float ->
  Float ->
  Float ->
  Float ->
  (Float, Float)
scrollGutters2D slot cfg pad contentW contentH innerW innerH =
  let gVert inner = scrollAxisGutter (scrollPolicyY cfg) slot (padR pad) contentH inner
      gHorz inner = scrollAxisGutter (scrollPolicyX cfg) slot (padB pad) contentW inner
      gW0 = gVert innerH
      gH0 = gHorz innerW
      gW = gVert (innerH - gH0)
      gH = gHorz (innerW - gW0)
   in (gW, gH)

isScrollStyle2D :: Int -> Bool
isScrollStyle2D si = si /= 0 && scrollConfigNative2D (decodeScrollConfig si)

scrollShowsChrome :: ScrollConfig -> DirTag -> Bool
scrollShowsChrome cfg dir =
  case dir of
    DirColumn -> axisShows (scrollPolicyY cfg)
    DirRow -> axisShows (scrollPolicyX cfg)
  where
    axisShows = \case
      ScrollAuto -> True
      ScrollAlways -> True
      _ -> False

scrollChromeSuppressed :: ScrollConfig -> DirTag -> Bool
scrollChromeSuppressed cfg dir = not (scrollShowsChrome cfg dir)

-- | Distance one wheel notch scrolls along a live axis. Window hosts step a
-- text line. Widgets that map wheel notches onto a scroller's offset share
-- this so the step cannot drift per caller.
scrollLineFor :: Float
scrollLineFor = 20

-- | Wheel eligibility is wider than chrome eligibility: a hidden bar never
-- paints or drags, but it still scrolls. Only a dead axis ('ScrollNone')
-- ignores the wheel outright. Native 2D scrollers always keep both axes
-- live by construction.
scrollWheelSuppressed :: ScrollConfig -> Bool -> DirTag -> Bool
scrollWheelSuppressed cfg native2D dir =
  not native2D
    && ( case dir of
           DirColumn -> scrollPolicyY cfg == ScrollNone
           DirRow -> scrollPolicyX cfg == ScrollNone
       )

scrollAxisOverflows :: ScrollPolicy -> Float -> Float -> Bool
scrollAxisOverflows policy contentSize innerMain =
  case policy of
    ScrollNone -> False
    ScrollHidden -> False
    ScrollAlways -> True
    ScrollAuto -> contentSize > innerMain + 0.5

-- | Scroll range along one axis. Content that fits (modulo the trailing
-- padding, which must not surface a bar by itself) does not scroll; genuine
-- overflow extends the range past the last child by the trailing padding so
-- scrolling to the end still reveals it. Stored content sizes exclude the
-- trailing padding (see positionScrollChildren); this is where it is added
-- back into the reachable range.
scrollAxisRange :: Float -> Float -> Float -> Float
scrollAxisRange contentSize innerMain trailingPad
  | contentSize > innerMain + 0.5 = max 0 (contentSize + trailingPad - innerMain)
  | otherwise = 0

scrollChromeActive :: ScrollConfig -> DirTag -> Float -> Float -> Bool
scrollChromeActive cfg dir contentSize innerMain =
  scrollShowsChrome cfg dir
    && scrollAxisOverflows
      (case dir of
         DirColumn -> scrollPolicyY cfg
         DirRow -> scrollPolicyX cfg)
      contentSize
      innerMain

data ScrollBarLayout = ScrollBarLayout
  { sbTrack :: Rect
  , sbThumb :: Rect
  , sbMaxOff :: Float
  }
  deriving (Eq, Show)

padContentClip :: Float -> Float -> Float -> Float -> Padding -> Rect
padContentClip x y w h pad =
  Rect
    (x + padL pad)
    (y + padT pad)
    (max 0 (w - padL pad - padR pad))
    (max 0 (h - padT pad - padB pad))

scrollContentClip ::
  ScrollBarSlot ->
  ScrollConfig ->
  DirTag ->
  Float ->
  Float ->
  Float ->
  Float ->
  Padding ->
  Float ->
  Rect
scrollContentClip slot cfg dir x y w h pad contentSize =
  let base = padContentClip x y w h pad
      innerMain =
        case dir of
          DirColumn -> rectH base
          DirRow -> rectW base
      (policy, trailPad) =
        case dir of
          DirColumn -> (scrollPolicyY cfg, padR pad)
          DirRow -> (scrollPolicyX cfg, padB pad)
      gutter = scrollAxisGutter policy slot trailPad contentSize innerMain
   in case dir of
        DirColumn -> Rect (rectX base) (rectY base) (max 0 (rectW base - gutter)) (rectH base)
        DirRow -> Rect (rectX base) (rectY base) (rectW base) (max 0 (rectH base - gutter))

scrollViewportClip2D ::
  ScrollBarSlot ->
  ScrollConfig ->
  Float ->
  Float ->
  Float ->
  Float ->
  Padding ->
  Float ->
  Float ->
  Rect
scrollViewportClip2D slot cfg x y w h pad contentW contentH =
  let base = padContentClip x y w h pad
      innerW = rectW base
      innerH = rectH base
      (gutterW, gutterH) = scrollGutters2D slot cfg pad contentW contentH innerW innerH
   in Rect (rectX base) (rectY base) (max 0 (innerW - gutterW)) (max 0 (innerH - gutterH))

-- | The strip a bar sits in. A list bar sits one gap (see 'scrollBarGap')
-- inside its well's edge. A page bar sits a side gap inside the page's edge,
-- and a window body's bar a side gap inside the window's edge, out in the
-- window's padding. The gutter keeps the content one gap before each of them.
scrollChromeLane ::
  ScrollBarSlot -> DirTag -> Float -> Float -> Float -> Float -> Padding -> Rect
scrollChromeLane slot dir x y w h pad =
  let (barW, _) = scrollBarGeomFor slot
      -- From the scroller's edge in to the bar's far side. Window and modal
      -- bodies only scroll vertically, so the window's side padding is the
      -- one that places their bar.
      inset trailPad = case slot of
        ScrollBarList -> scrollBarGap trailPad
        ScrollBarPage -> scrollBarSideGap
        ScrollBarWindow -> scrollBarSideGap - padR windowPad
   in case dir of
        DirColumn ->
          Rect (max x (x + w - inset (padR pad) - barW)) (y + padT pad) barW (max 0 (h - padT pad - padB pad))
        DirRow ->
          Rect (x + padL pad) (max y (y + h - inset (padB pad) - barW)) (max 0 (w - padL pad - padR pad)) barW

scrollBarLayout ::
  ScrollBarSlot ->
  DirTag ->
  Float ->
  Float ->
  Float ->
  Float ->
  Padding ->
  Float ->
  Float ->
  Maybe ScrollBarLayout
scrollBarLayout slot dir x y w h pad contentSize off =
  let innerW = w - padL pad - padR pad
      innerH = h - padT pad - padB pad
      viewMain = case dir of
        DirColumn -> innerH
        DirRow -> innerW
   in scrollBarLayoutIn slot dir x y w h pad viewMain contentSize off

-- | 'scrollBarLayout' with an explicit visible main extent. A native 2D
-- scroller passes the padding box minus the cross-axis lane (see
-- 'scrollGutters2D'), so its reachable range and thumb reflect the viewport
-- that is actually visible rather than the lane-underlapped padding box. On a
-- one-dimensional scroller @viewMain@ is just the padding box on that axis.
scrollBarLayoutIn ::
  ScrollBarSlot ->
  DirTag ->
  Float ->
  Float ->
  Float ->
  Float ->
  Padding ->
  Float ->
  Float ->
  Float ->
  Maybe ScrollBarLayout
scrollBarLayoutIn slot dir x y w h pad viewMain contentSize off =
  let (barW, barMargin) = scrollBarGeomFor slot
      minThumb = 16
   in case dir of
        DirColumn ->
          let trailH = padB pad
              extentH = contentSize + trailH
              maxOff = scrollAxisRange contentSize viewMain trailH
           in if maxOff <= 0
                then Nothing
                else
                  let lane = scrollChromeLane slot DirColumn x y w h pad
                      trackX = rectX lane
                      trackY = y + padT pad + barMargin
                      trackH = max 0 (viewMain - 2 * barMargin)
                      thumbH = max minThumb (trackH * viewMain / extentH)
                      ratio = off / maxOff
                      thumbY = trackY + ratio * (trackH - thumbH)
                   in
                    Just
                      ScrollBarLayout
                        { sbTrack = Rect trackX trackY barW trackH
                        , sbThumb = Rect trackX thumbY barW thumbH
                        , sbMaxOff = maxOff
                        }
        DirRow ->
          let trailW = padR pad
              extentW = contentSize + trailW
              maxOff = scrollAxisRange contentSize viewMain trailW
           in if maxOff <= 0
                then Nothing
                else
                  let lane = scrollChromeLane slot DirRow x y w h pad
                      trackY = rectY lane
                      trackX = x + padL pad + barMargin
                      trackW = max 0 (viewMain - 2 * barMargin)
                      thumbW = max minThumb (trackW * viewMain / extentW)
                      ratio = off / maxOff
                      thumbX = trackX + ratio * (trackW - thumbW)
                   in
                    Just
                      ScrollBarLayout
                        { sbTrack = Rect trackX trackY trackW barW
                        , sbThumb = Rect thumbX trackY thumbW barW
                        , sbMaxOff = maxOff
                        }

-- | Both-axis layouts for a native 2D scroller: (vertical, horizontal). Each
-- axis's visible main extent is reduced by the other axis's live gutter, so
-- the range and thumb are computed against the viewport minus the opposite
-- scrollbar lane.
scrollBarLayouts2D ::
  ScrollBarSlot ->
  ScrollConfig ->
  Float ->
  Float ->
  Float ->
  Float ->
  Padding ->
  Float ->
  Float ->
  Float ->
  Float ->
  (Maybe ScrollBarLayout, Maybe ScrollBarLayout)
scrollBarLayouts2D slot cfg x y w h pad contentW contentH offX offY =
  let innerW = w - padL pad - padR pad
      innerH = h - padT pad - padB pad
      (gutterW, gutterH) = scrollGutters2D slot cfg pad contentW contentH innerW innerH
      viewW = max 0 (innerW - gutterW)
      viewH = max 0 (innerH - gutterH)
      v = scrollBarLayoutIn slot DirColumn x y w h pad viewH contentH offY
      hr = scrollBarLayoutIn slot DirRow x y w h pad viewW contentW offX
   in (v, hr)

scrollOffsetFromThumb :: DirTag -> ScrollBarLayout -> Float -> V2 -> Float
scrollOffsetFromThumb dir layout grabOff mouse =
  let maxOff = sbMaxOff layout
      track = sbTrack layout
      thumb = sbThumb layout
   in case dir of
        DirColumn ->
          let trackY = rectY track
              trackH = rectH track
              thumbH = rectH thumb
              thumbTop = v2Y mouse - grabOff
              ratio = (thumbTop - trackY) / max 1 (trackH - thumbH)
           in max 0 (min maxOff (ratio * maxOff))
        DirRow ->
          let trackX = rectX track
              trackW = rectW track
              thumbW = rectW thumb
              thumbLeft = v2X mouse - grabOff
              ratio = (thumbLeft - trackX) / max 1 (trackW - thumbW)
           in max 0 (min maxOff (ratio * maxOff))

textClipSlop :: Float
textClipSlop = 4

tagClippedSpans :: Rect -> [(Rect, Text, Color, Color)] -> [(Rect, Text, Color, Color, Rect)]
tagClippedSpans clip =
  mapMaybe (\(rect, txt, fg, bg) -> (rect, txt, fg, bg,) <$> rectIntersect clip (padTextClipRect rect))

padTextClipRect :: Rect -> Rect
padTextClipRect (Rect x y w h) = Rect x y (w + textClipSlop) h

borderContentClip :: Style -> Rect -> Rect
borderContentClip style (Rect x y w h) =
  if styleBorderWidth style <= 0
    then Rect x y w h
    else
      let bw = max 1 (styleBorderWidth style)
       in Rect (x + bw) (y + bw) (max 0 (w - 2 * bw)) (max 0 (h - 2 * bw))