nano-ui-0.1.0.0: lib/NanoUI/Frame/TextArea.hs
{-# LANGUAGE DataKinds #-}
-- | Multi-line text areas: content painting (lines, selection, caret and
-- scrollbars) and mouse selection.
module NanoUI.Frame.TextArea
( TextAreaHit (..)
, textAreaHitForWidget
, drawTextAreaContentWith
, finalizeTextAreaMouse
, collapseTextAreaSelection
) where
import Control.Monad (forM_, unless, when)
import Data.IORef (writeIORef)
import qualified Data.IntMap.Strict as IM
import Data.Maybe (catMaybes, isJust)
import qualified Data.Text as T
import NanoUI.Context
( Context (..)
, TextInputDrag (..)
, WidgetStore (..)
, getStore
, intKey
, markDirty
, setStore
, setTextInputDrag
, slotKey
, nodeTheme
, getsInteraction
, InteractionState (..)
)
import NanoUI.Draw (DrawArena, getDrawSnapScale, pushText, withClip)
import NanoUI.Font (FontMetrics, caretXIO, prepareFontMetrics, selectionSpans, textIndexAtX, widgetContentInset)
import NanoUI.Frame.Chrome (paintScrollBarLayout, textInputFocused)
import NanoUI.Frame.Hit (findNodeByWidgetId)
import NanoUI.Frame.TextArea.Content
( ensureTextAreaBuffer
, isMouseOnTextAreaScrollBarAt
, resolveTextAreaFont
, textAreaContentMetrics
)
import NanoUI.Frame.TextArea.Geometry
import NanoUI.Frame.TextInput (drawTextCaret, drawTextSelectionLine, normalizeTextFieldClicks)
import NanoUI.Id (WidgetId)
import NanoUI.Input
( Input (..)
, inputMouseClicks
, inputMouseDown
, inputMousePos
, inputMousePressed
, inputMouseReleased
)
import NanoUI.Layout.Arena (NodeIdx, NodeType (NodeTextArea), getNodeType, getRect, getWidgetId)
import NanoUI.Store (Slot (..))
import NanoUI.Style (Style (..), Theme, scrollBarThumbColor, scrollBarTrackColor, themePanel, themeSelection)
import NanoUI.Types (Rect (..), V2 (..), onGrid, rectContains)
import NanoUI.Widgets.TextArea (TextAreaState (..), loadTextAreaState, saveTextAreaState)
import qualified NanoUI.Widgets.TextArea as TA
import qualified NanoUI.Widgets.TextBuffer as TB
import NanoUI.Widgets.TextCommon (selectionCaretGeom, textWordBounds)
data TextAreaHit = TextAreaHit
{ tahNodeIdx :: !NodeIdx
, tahFieldRect :: !Rect
, tahContentX :: !Float
, tahLineH :: !Float
, tahWidgetX :: !Float
, tahWidgetY :: !Float
, tahWidgetW :: !Float
, tahWidgetH :: !Float
}
-- | Editor state of the text area at @idx@, its viewport set from the field
-- clip.
loadTextAreaStateAt :: Context -> NodeIdx -> FontMetrics -> Float -> Float -> Float -> Float -> IO TA.TextAreaState
loadTextAreaStateAt ctx idx fm x y w h = do
wid <- getWidgetId (ctxNodeArena ctx) idx
let key = intKey wid
store <- getStore ctx
let initial = IM.findWithDefault "" key (storeText store)
buf <- ensureTextAreaBuffer ctx key initial
let Rect _ _ vpW vpH = textAreaFieldClip fm (Rect x y w h)
state0 = TA.loadTextAreaStateWithBuffer store key buf
pure (TA.setTextAreaViewport (realToFrac vpW, realToFrac vpH) (realToFrac (textAreaLineHeight fm)) state0)
loadHitState :: Context -> TextAreaHit -> IO TA.TextAreaState
loadHitState ctx hit = do
fm <- resolveTextAreaFont ctx (tahNodeIdx hit)
loadTextAreaStateAt ctx (tahNodeIdx hit) fm (tahWidgetX hit) (tahWidgetY hit) (tahWidgetW hit) (tahWidgetH hit)
-- | Record the text viewport and clamp the stored scroll to the content.
-- This paint already reflects both, so the write marks nothing dirty: a
-- window resize would otherwise request a second frame that has nothing to
-- repaint.
syncTextAreaViewport :: Context -> NodeIdx -> FontMetrics -> Float -> Float -> Float -> Float -> IO ()
syncTextAreaViewport ctx idx fm x y w h = do
wid <- getWidgetId (ctxNodeArena ctx) idx
(contentW, contentH) <- textAreaContentMetrics ctx idx
-- Read after the metrics query: a cold query caches into the store.
store <- getStore ctx
let key = intKey wid
Rect _ _ clipW clipH = textAreaFieldClip fm (Rect x y w h)
bars = textAreaBars fm (Rect x y w h) contentW contentH
(sx, sy) = IM.findWithDefault (0, 0) (slotKey SlotTextAreaScroll key) (storePoint store)
sx' = max 0 (min (max 0 (contentW - tabViewW bars)) sx)
sy' = max 0 (min (max 0 (contentH - tabViewH bars)) sy)
viewportKey = slotKey SlotTextAreaViewport key
pts0 = IM.insert viewportKey (clipW, clipH) (storePoint store)
pts1
| sx' /= sx || sy' /= sy = IM.insert (slotKey SlotTextAreaScroll key) (sx', sy') pts0
| otherwise = pts0
unless (sx' == sx && sy' == sy && IM.lookup viewportKey (storePoint store) == Just (clipW, clipH)) $
writeIORef (ctxStore ctx) $! store {storePoint = pts1}
-- | Snap a text-area scroll offset to the device pixel grid, the same grid
-- 'pushText' snaps to, so line pens and hit-testing stay in lockstep (and in
-- agreement with each other) while the text area scrolls. The raw 'Double'
-- offset keeps sub-pixel wheel deltas; only the applied value is quantized.
textAreaSnap :: DrawArena -> IO (Float -> Float)
textAreaSnap da = onGrid <$> getDrawSnapScale da
-- | The selection highlight on the rows between @firstRow@ and @lastRow@,
-- the ones in view.
drawTextAreaSelectionLines :: DrawArena -> Int -> Int -> TA.TextAreaState -> Rect -> FontMetrics -> Theme -> IO ()
drawTextAreaSelectionLines da firstRow lastRow state (Rect fieldX fieldY _ _) fm theme = do
snap <- textAreaSnap da
let anchor = TA.selectionAnchor state
cursor = TB.getCursor (TA.buffer state)
when (anchor /= cursor) $ do
let (lo, hi) = TB.selectionRange anchor cursor
lineH = textAreaLineHeight fm
(ix, iy) = widgetContentInset fm
(scrollX, scrollY) = TA.scrollOffset state
scrollXf = snap (realToFrac scrollX)
scrollYf = snap (realToFrac scrollY)
contentTop = fieldY + iy
selBg = themeSelection theme
loRow = TB.cursorRow lo
hiRow = TB.cursorRow hi
forM_ [max loRow firstRow .. min hiRow lastRow] $ \row -> do
let line = TB.lineAt row (TA.buffer state)
clampCol c = max 0 (min (T.length line) c)
startCol = clampCol (if row == loRow then TB.cursorCol lo else 0)
endCol = clampCol (if row == hiRow then TB.cursorCol hi else T.length line)
when (startCol < endCol) $ do
prepared <- prepareFontMetrics fm line
let ly = contentTop + fromIntegral row * lineH - scrollYf
forM_ (selectionSpans prepared line startCol endCol) $ \(wLo, wHi) ->
drawTextSelectionLine da (fieldX + ix + wLo - scrollXf) ly (wHi - wLo) (max 4 lineH) selBg
-- | Text-area content with the node font already resolved, so a paint pass
-- that also needs it (for the field frame) resolves it once.
drawTextAreaContentWith :: DrawArena -> Context -> FontMetrics -> NodeIdx -> Float -> Float -> Float -> Float -> Style -> IO ()
drawTextAreaContentWith da ctx fm idx x y w h style = do
snap <- textAreaSnap da
syncTextAreaViewport ctx idx fm x y w h
focus <- textInputFocused ctx idx
theme <- nodeTheme ctx idx
let field = Rect x y w h
lineH = textAreaLineHeight fm
Rect clipX contentTop clipW clipH = textAreaFieldClip fm field
fg = styleFg style
state <- loadTextAreaStateAt ctx idx fm x y w h
(contentW, contentH) <- textAreaContentMetrics ctx idx
let buf = TA.buffer state
(scrollX, scrollY) = TA.scrollOffset state
scrollXf = snap (realToFrac scrollX)
scrollYf = snap (realToFrac scrollY)
contentX = clipX - scrollXf
layouts = textAreaScrollBarLayouts fm field contentW contentH scrollXf scrollYf
textClip =
Rect
clipX
contentTop
(if isJust (tasbVertical layouts) then max 0 (clipW - textAreaBarLane) else clipW)
(if isJust (tasbHorizontal layouts) then max 0 (clipH - textAreaBarLane) else clipH)
-- Only the rows in view are read, so painting costs the same however
-- long the document is.
rowAt py = floor ((py - contentTop + scrollYf) / max 1 lineH) :: Int
firstRow = max 0 (rowAt y)
lastRow = min (TB.getLineCount buf - 1) (rowAt (y + h))
withClip da textClip $ do
when focus $
drawTextAreaSelectionLines da firstRow lastRow state field fm theme
forM_ [firstRow .. lastRow] $ \row -> do
let line = TB.lineAt row buf
ly = contentTop + fromIntegral row * lineH - scrollYf
unless (T.null line) $
pushText da fm contentX ly line fg
when focus $ do
let TB.Cursor row col = TB.getCursor buf
currentLine = TB.lineAt row buf
pw <- caretXIO fm currentLine col
let (caretX, caretY, caretH) = selectionCaretGeom contentX (contentTop + fromIntegral row * lineH - scrollYf) pw lineH
drawTextCaret da caretX caretY caretH fg
let base = themePanel theme
mapM_
(paintScrollBarLayout da (scrollBarTrackColor base theme) (scrollBarThumbColor base theme))
(catMaybes [tasbVertical layouts, tasbHorizontal layouts])
textAreaHitForWidget :: Context -> WidgetId -> IO (Maybe TextAreaHit)
textAreaHitForWidget ctx wid = do
mIdx <- findNodeByWidgetId ctx wid
case mIdx of
Nothing -> pure Nothing
Just idx -> do
nt <- getNodeType (ctxNodeArena ctx) idx
if nt /= NodeTextArea
then pure Nothing
else do
(x, y, w, h) <- getRect (ctxNodeArena ctx) idx
fm <- resolveTextAreaFont ctx idx
let field = Rect x y w h
Rect clipX _ _ _ = textAreaFieldClip fm field
pure
( Just
TextAreaHit
{ tahNodeIdx = idx
, tahFieldRect = field
, tahContentX = clipX
, tahLineH = textAreaLineHeight fm
, tahWidgetX = x
, tahWidgetY = y
, tahWidgetW = w
, tahWidgetH = h
}
)
textAreaCursorAt :: Context -> TA.TextAreaState -> TextAreaHit -> V2 -> IO (Int, Int)
textAreaCursorAt ctx state hit (V2 mouseX mouseY) = do
snap <- textAreaSnap (ctxDrawArena ctx)
fm <- resolveTextAreaFont ctx (tahNodeIdx hit)
let buf = TA.buffer state
lineCount = max 1 (TB.getLineCount buf)
(scrollX, scrollY) = TA.scrollOffset state
scrollXf = snap (realToFrac scrollX)
scrollYf = snap (realToFrac scrollY)
(_, iy) = widgetContentInset fm
Rect _ fieldY _ _ = tahFieldRect hit
relY = mouseY - (fieldY + iy) + scrollYf
row = max 0 (min (lineCount - 1) (floor (relY / max 1 (tahLineH hit))))
line = TB.lineAt row buf
prepared <- prepareFontMetrics fm line
pure (row, textIndexAtX prepared line (max 0 (mouseX - (tahContentX hit - scrollXf))))
updateTextAreaSelection :: Context -> WidgetId -> TextAreaHit -> TB.Cursor -> TB.Cursor -> IO ()
updateTextAreaSelection ctx wid hit anchor cursor = do
state0 <- loadHitState ctx hit
store <- getStore ctx
-- A selection change keeps the stored text, so the document is not rejoined.
let key = intKey wid
text = IM.findWithDefault "" key (storeText store)
setStore ctx (TA.saveTextAreaState key text (TA.setTextAreaSelection anchor cursor state0) store)
markDirty ctx
applyTextAreaClick :: Context -> WidgetId -> TextAreaHit -> Int -> Int -> Int -> IO ()
applyTextAreaClick ctx wid hit row col clicks
| clicks >= 3 = do
state <- loadHitState ctx hit
updateTextAreaSelection ctx wid hit (TB.Cursor 0 0) (TB.documentEnd (TA.buffer state))
| clicks == 2 = do
state <- loadHitState ctx hit
let (lo, hi) = textWordBounds (TB.lineAt row (TA.buffer state)) col
updateTextAreaSelection ctx wid hit (TB.Cursor row lo) (TB.Cursor row hi)
| otherwise =
updateTextAreaSelection ctx wid hit (TB.Cursor row col) (TB.Cursor row col)
applyTextAreaDrag :: Context -> WidgetId -> TextAreaHit -> Int -> Int -> Int -> Int -> Int -> IO ()
applyTextAreaDrag ctx wid hit anchorRow anchorCol row col clicks
| clicks >= 3 = applyTextAreaClick ctx wid hit row col clicks
| clicks == 2 = do
state <- loadHitState ctx hit
let buf = TA.buffer state
(a0, a1) = textWordBounds (TB.lineAt anchorRow buf) anchorCol
(c0, c1) = textWordBounds (TB.lineAt row buf) col
updateTextAreaSelection ctx wid hit (TB.Cursor anchorRow (min a0 c0)) (TB.Cursor row (max a1 c1))
| otherwise =
updateTextAreaSelection ctx wid hit (TB.Cursor anchorRow anchorCol) (TB.Cursor row col)
-- | Mouse selection in text area @wid@: press (with word and document
-- multi-clicks) and drag. Presses on the scrollbars are left to the scroller.
finalizeTextAreaMouse :: Context -> Input -> WidgetId -> IO ()
finalizeTextAreaMouse ctx inp wid = do
mHit <- textAreaHitForWidget ctx wid
case mHit of
Nothing -> pure ()
Just hit -> do
let mouse = inputMousePos inp
onScroll <- isMouseOnTextAreaScrollBarAt ctx (tahNodeIdx hit) mouse
let cursorAtMouse = do
state <- loadHitState ctx hit
textAreaCursorAt ctx state hit mouse
if inputMousePressed inp && rectContains (tahFieldRect hit) mouse && not onScroll
then do
(row, col) <- cursorAtMouse
clicks <- normalizeTextFieldClicks ctx wid 0 row col True (max 1 (inputMouseClicks inp))
applyTextAreaClick ctx wid hit row col clicks
setTextInputDrag ctx (Just (TextInputDrag wid 0 row col True clicks))
else do
mDrag <- getsInteraction ctx isTextInputDrag
case mDrag of
Just drag
| textInputDragWidget drag == wid
, textInputDragMultiline drag
, inputMouseDown inp || inputMouseReleased inp -> do
(row, col) <- cursorAtMouse
applyTextAreaDrag
ctx
wid
hit
(textInputDragAnchorRow drag)
(textInputDragAnchorCol drag)
row
col
(textInputDragClicks drag)
_ -> pure ()
collapseTextAreaSelection :: Context -> WidgetId -> IO ()
collapseTextAreaSelection ctx wid = do
store <- getStore ctx
let key = intKey wid
text = IM.findWithDefault "" key (storeText store)
row = IM.findWithDefault 0 (slotKey SlotTextAreaRow key) (storeInt store)
col = IM.findWithDefault 0 (slotKey SlotTextAreaCol key) (storeInt store)
state = loadTextAreaState store key text
setStore ctx (saveTextAreaState key text state {selectionAnchor = TB.Cursor row col} store)