packages feed

nano-ui-0.1.0.0: lib/NanoUI/Widgets/Combo.hs

{-# LANGUAGE OverloadedStrings #-}

-- | Combo box: a search field with a filtered, scrollable suggestion dropdown.
-- The per-frame logic is the pure 'comboStep' over a persisted 'ComboState'.
module NanoUI.Widgets.Combo
  ( comboBox
  , comboBox'
  , ComboState (..)
  , ComboInput (..)
  , ComboStep (..)
  , comboStep
  )
where

import Control.Monad (foldM, when, (<$!>))
import Data.IORef (writeIORef)
import Data.IntMap.Strict qualified as IM
import Data.Maybe (fromMaybe, isJust)
import Data.Text (Text)
import Data.Text qualified as T
import Effectful (Eff, type (:>))
import NanoUI.Context
  ( Context (..)
  , getStore
  , intKey
  , markDirty
  , markEscapeConsumed
  , modifyStore
  , recordStoreText
  )
import NanoUI.Font (FontMetrics, menuItemRowH)
import NanoUI.Frame.Hit (findNodeByWidgetId)
import NanoUI.Frame.Select (comboDropPickIndex, comboDropRect, comboScrollGeom)
import NanoUI.Id (WidgetId (..))
import NanoUI.Input (Key (..), inputKeys, inputMouseDown, inputMousePos, inputMousePressed, inputScroll)
import NanoUI.Layout.Arena (setOptions)
import NanoUI.Monad (Ui, askContext, askInput, uiIO)
import NanoUI.Store
  ( WidgetStore (..)
  , boolInt
  , Slot (..)
  , slotKey
  )
import NanoUI.Types (Rect (..), V2 (..), clamp, rectContains, rectNonEmpty, v2X, v2Y)
import NanoUI.WidgetText (textInputFlagSearch)
import NanoUI.Widgets.Behavior (keyboardFocused)
import NanoUI.Widgets.Node (Response (..), setChanged)
import NanoUI.Widgets.TextInput (buildTextInput, searchFieldLayout)

-- | Maximum suggestion rows the combo dropdown shows at once; Up/Down walk
-- the highlight and the wheel scrolls the list through a sliding window.
comboBoxMaxVisible :: Int
comboBoxMaxVisible = 8

-- | Rows scrolled per wheel notch.
comboBoxRowsPerNotch :: Float
comboBoxRowsPerNotch = 3

-- | Case-insensitive substring filter behind the combo's suggestion list.
comboFiltered :: Foldable f => f Text -> Text -> [Text]
comboFiltered options q
  | T.null q = opts
  | otherwise =
      let needle = T.toLower q
        in filter (T.isInfixOf needle . T.toLower) opts
  where
    opts = foldr (:) [] options

-- | A combo's state between frames.
data ComboState = ComboState
  { csHighlight :: !Int
    -- ^ Highlighted row of the filtered list; -1 for none.
  , csWindow :: !Int
    -- ^ First visible row.
  , csScrollX :: !Float
  , csContentW :: !Float
    -- ^ Widest matching row, measured while focused.
  , csDrag :: !Int
    -- ^ Scrollbar thumb drag: 0 none, 1 vertical, 2 horizontal.
  , csDragOff :: !Float
    -- ^ Pointer offset into the dragged thumb.
  , csCommitted :: !Text
    -- ^ Last committed value.
  , csLive :: !Text
    -- ^ Field text the combo last produced.
  , csFocused :: !Bool
  }
  deriving (Eq, Show)

-- | One frame's inputs to 'comboStep'.
data ComboInput = ComboInput
  { ciFocused :: !Bool
  , ciEdited :: !Bool
    -- ^ Typing changed the field text this frame.
  , ciText :: !Text
    -- ^ Field text after this frame's editing.
  , ciRows :: ![Text]
    -- ^ Options matching the field text.
  , ciContentW :: !Float
    -- ^ Width of the widest matching row.
  , ciField :: !Rect
    -- ^ The field's rect; empty before its first layout.
  , ciMetrics :: !FontMetrics
  , ciMouse :: !V2
  , ciPressed :: !Bool
  , ciDown :: !Bool
  , ciScroll :: !V2
  , ciKeyUp :: !Bool
  , ciKeyDown :: !Bool
  , ciEnter :: !Bool
  , ciEscape :: !Bool
  }

-- | What one frame of the combo decided.
data ComboStep = ComboStep
  { stepState :: !ComboState
  , stepCommit :: !(Maybe Text)
    -- ^ The newly committed value, on the frame the committed value changes.
  , stepPicked :: !Bool
    -- ^ Enter picked the highlighted row; the caret moves to the text's end.
  , stepDismissed :: !Bool
    -- ^ Escape reverted the field to the committed value and releases focus.
  , stepRedraw :: !Bool
    -- ^ Something visible moved.
  }

-- | One frame of the combo: highlight, scrolling, thumb drags, and commits.
--
-- Typing edits the live text but never commits it: the committed value only
-- changes on Enter (which commits the highlighted row only), on a row click
-- (a field text the combo did not produce), or when the field loses focus.
-- Escape reverts the live text to the last committed value. Hover
-- highlights a row and makes it the Enter target; Up/Down move the highlight.
comboStep :: ComboInput -> ComboState -> ComboStep
comboStep ci cs0 =
  ComboStep
    { stepState =
        ComboState
          { csHighlight = hi'
          , csWindow = win
          , csScrollX = xOff
          , csContentW = contentW
          , csDrag = dragKind'
          , csDragOff = dragOff'
          , csCommitted = fromMaybe committed0 commitText
          , csLive = finalText
          , csFocused = isFocus
          }
    , stepCommit = if commitPulse then commitText else Nothing
    , stepPicked = picked
    , stepDismissed = escDismiss
    , stepRedraw =
        picked || nav /= 0 || escDismiss || wheelDelta /= 0 || xWheel /= 0
          || win /= storedWin || xOff /= storedX || hi' /= storedHi
          || dragKind' /= drag0 || commitPulse || csFocused cs0 /= isFocus
    }
  where
    isFocus = ciFocused ci
    text = ciText ci
    displayed = ciRows ci
    contentW = ciContentW ci
    n = length displayed
    vis = max 1 comboBoxMaxVisible
    storedHi = csHighlight cs0
    storedWin = csWindow cs0
    storedX = csScrollX cs0
    drag0 = csDrag cs0
    dragOff0 = csDragOff cs0
    committed0 = csCommitted cs0
    -- Typing clears the highlight (-1): it never pre-selects a row.
    hi0 = if ciEdited ci then -1 else storedHi
    win0 = if ciEdited ci then 0 else storedWin
    nav
      | not isFocus || n <= 0 = 0 :: Int
      | ciKeyDown ci = 1
      | ciKeyUp ci = -1
      | otherwise = 0
    hi
      | nav == 0 = hi0
      | hi0 < 0 = if nav > 0 then 0 else n - 1
      | otherwise = clamp 0 (n - 1) (hi0 + nav)
    clampWin = clamp 0 (max 0 (n - vis))
    -- Keep the highlighted row inside the window after keyboard navigation.
    alignWin v
      | n <= vis = 0
      | hi < v = hi
      | hi >= v + vis = hi - vis + 1
      | otherwise = clampWin v
    Rect rx ry rw rh = ciField ci
    mouse = ciMouse ci
    dropRect = comboDropRect rx ry rw rh (min vis n) n contentW
    overDrop = isFocus && rectNonEmpty (ciField ci) && rectContains dropRect mouse
    itemH = menuItemRowH
    -- Hover highlights the row under the pointer (and makes it the Enter
    -- target); it never commits by itself. Rows on screen belong to the
    -- previous frame's window, so the hit test maps through storedWin.
    hoverIdx
      | overDrop = (storedWin +) <$> comboDropPickIndex dropRect itemH (min vis n) (v2Y mouse)
      | otherwise = Nothing
    hiRaw = fromMaybe hi hoverIdx
    -- A hover mapped through a stale window can point past a shrunken list:
    -- highlight nothing then.
    hi' = if hiRaw < n then hiRaw else -1
    -- Scrollbar geometry from the pre-frame scroll state (the thumb the user
    -- is looking at when a drag starts).
    (_, vSb, hSb, usableW) = comboScrollGeom dropRect n vis storedWin storedX contentW
    maxOffX = max 0 (contentW - usableW)
    onVThumb = maybe False (\(_, th) -> rectContains th mouse) vSb
    onVTrack = maybe False (\(t, _) -> rectContains t mouse) vSb
    onHThumb = maybe False (\(_, th) -> rectContains th mouse) hSb
    onHTrack = maybe False (\(t, _) -> rectContains t mouse) hSb
    pressed = isFocus && ciPressed ci
    down = isFocus && ciDown ci
    startV = pressed && overDrop && onVTrack
    startH = pressed && overDrop && not startV && onHTrack
    vThumbR = maybe (Rect 0 0 0 0) snd vSb
    vTrackR = maybe (Rect 0 0 0 0) fst vSb
    hThumbR = maybe (Rect 0 0 0 0) snd hSb
    hTrackR = maybe (Rect 0 0 0 0) fst hSb
    vGrab = if onVThumb then v2Y mouse - rectY vThumbR else rectH vThumbR / 2
    hGrab = if onHThumb then v2X mouse - rectX hThumbR else rectW hThumbR / 2
    drag1
      | startV = 1
      | startH = 2
      | down && drag0 /= 0 = drag0
      | otherwise = 0
    -- Thumb-anchored drags move from the next frame on; track presses jump
    -- the window to the click immediately.
    draggingV = down && drag1 == 1 && ((drag0 == 1 && not startV) || (startV && not onVThumb))
    draggingH = down && drag1 == 2 && ((drag0 == 2 && not startH) || (startH && not onHThumb))
    dragWin = clampWin (round ((v2Y mouse - rectY vTrackR - dragOff0) / max 1 (rectH vTrackR - rectH vThumbR) * fromIntegral (n - vis)))
    dragX = clamp 0 maxOffX ((v2X mouse - rectX hTrackR - dragOff0) / max 1 (rectW hTrackR - rectW hThumbR) * maxOffX)
    wheelRows = round (v2Y (ciScroll ci) * comboBoxRowsPerNotch) :: Int
    wheelDelta = if overDrop then wheelRows else 0
    xWheel = if overDrop then v2X (ciScroll ci) * 20 else 0
    win
      | draggingV = dragWin
      | nav /= 0 = alignWin (win0 + wheelDelta)
      | otherwise = clampWin (win0 + wheelDelta)
    xOff
      | draggingH = dragX
      | otherwise = clamp 0 maxOffX (storedX + xWheel)
    dragKind' = if down then drag1 else 0
    dragOff' | startV = vGrab | startH = hGrab | otherwise = dragOff0
    -- Enter commits only an explicitly highlighted row (hover or Up/Down).
    picked = isFocus && n > 0 && hi' >= 0 && ciEnter ci
    pickedText = case drop (max 0 hi') displayed of
      chosen : _ | hi' >= 0 -> chosen
      _ -> text
    escDismiss = isFocus && ciEscape ci
    -- Commit points: Enter, a row click (the frame-side pick lands as a
    -- frame-start text the widget did not produce), and losing focus (which
    -- the blur frame after the focus clear detects). Escape is a cancel: it
    -- reverts the live text to the last committed value without committing.
    externalText = not (ciEdited ci) && text /= csLive cs0
    commitText
      | picked = Just pickedText
      | externalText = Just text
      | csFocused cs0 && not isFocus = Just text
      | otherwise = Nothing
    commitPulse = maybe False (/= committed0) commitText
    finalText
      | picked = pickedText
      | escDismiss = committed0
      | otherwise = text

-- | Combo box: the 'searchField' with a select-style dropdown of options.
-- While the field holds focus, the shared select dropdown overlay lists the
-- options filtered by the field text (all of them while it is empty). The
-- value is free text: options are suggestions, not a closed set. See
-- 'comboStep' for when the value commits. Pass the current text; the result is
-- the text after this frame, and 'respChanged' on 'comboBox'' marks a commit.
{-# INLINE comboBox #-}
comboBox :: (Foldable f, Ui :> es) => Text -> f Text -> Text -> Eff es Text
comboBox placeholder options value = snd <$> comboBox' placeholder options value

comboBox' :: (Foldable f, Ui :> es) => Text -> f Text -> Text -> Eff es (Response, Text)
comboBox' placeholder options value = do
  (resp, text) <-
    buildTextInput textInputFlagSearch searchFieldLayout placeholder value Nothing
  ctx <- askContext
  inp <- askInput
  let wid = rawRespId resp
      key = intKey wid
      keys = inputKeys inp
  isFocus <- keyboardFocused wid
  -- The dropdown only shows while the field is focused, so an unfocused
  -- combo steps with no rows. The matches stay lazy: the option window below
  -- forces only its rows, and the count is forced only on frames that store it.
  let matches = comboFiltered options text
      displayed = if isFocus then matches else []
  store <- uiIO (getStore ctx)
  let cs0 =
        ComboState
          { csHighlight = IM.findWithDefault (-1) (slotKey SlotComboHighlight key) (storeInt store)
          , csWindow = IM.findWithDefault 0 (slotKey SlotComboScroll key) (storeInt store)
          , csScrollX = IM.findWithDefault 0 (slotKey SlotComboScrollX key) (storeFloat store)
          , csContentW = IM.findWithDefault 0 (slotKey SlotComboContentW key) (storeFloat store)
          , csDrag = IM.findWithDefault 0 (slotKey SlotComboDrag key) (storeInt store)
          , csDragOff = IM.findWithDefault 0 (slotKey SlotComboDragOff key) (storeFloat store)
          , csCommitted = IM.findWithDefault value (slotKey SlotComboCommitted key) (storeText store)
          , csLive = IM.findWithDefault text (slotKey SlotComboLive key) (storeText store)
          , csFocused = IM.findWithDefault 0 (slotKey SlotComboFocus key) (storeInt store) /= 0
          }
  contentW <- uiIO $
    if isFocus && not (null displayed)
      then foldM (\widest t -> max widest . fst <$!> ctxMeasureText ctx t) 0 displayed
      else pure (csContentW cs0)
  let step =
        comboStep
          ComboInput
            { ciFocused = isFocus
            , ciEdited = rawRespChanged resp
            , ciText = text
            , ciRows = displayed
            , ciContentW = contentW
            , ciField = rawRespRect resp
            , ciMetrics = ctxFontMetrics ctx
            , ciMouse = inputMousePos inp
            , ciPressed = inputMousePressed inp
            , ciDown = inputMouseDown inp
            , ciScroll = inputScroll inp
            , ciKeyUp = KeyUp `elem` keys
            , ciKeyDown = KeyDown `elem` keys
            , ciEnter = KeyEnter `elem` keys
            , ciEscape = KeyEscape `elem` keys
            }
          cs0
      cs1 = stepState step
      finalText = csLive cs1
  when (isFocus || stepRedraw step) $
    uiIO $ do
      let len = T.length finalText
      modifyStore ctx $ \st ->
        let ints =
              IM.insert (slotKey SlotComboHighlight key) (csHighlight cs1) $
                IM.insert (slotKey SlotComboScroll key) (csWindow cs1) $
                  IM.insert (slotKey SlotComboCount key) (length matches) $
                    IM.insert (slotKey SlotComboFocus key) (boolInt (csFocused cs1)) $
                      IM.insert (slotKey SlotComboDrag key) (csDrag cs1) (storeInt st)
         in st
              { storeInt =
                  if stepPicked step
                    then IM.insert (slotKey SlotCursor key) len (IM.insert (slotKey SlotAnchor key) len ints)
                    else ints
              , storeFloat =
                  IM.insert (slotKey SlotComboScrollX key) (csScrollX cs1) $
                    IM.insert (slotKey SlotComboContentW key) (csContentW cs1) $
                      IM.insert (slotKey SlotComboDragOff key) (csDragOff cs1) (storeFloat st)
              , storeText =
                  IM.insert (slotKey SlotComboLive key) finalText $
                    IM.insert (slotKey SlotComboCommitted key) (csCommitted cs1) $
                      IM.insert key finalText (storeText st)
              }
      when (stepDismissed step) $ do
        writeIORef (ctxFocusId ctx) (WidgetId 0)
        markEscapeConsumed ctx
      when (stepRedraw step) $ markDirty ctx
  -- The dropdown overlay reads its rows from the node's option list: the
  -- visible window of the filtered list. Unfocused combos set it too, since a
  -- click that focuses the field this frame shows the dropdown this frame.
  uiIO $ do
    findNodeByWidgetId ctx wid
      >>= mapM_ (\idx -> setOptions (ctxNodeArena ctx) idx (take comboBoxMaxVisible (drop (csWindow cs1) matches)))
    recordStoreText ctx key finalText
  pure (setChanged (isJust (stepCommit step)) resp, finalText)