nano-ui-0.1.0.0: lib/NanoUI/Frame/TextInput.hs
{-# LANGUAGE DataKinds #-}
-- | Single-line text fields: field geometry, horizontal scroll, caret and
-- selection painting, and mouse selection. Also holds the click-count and
-- caret primitives the text area shares.
module NanoUI.Frame.TextInput
( textInputFieldRect
, textInputFieldTextClip
, nodeTextFieldGeom
, tagTextInputClippedSpans
, syncTextInputScroll
, FieldEdit
, readFieldEdit
, drawTextInputSelection
, drawTextInputCaret
, drawTextCaret
, drawTextSelectionLine
, searchClearHit
, normalizeTextFieldClicks
, finalizeTextInputMouse
, collapseTextInputSelection
) where
import Control.Monad (forM_, when)
import qualified Data.IntMap.Strict as IM
import Data.Maybe (mapMaybe)
import Data.Text (Text)
import qualified Data.Text as T
import NanoUI.Context
( Context (..)
, TextFieldClickCell (..)
, TextInputDrag (..)
, WidgetStore (..)
, getStore
, intKey
, markDirty
, setStore
, setTextInputDrag
, Slot (..)
, slotKey
, nodeTheme
, InteractionState (..)
, getsInteraction
, modifyInteraction
)
import NanoUI.Draw (DrawArena, pushRect)
import NanoUI.Font (FontMetrics (..), caretXIO, centeredTextY, lineWidthIO, prepareFontMetrics, selectionSpans, textIndexAtX, widgetContentInset)
import NanoUI.Frame.Chrome (textInputFocused, textInputValue)
import NanoUI.Frame.Hit (findNodeByWidgetId)
import NanoUI.Frame.Node (nodeFontMetrics)
import NanoUI.Frame.Scroll.Geometry (padTextClipRect)
import NanoUI.Id (WidgetId)
import NanoUI.Input
( Input (..)
, inputMouseClicks
, inputMouseDown
, inputMousePos
, inputMousePressed
, inputMouseReleased
)
import NanoUI.Layout.Arena
( NodeIdx
, NodeType (NodeTextInput)
, getNodeType
, getOptions
, getRect
, getStyleIdx
, getWidgetId
)
import NanoUI.Style (themeSelection)
import NanoUI.Types (Color (..), Rect (..), V2 (..), rectContains, rectIntersect, rectOverlapArea, rectW)
import NanoUI.WidgetText
( comboTextClip
, numericTextClip
, searchFieldIconRects
, searchFieldTextClip
, textInputNumericMode
, textInputFieldHeight
, textInputSearchMode
, textInputSelectableMode
)
import NanoUI.Widgets.TextCommon
( selectionCaretGeom
, textSelectionForClick
, textSelectionForDrag
)
textInputFieldRect :: FontMetrics -> Float -> Float -> Float -> Float -> Rect
textInputFieldRect fm x y w h =
let fieldH = if h > 0 then h else textInputFieldHeight fm
in Rect x y w fieldH
textInputFieldTextClip :: FontMetrics -> Rect -> Rect
textInputFieldTextClip fm (Rect fx fy fw fh) =
let (ix, iy) = widgetContentInset fm
in Rect (fx + ix) (fy + iy) (max 0 (fw - 2 * ix)) (max 0 (fh - 2 * iy))
-- | Resolve the box a field paints/hits and the clip its text is confined to.
-- Search fields are caption-less: the whole node rect is the box and text is
-- clipped around the magnifier / clear chrome. Combo boxes (search fields
-- carrying dropdown options) clip to the left of the chevron instead.
nodeTextFieldGeom :: Context -> NodeIdx -> Float -> Float -> Float -> Float -> IO (Rect, Rect)
nodeTextFieldGeom ctx idx x y w h = do
si <- getStyleIdx (ctxNodeArena ctx) idx
opts <- getOptions (ctxNodeArena ctx) idx
let fm = ctxFontMetrics ctx
box = Rect x y w h
field = textInputFieldRect fm x y w h
pure $
if textInputSelectableMode si
then (box, box)
else
if textInputNumericMode si
then (box, numericTextClip fm x y w h)
else
if textInputSearchMode si
then (box, if null opts then searchFieldTextClip fm x y w h else comboTextClip fm x y w h)
else (field, textInputFieldTextClip fm field)
-- | Whether the pointer is over the clear (×) button of a non-empty search
-- field. Search fields reserve that slot even when empty, but the button is
-- only active when there is text to clear.
searchClearHit :: Context -> WidgetId -> V2 -> IO Bool
searchClearHit ctx wid mouse = do
mIdx <- findNodeByWidgetId ctx wid
case mIdx of
Nothing -> pure False
Just idx -> do
si <- getStyleIdx (ctxNodeArena ctx) idx
opts <- getOptions (ctxNodeArena ctx) idx
if not (textInputSearchMode si) || not (null opts)
then pure False
else do
value <- textInputValue ctx idx
if T.null value
then pure False
else do
(x, y, w, h) <- getRect (ctxNodeArena ctx) idx
let (_, clearRect) = searchFieldIconRects (ctxFontMetrics ctx) x y w h
pure (rectContains clearRect mouse)
-- | Clear a search field. The debounced pulse picks the empty text up as an
-- immediate (empty) commit on the next frame.
clearSearchField :: Context -> WidgetId -> IO ()
clearSearchField ctx wid = do
store <- getStore ctx
let key = intKey wid
storeInt' =
IM.insert (slotKey SlotAnchor key) 0 $
IM.insert (slotKey SlotCursor key) 0 (storeInt store)
store' = store {storeText = IM.insert key "" (storeText store), storeInt = storeInt'}
setStore ctx store'
markDirty ctx
tagTextInputClippedSpans ::
Rect -> Float -> Float -> Float -> Float -> FontMetrics -> [(Rect, T.Text, Color, Color)] -> [(Rect, T.Text, Color, Color, Rect)]
tagTextInputClippedSpans parentClip x y w h fm spans =
let fieldClip = textInputFieldTextClip fm (textInputFieldRect fm x y w h)
labelClip = Rect x y w (fmLineHeight fm)
tagOne (rect, txt, fg, bg) =
let clipRect = padTextClipRect rect
isField = rectOverlapArea fieldClip clipRect > rectOverlapArea labelClip clipRect
area = if isField then fieldClip else labelClip
in (rect, txt, fg, bg,) <$> (rectIntersect area clipRect >>= rectIntersect parentClip)
in mapMaybe tagOne spans
drawTextCaret :: DrawArena -> Float -> Float -> Float -> Color -> IO ()
drawTextCaret da caretX caretY caretH fg =
pushRect da (Rect caretX caretY 1 caretH) fg
drawTextSelectionLine :: DrawArena -> Float -> Float -> Float -> Float -> Color -> IO ()
drawTextSelectionLine da selX selY selW selH selBg =
when (selW > 0) $
pushRect da (Rect selX selY (max 1 selW) (max 4 selH)) selBg
computeTextInputScroll :: FontMetrics -> Float -> Text -> Int -> Float -> Bool -> IO Float
computeTextInputScroll fm viewportW value cursor oldScroll isFocused
| not isFocused = pure 0
| viewportW <= 0 = pure 0
| otherwise = do
caretRelX <- caretXIO fm value cursor
totalTextW <- lineWidthIO fm value
let maxScroll = max 0 (totalTextW + 1 - viewportW)
s0
| caretRelX < oldScroll = caretRelX
| caretRelX + 1 > oldScroll + viewportW = caretRelX + 1 - viewportW
| otherwise = oldScroll
pure (max 0 (min maxScroll s0))
syncTextInputScroll :: Context -> NodeIdx -> Float -> Float -> Float -> Float -> IO Float
syncTextInputScroll ctx idx x y w h = do
si <- getStyleIdx (ctxNodeArena ctx) idx
if textInputSelectableMode si
then pure 0
else do
wid <- getWidgetId (ctxNodeArena ctx) idx
store <- getStore ctx
let key = intKey wid
value <- textInputValue ctx idx
focus <- textInputFocused ctx idx
(_, clip) <- nodeTextFieldGeom ctx idx x y w h
let cursor = IM.findWithDefault (T.length value) (slotKey SlotCursor key) (storeInt store)
oldScroll = IM.findWithDefault 0 (slotKey SlotTextInputScroll key) (storeFloat store)
newScroll <- computeTextInputScroll (ctxFontMetrics ctx) (rectW clip) value cursor oldScroll focus
when (newScroll /= oldScroll) $
setStore ctx (store {storeFloat = IM.insert (slotKey SlotTextInputScroll key) newScroll (storeFloat store)})
pure newScroll
-- | What a focused single-line field paints its selection and caret from: the
-- displayed value, cursor and anchor, the node font, the field box's top and
-- height, and the x its text starts at with the scroll applied.
data FieldEdit = FieldEdit !Text !Int !Int !FontMetrics !Float !Float !Float
-- | Editing state of field @idx@ at @x y w h@ scrolled by @scrollX@ (see
-- 'syncTextInputScroll'), or Nothing while it is unfocused.
readFieldEdit :: Context -> NodeIdx -> Float -> Float -> Float -> Float -> Float -> IO (Maybe FieldEdit)
readFieldEdit ctx idx x y w h scrollX = do
focus <- textInputFocused ctx idx
if not focus
then pure Nothing
else do
value <- textInputValue ctx idx
wid <- getWidgetId (ctxNodeArena ctx) idx
store <- getStore ctx
(Rect _ boxY _ boxH, Rect clipX _ _ _) <- nodeTextFieldGeom ctx idx x y w h
fm <- nodeFontMetrics ctx idx
let key = intKey wid
!cursor = IM.findWithDefault (T.length value) (slotKey SlotCursor key) (storeInt store)
!anchor = IM.findWithDefault cursor (slotKey SlotAnchor key) (storeInt store)
pure $! Just (FieldEdit value cursor anchor fm boxY boxH (clipX - scrollX))
drawTextInputSelection :: DrawArena -> Context -> NodeIdx -> FieldEdit -> IO ()
drawTextInputSelection da ctx idx (FieldEdit value cursor anchor fm boxY boxH textX) = do
let selLo = min anchor cursor
selHi = max anchor cursor
lineH = fmLineHeight fm
when (selLo < selHi) $ do
theme <- nodeTheme ctx idx
prepared <- prepareFontMetrics fm value
forM_ (selectionSpans prepared value selLo selHi) $ \(wLo, wHi) ->
drawTextSelectionLine
da
(textX + wLo)
(centeredTextY fm boxY boxH lineH)
(wHi - wLo)
lineH
(themeSelection theme)
drawTextInputCaret :: DrawArena -> FieldEdit -> Color -> IO ()
drawTextInputCaret da (FieldEdit value cursor _ fm boxY boxH textX) fg = do
let lineH = fmLineHeight fm
pw <- caretXIO fm value cursor
let (caretX, caretY, caretH) =
selectionCaretGeom textX (centeredTextY fm boxY boxH lineH) pw lineH
drawTextCaret da caretX caretY caretH fg
updateTextInputSelection :: Context -> WidgetId -> Int -> Int -> IO ()
updateTextInputSelection ctx wid anchor cursor = do
store <- getStore ctx
let key = intKey wid
oldAnchor = IM.findWithDefault cursor (slotKey SlotAnchor key) (storeInt store)
oldCursor = IM.findWithDefault 0 (slotKey SlotCursor key) (storeInt store)
when (oldAnchor /= anchor || oldCursor /= cursor) $ do
setStore
ctx
( store
{ storeInt =
IM.insert (slotKey SlotAnchor key) anchor $
IM.insert (slotKey SlotCursor key) cursor (storeInt store)
}
)
markDirty ctx
-- | Field box, text origin x (scroll applied), value and font of a single-line
-- field.
textInputGeomForWidget :: Context -> WidgetId -> IO (Maybe (Rect, Float, Text, FontMetrics))
textInputGeomForWidget ctx wid = do
mIdx <- findNodeByWidgetId ctx wid
case mIdx of
Nothing -> pure Nothing
Just idx -> do
nt <- getNodeType (ctxNodeArena ctx) idx
if nt /= NodeTextInput
then pure Nothing
else do
(x, y, w, h) <- getRect (ctxNodeArena ctx) idx
(field, Rect clipX _ _ _) <- nodeTextFieldGeom ctx idx x y w h
scrollX <- syncTextInputScroll ctx idx x y w h
fm <- nodeFontMetrics ctx idx
value <- textInputValue ctx idx
pure (Just (field, clipX - scrollX, value, fm))
-- | Mouse selection in single-line field @wid@: click (with word and line
-- multi-clicks), drag, and the search clear button. False when @wid@ is not a
-- single-line field.
finalizeTextInputMouse :: Context -> Input -> WidgetId -> IO Bool
finalizeTextInputMouse ctx inp wid = do
mGeom <- textInputGeomForWidget ctx wid
case mGeom of
Nothing -> pure False
Just (fieldRect, contentX, value, fm) -> do
let mouse@(V2 mouseX _) = inputMousePos inp
charAt = do
prepared <- prepareFontMetrics fm value
pure (textIndexAtX prepared value (max 0 (mouseX - contentX)))
if inputMousePressed inp && rectContains fieldRect mouse
then do
cleared <- searchClearHit ctx wid mouse
if cleared
then clearSearchField ctx wid
else do
idx <- charAt
clicks <- normalizeTextFieldClicks ctx wid idx 0 0 False (max 1 (inputMouseClicks inp))
uncurry (updateTextInputSelection ctx wid) (textSelectionForClick value idx clicks)
setTextInputDrag ctx (Just (TextInputDrag wid idx 0 0 False clicks))
else do
mDrag <- getsInteraction ctx isTextInputDrag
case mDrag of
Just drag
| textInputDragWidget drag == wid
, not (textInputDragMultiline drag)
, inputMouseDown inp || inputMouseReleased inp -> do
idx <- charAt
uncurry (updateTextInputSelection ctx wid) $
textSelectionForDrag value (textInputDragAnchor drag) idx (textInputDragClicks drag)
_ -> pure ()
pure True
collapseTextInputSelection :: Context -> WidgetId -> IO ()
collapseTextInputSelection ctx wid = do
store <- getStore ctx
let key = intKey wid
cur = IM.findWithDefault 0 (slotKey SlotCursor key) (storeInt store)
setStore ctx (store {storeInt = IM.insert (slotKey SlotAnchor key) cur (storeInt store)})
-- | Count a press as a multi-click only when it lands on the same cell as the
-- previous press; anything else restarts the count at one.
normalizeTextFieldClicks :: Context -> WidgetId -> Int -> Int -> Int -> Bool -> Int -> IO Int
normalizeTextFieldClicks ctx wid flat row col multiline rawClicks = do
let cell =
TextFieldClickCell
{ textFieldClickWidget = wid
, textFieldClickFlat = flat
, textFieldClickRow = row
, textFieldClickCol = col
, textFieldClickMultiline = multiline
}
if rawClicks <= 1
then modifyInteraction ctx (\s -> s {isTextFieldClickCell = Just cell}) >> pure rawClicks
else do
mPrev <- getsInteraction ctx isTextFieldClickCell
if maybe False (sameCell cell) mPrev
then pure rawClicks
else modifyInteraction ctx (\s -> s {isTextFieldClickCell = Just cell}) >> pure 1
where
sameCell a b =
textFieldClickWidget a == textFieldClickWidget b
&& textFieldClickMultiline a == textFieldClickMultiline b
&& if textFieldClickMultiline a
then textFieldClickRow a == textFieldClickRow b && textFieldClickCol a == textFieldClickCol b
else textFieldClickFlat a == textFieldClickFlat b