nano-ui-0.1.0.0: lib/NanoUI/Frame/Select.hs
{-# LANGUAGE DataKinds #-}
module NanoUI.Frame.Select
( selectDropRect
, selectDropPickIndex
, closeSelectOnOutsideClick
, finalizeSelectKeyboard
, finalizeSelectPick
, markSelectDropPress
, drawSelectOverlays
, collectSelectDropdownSpans
, findSelectUnderMouse
, overlayMenuOwnerAt
, cacheOpenSelectDrop
, tagSelectClippedSpans
, comboDropRect
, comboDropPickIndex
, comboScrollGeom
) where
import Control.Monad (forM, forM_, unless, when)
import Data.Foldable (find)
import Data.IORef (readIORef, writeIORef)
import qualified Data.IntMap.Strict as IM
import Data.Maybe (catMaybes, listToMaybe, maybeToList)
import qualified Data.Text as T
import NanoUI.Context
( Context (..)
, TextInputMenu (..)
, WidgetStore (..)
, anySelectOpen
, closeSelects
, getStore
, getTextInputMenu
, intKey
, isSelectOpen
, markDirty
, markEscapeConsumed
, setSelectOpen
, setStore
, widgetTheme
, isDisabled
, InteractionState (..)
, modifyInteraction
)
import NanoUI.Draw (pushRect, pushRoundedRect, pushText, withClip)
import NanoUI.Font (FontMetrics, centeredTextY, menuItemPadX, menuItemRowH, menuOuterPad, widgetContentInset)
import NanoUI.Frame.Chrome (overlayMenuStyle, paintMenuAccent, paintMenuPanel)
import NanoUI.Frame.Hit (findNodeByWidgetId, widgetOverlayAllowed)
import NanoUI.Frame.Scroll.Geometry (padTextClipRect)
import NanoUI.Id (WidgetId (..), hashWidgetId)
import NanoUI.Input (Input (..), Key (..), foldInputKeys, inputKeys, inputMouseDown, inputMousePos, inputMousePressed)
import NanoUI.Layout.Arena (NodeType (NodeSelect, NodeTextInput), findNodeM, foldNodeRevM, getNodeType, lookupNodeByWidgetId, getOptions, getRect, getWidgetId)
import NanoUI.Store (Slot (..), slotKey)
import NanoUI.Style (Style (..), Theme (..), scrollBarThumbColor, scrollBarTrackColor, themeAccent, themeInput)
import NanoUI.Types (Color (..), Rect (..), V2 (..), rectContains, rectIntersect)
import NanoUI.WidgetText (selectChevronReserve)
-- | An open dropdown: a select with its open flag set, or a combo box (a
-- search field carrying options) exactly while it holds focus.
data Dropdown = Dropdown
{ ddWidget :: !WidgetId
, ddCombo :: !Bool
, ddOptions :: [T.Text]
, ddAnchor :: !Rect
, ddRect :: !Rect
, ddPicked :: !Int
-- ^ Row shown as picked: the select's value, or the combo's keyboard
-- highlight relative to its window (-1 highlights nothing).
, ddComboRows :: !Int
, ddComboWindow :: !Int
, ddComboScrollX :: !Float
, ddComboContentW :: !Float
}
-- | Every open dropdown, in arena order.
openDropdowns :: Context -> IO [Dropdown]
openDropdowns ctx = do
store <- getStore ctx
focus <- readIORef (ctxFocusId ctx)
-- Selects open only through the store flag and combos only while focused.
-- With no select open, the focused node is the only candidate, so only an
-- open select walks the arena.
if anySelectOpen store
then foldNodeRevM na (\acc idx -> maybe acc (: acc) <$> dropdownAt store focus idx) []
else
if hashWidgetId focus == 0
then pure []
else maybe (pure []) (fmap maybeToList . dropdownAt store focus) =<< lookupNodeByWidgetId na focus
where
na = ctxNodeArena ctx
dropdownAt store focus idx =
getNodeType na idx >>= \case
NodeSelect -> do
wid <- getWidgetId na idx
if isSelectOpen store (intKey wid) then Just <$> build store idx wid False else pure Nothing
NodeTextInput -> do
wid <- getWidgetId na idx
opts <- getOptions na idx
if wid /= focus || null opts then pure Nothing else Just <$> build store idx wid True
_ -> pure Nothing
build store idx wid combo = do
opts <- getOptions na idx
(x, y, w, h) <- getRect na idx
let key = intKey wid
slotInt slot def = IM.findWithDefault def (slotKey slot key) (storeInt store)
slotFloat slot = IM.findWithDefault 0 (slotKey slot key) (storeFloat store)
nOpts = length opts
rows = slotInt SlotComboCount nOpts
window = slotInt SlotComboScroll 0
contentW = slotFloat SlotComboContentW
pure
Dropdown
{ ddWidget = wid
, ddCombo = combo
, ddOptions = opts
, ddAnchor = Rect x y w h
, ddRect =
if combo
then comboDropRect x y w h nOpts rows contentW
else selectDropRect x y w h nOpts
, ddPicked =
if combo
then slotInt SlotComboHighlight (-1) - window
else IM.findWithDefault 0 key (storeInt store)
, ddComboRows = rows
, ddComboWindow = window
, ddComboScrollX = slotFloat SlotComboScrollX
, ddComboContentW = contentW
}
-- | One placed row of an open dropdown.
data DropdownRow = DropdownRow
{ drIndex :: !Int
, drOption :: T.Text
, drRect :: !Rect
, drTextX :: !Float
, drHovered :: !Bool
}
-- | Rows of an open dropdown, shared by its painter and its text spans. Combo
-- rows sit flush at the drop rect's top edge (no outer margin) and scroll
-- horizontally; select rows keep their padded layout.
dropdownRows :: FontMetrics -> V2 -> Dropdown -> [DropdownRow]
dropdownRows fm mouse dd =
let Rect dx dy dw _ = ddRect dd
top = if ddCombo dd then dy else dy + menuOuterPad
textX0 = dx + menuItemPadX + fst (widgetContentInset fm)
textX = if ddCombo dd then textX0 - ddComboScrollX dd else textX0
in [ DropdownRow i opt row textX (rectContains row mouse)
| (i, opt) <- zip [0 ..] (ddOptions dd)
, let row = Rect dx (top + menuItemRowH * fromIntegral i) dw menuItemRowH
]
overlayMenuOwnerAt :: Context -> V2 -> IO (Maybe WidgetId)
overlayMenuOwnerAt ctx mouse = do
mMenu <- getTextInputMenu ctx
case mMenu of
Just m | rectContains (textInputMenuRect m) mouse -> pure (Just (textInputMenuWidget m))
_ -> fmap ddWidget . find (\dd -> rectContains (ddRect dd) mouse) <$> openDropdowns ctx
cacheOpenSelectDrop :: Context -> IO ()
cacheOpenSelectDrop ctx = do
dropdowns <- openDropdowns ctx
modifyInteraction ctx (\s -> s {isOpenSelectDrop = (\dd -> (ddWidget dd, ddRect dd)) <$> listToMaybe dropdowns})
markSelectDropPress :: Context -> Input -> IO ()
markSelectDropPress ctx inp =
when (inputMouseDown inp) $ do
store <- getStore ctx
when (anySelectOpen store) $ do
let mouse = inputMousePos inp
dropdowns <- openDropdowns ctx
when (any (\dd -> rectContains (ddAnchor dd) mouse || rectContains (ddRect dd) mouse) dropdowns) $
modifyInteraction ctx (\s -> s {isSelectDropPress = True})
closeSelectOnOutsideClick :: Context -> Input -> IO ()
closeSelectOnOutsideClick ctx inp =
when (inputMousePressed inp || inputMouseReleased inp) $ do
store <- getStore ctx
when (anySelectOpen store) $ do
let mouse = inputMousePos inp
dropdowns <- openDropdowns ctx
unless (any (\dd -> rectContains (ddAnchor dd) mouse || rectContains (ddRect dd) mouse) dropdowns) $
setStore ctx (closeSelects store)
finalizeSelectKeyboard :: Context -> Input -> IO ()
finalizeSelectKeyboard ctx inp = do
let (wantNext, wantPrev, wantEsc, wantEnter) =
foldInputKeys
( \(n, p, e, r) k ->
( n || k == KeyDown || k == KeyRight
, p || k == KeyUp || k == KeyLeft
, e || k == KeyEscape
, r || k == KeyEnter
)
)
(False, False, False, False)
(inputKeys inp)
wantStep = wantNext || wantPrev
when (wantStep || wantEsc || wantEnter) $ do
focus <- readIORef (ctxFocusId ctx)
store <- getStore ctx
mTarget <- pickSelectKeyboardTarget ctx focus store wantStep
forM_ mTarget $ \(wid, open) -> do
allow <- widgetOverlayAllowed ctx wid
when allow $
if wantEsc || wantEnter
then when open $ do
setStore ctx (setSelectOpen store (intKey wid) False)
when wantEsc $ markEscapeConsumed ctx
markDirty ctx
else do
mIdx <- findNodeByWidgetId ctx wid
forM_ mIdx $ \idx -> do
n <- length <$> getOptions (ctxNodeArena ctx) idx
when (n > 0) $ do
let key = intKey wid
cur = IM.findWithDefault 0 key (storeInt store)
next = max 0 (min (n - 1) (cur + if wantNext then 1 else -1))
when (next /= cur) $ do
setStore ctx (store {storeInt = IM.insert key next (storeInt store)})
markDirty ctx
pickSelectKeyboardTarget :: Context -> WidgetId -> WidgetStore -> Bool -> IO (Maybe (WidgetId, Bool))
pickSelectKeyboardTarget ctx focus store wantStep = do
mFocus <- if wantStep then selectWidgetIfAny ctx focus else pure Nothing
case mFocus of
Just wid -> pure (Just (wid, isSelectOpen store (intKey wid)))
Nothing -> fmap (,True) <$> findOpenSelectWidget ctx
selectWidgetIfAny :: Context -> WidgetId -> IO (Maybe WidgetId)
selectWidgetIfAny ctx wid
| hashWidgetId wid == 0 = pure Nothing
| otherwise = do
mIdx <- findNodeByWidgetId ctx wid
case mIdx of
Nothing -> pure Nothing
Just idx -> do
nt <- getNodeType (ctxNodeArena ctx) idx
disabled <- isDisabled ctx wid
pure (if nt == NodeSelect && not disabled then Just wid else Nothing)
findOpenSelectWidget :: Context -> IO (Maybe WidgetId)
findOpenSelectWidget ctx = do
store <- getStore ctx
let na = ctxNodeArena ctx
mIdx <-
findNodeM na $ \idx -> do
nt <- getNodeType na idx
if nt /= NodeSelect
then pure False
else isSelectOpen store . intKey <$> getWidgetId na idx
traverse (getWidgetId na) mIdx
finalizeSelectPick :: Context -> Input -> IO ()
finalizeSelectPick ctx inp =
when (inputMousePressed inp || inputMouseReleased inp) $ do
let mouse@(V2 _ mouseY) = inputMousePos inp
dropdowns <- openDropdowns ctx
forM_ dropdowns $ \dd -> do
allow <- widgetOverlayAllowed ctx (ddWidget dd)
when (allow && rectContains (ddRect dd) mouse) $ do
st <- getStore ctx
let wid = ddWidget dd
key = intKey wid
nOpts = length (ddOptions dd)
if ddCombo dd
then do
-- Combo: pick on press only, never from the scrollbar lanes, so
-- finishing a thumb drag cannot commit a row. Picking commits the
-- option text into the field and defocuses it: the combo's
-- dropdown is visible exactly while focused, so the menu
-- disappears with the pick.
let (_, vSb, hSb, _) = comboScrollGeom (ddRect dd) (ddComboRows dd) nOpts (ddComboWindow dd) (ddComboScrollX dd) (ddComboContentW dd)
onLane = any (\(track, _) -> rectContains track mouse) (catMaybes [vSb, hSb])
when (inputMousePressed inp && not onLane) $
forM_ (comboDropPickIndex (ddRect dd) menuItemRowH nOpts mouseY) $ \picked -> do
let txt = maybe "" id (listToMaybe (drop picked (ddOptions dd)))
len = T.length txt
setStore
ctx
( st
{ storeText = IM.insert key txt (storeText st)
, storeInt =
IM.insert (slotKey SlotCursor key) len $
IM.insert (slotKey SlotAnchor key) len (storeInt st)
}
)
writeIORef (ctxFocusId ctx) (WidgetId 0)
markDirty ctx
else
forM_ (selectDropPickIndex (ddRect dd) menuItemRowH nOpts mouseY) $ \picked -> do
setStore ctx (setSelectOpen (st {storeInt = IM.insert key picked (storeInt st)}) key False)
writeIORef (ctxFocusId ctx) wid
markDirty ctx
-- | Topmost open dropdown owner (in reverse arena order) whose anchor or menu
-- is under @mouse@ and that the modal state lets receive input.
findSelectUnderMouse :: Context -> V2 -> IO (Maybe WidgetId)
findSelectUnderMouse ctx mouse = do
dropdowns <- openDropdowns ctx
firstAllowed [dd | dd <- reverse dropdowns, rectContains (ddAnchor dd) mouse || rectContains (ddRect dd) mouse]
where
firstAllowed [] = pure Nothing
firstAllowed (dd : rest) = do
allow <- widgetOverlayAllowed ctx (ddWidget dd)
if allow then pure (Just (ddWidget dd)) else firstAllowed rest
-- | Vertical gap between the select widget and its dropdown menu.
selectDropGap :: Float
selectDropGap = 4
selectDropRect :: Float -> Float -> Float -> Float -> Int -> Rect
selectDropRect x y w h nOpts =
Rect x (y + h + selectDropGap) w (menuItemRowH * fromIntegral nOpts + 2 * menuOuterPad)
selectDropPickIndex :: Rect -> Float -> Int -> Float -> Maybe Int
selectDropPickIndex dropRect itemH nOpts mouseY =
let Rect _ dy _ dh = dropRect
innerH = itemH * fromIntegral nOpts
rel = mouseY - dy - max 0 ((dh - innerH) / 2)
in if rel < 0 || rel >= innerH
then Nothing
else Just (max 0 (min (nOpts - 1) (floor (rel / max itemH 1))))
-- Combo dropdown scrollbar sizes: lane thickness and the shortest a thumb
-- ever gets.
comboSbW, comboSbMinThumb :: Float
comboSbW = 10
comboSbMinThumb = 24
-- | Scrollbar geometry for a combo dropdown, shared by the overlay painter,
-- the pick guard, and the widget's thumb-drag gesture. The list has no outer
-- margin: rows fill the drop rect edge to edge, and a vertical lane sits on
-- the right when rows overflow the window, a horizontal one on the bottom
-- when the widest row overflows the width. Returns (inner rows area,
-- vertical (track, thumb), horizontal (track, thumb), usable content width).
comboScrollGeom ::
Rect ->
Int ->
Int ->
Int ->
Float ->
Float ->
(Rect, Maybe (Rect, Rect), Maybe (Rect, Rect), Float)
comboScrollGeom (Rect dx dy dw dh) n vis win xOff contentW =
let
vScroll = n > vis && vis > 0
vLaneW = if vScroll then comboSbW else 0
usableW = max 0 (dw - vLaneW)
hScroll = contentW > usableW && contentW > 0
hLaneH = if hScroll then comboSbW else 0
-- Rows fill the drop rect from the top, stopping short of the lanes.
inner = Rect dx dy (max 0 (dw - vLaneW)) (max 0 (dh - hLaneH))
-- Lanes sit flush against the dropdown border and share the corner.
vTrack = Rect (dx + dw - comboSbW) dy comboSbW (max 0 (dh - hLaneH))
hTrack = Rect dx (dy + dh - comboSbW) (max 0 (dw - vLaneW)) comboSbW
vSb =
if vScroll
then
let Rect vx vy _ vh = vTrack
trackH = max 1 vh
thumbH = max (min comboSbMinThumb trackH) (min trackH (trackH * fromIntegral vis / fromIntegral n))
maxWin = max 1 (n - vis)
ty = vy + (trackH - thumbH) * fromIntegral (max 0 (min maxWin win)) / fromIntegral maxWin
in Just (vTrack, Rect (vx + 2) ty (comboSbW - 4) thumbH)
else Nothing
hSb =
if hScroll
then
let Rect hx hy hw _ = hTrack
trackW = max 1 hw
thumbW = max (min comboSbMinThumb trackW) (min trackW (trackW * usableW / contentW))
maxOff = max 1 (contentW - usableW)
tx = hx + (trackW - thumbW) * max 0 (min maxOff xOff) / maxOff
in Just (hTrack, Rect tx (hy + 2) thumbW (comboSbW - 4))
else Nothing
in (inner, vSb, hSb, usableW)
-- | Combo dropdown rect: like 'selectDropRect', but with no outer margin
-- (rows start flush at the top), and the height reserves a flush bottom
-- scrollbar lane when the widest row overflows, so the horizontal bar never
-- covers the bottommost row. Must agree with 'comboScrollGeom' on when lanes
-- appear (same inputs, same formulas).
comboDropRect :: Float -> Float -> Float -> Float -> Int -> Int -> Float -> Rect
comboDropRect x y w h nRows nTotal contentW =
let vLaneW = if nTotal > nRows then comboSbW else 0
hScroll = contentW > max 0 (w - vLaneW) && contentW > 0
in Rect x (y + h + selectDropGap) w (fromIntegral nRows * menuItemRowH + (if hScroll then comboSbW else 0))
-- | Row index at @mouseY@ for a combo dropdown, whose rows start flush at the
-- drop rect's top (unlike 'selectDropPickIndex', which centers them).
comboDropPickIndex :: Rect -> Float -> Int -> Float -> Maybe Int
comboDropPickIndex (Rect _ dy _ _) itemH nOpts mouseY =
let rel = mouseY - dy
in if rel < 0 || rel >= itemH * fromIntegral nOpts
then Nothing
else Just (max 0 (min (nOpts - 1) (floor (rel / max itemH 1))))
drawSelectOverlays :: Context -> Input -> IO ()
drawSelectOverlays ctx inp = do
dropdowns <- openDropdowns ctx
forM_ dropdowns $ \dd -> do
allow <- widgetOverlayAllowed ctx (ddWidget dd)
when allow $ do
theme <- widgetTheme ctx (ddWidget dd)
drawDropdownMenu ctx inp theme dd
-- | Paint one open dropdown (select or combo). The combo list clips to its
-- inner area (so x-shifted text and row fills stop at the scrollbar lanes)
-- and gets vertical / horizontal scrollbars when the filtered rows or the
-- widest row overflow the window.
drawDropdownMenu :: Context -> Input -> Theme -> Dropdown -> IO ()
drawDropdownMenu ctx inp theme dd = do
let da = ctxDrawArena ctx
fm = ctxFontMetrics ctx
style = overlayMenuStyle theme
paintRows =
forM_ (dropdownRows fm (inputMousePos inp) dd) $ \row -> do
let picked = drIndex row == ddPicked dd
Rect _ ry _ rh = drRect row
if drHovered row
then do
pushRect da (drRect row) (styleHoverBg style)
paintMenuAccent da theme (drRect row)
else when picked $ pushRect da (drRect row) (styleActiveBg style)
unless (T.null (drOption row)) $ do
(_, th) <- ctxMeasureText ctx (drOption row)
pushText da fm (drTextX row) (centeredTextY fm ry rh th) (drOption row) $
if picked then themeAccent theme else styleFg style
paintMenuPanel da theme style (ddRect dd)
if ddCombo dd
then do
let (inner, vSb, hSb, _) = comboScrollGeom (ddRect dd) (ddComboRows dd) (length (ddOptions dd)) (ddComboWindow dd) (ddComboScrollX dd) (ddComboContentW dd)
base = themeInput theme
drawBar (track, thumb) = do
pushRect da track (scrollBarTrackColor base theme)
pushRoundedRect da thumb 3 (scrollBarThumbColor base theme)
withClip da inner paintRows
mapM_ drawBar vSb
mapM_ drawBar hSb
else paintRows
collectSelectDropdownSpans :: Context -> Input -> IO [(Rect, T.Text, Color, Color, Rect)]
collectSelectDropdownSpans ctx inp = do
dropdowns <- openDropdowns ctx
let fm = ctxFontMetrics ctx
fmap concat . forM dropdowns $ \dd -> do
allow <- widgetOverlayAllowed ctx (ddWidget dd)
style <- overlayMenuStyle <$> widgetTheme ctx (ddWidget dd)
if not allow
then pure []
else fmap concat . forM (dropdownRows fm (inputMousePos inp) dd) $ \row ->
if T.null (drOption row)
then pure []
else do
(tw, th) <- ctxMeasureText ctx (drOption row)
let Rect _ ry _ rh = drRect row
bg
| drHovered row = styleHoverBg style
| drIndex row == ddPicked dd = styleActiveBg style
| otherwise = styleBg style
pure [(Rect (drTextX row) (centeredTextY fm ry rh th) tw th, drOption row, styleFg style, bg, ddRect dd)]
tagSelectClippedSpans ::
Rect -> Float -> Float -> Float -> Float -> FontMetrics -> [(Rect, T.Text, Color, Color)] -> [(Rect, T.Text, Color, Color, Rect)]
tagSelectClippedSpans parentClip x y w h fm spans =
let (ix, _) = widgetContentInset fm
textClip = padTextClipRect (Rect (x + ix) y (max 0 (w - ix - selectChevronReserve)) (max 0 h))
in case rectIntersect parentClip textClip of
Nothing -> []
Just clip -> [(rect, txt, fg, bg, clip) | (rect, txt, fg, bg) <- spans]