flaw-ui/Flaw/UI/EditBox.hs

Summary

Maintainability
Test Coverage
{-|
Module: Flaw.UI.EditBox
Description: One-line edit box.
License: MIT
-}

module Flaw.UI.EditBox
  ( EditBox(..)
  , newEditBox
  ) where

import Control.Concurrent.STM
import Control.Monad
import Control.Monad.IO.Class
import Data.List
import Data.Maybe
import qualified Data.Text as T

import Flaw.Graphics
import Flaw.Graphics.Canvas
import Flaw.Graphics.Font
import Flaw.Graphics.Font.Render
import Flaw.Input.Keyboard
import Flaw.Input.Mouse
import Flaw.Math
import Flaw.UI
import Flaw.UI.Drawer
import Flaw.UI.Metrics

-- | Edit box.
data EditBox = EditBox
  { editBoxTextVar :: !(TVar T.Text)
  , editBoxTextScriptVar :: !(TVar FontScript)
  , editBoxPasswordModeVar :: !(TVar Bool)
  -- | Start and end position of selection.
  , editBoxSelectionVar :: !(TVar (Int, Int))
  -- | Scroll offset in pixels. Positive means rendered text shifted to the left.
  , editBoxScrollVar :: !(TVar Float)
  , editBoxSizeVar :: !(TVar Size)
  , editBoxLastMousePositionVar :: !(TVar (Maybe Position))
  , editBoxMousePressedVar :: !(TVar Bool)
  , editBoxFocusedVar :: !(TVar Bool)
  , editBoxBlinkVar :: !(TVar Float)
  , editBoxDelayedOpVar :: !(TVar DelayedOp)
  }

-- | Operation delayed to rendering time.
data DelayedOp
  = EmptyDelayedOp
  | SetSelectionEndDelayedOp
  | SetSelectionDelayedOp
  deriving (Eq, Ord)

newEditBox :: STM EditBox
newEditBox = do
  textVar <- newTVar T.empty
  textScriptVar <- newTVar fontScriptUnknown
  passwordModeVar <- newTVar False
  selectionVar <- newTVar (0, 0)
  scrollVar <- newTVar 0
  sizeVar <- newTVar $ Vec2 0 0
  lastMousePositionVar <- newTVar Nothing
  mousePressedVar <- newTVar False
  focusedVar <- newTVar False
  blinkVar <- newTVar 0
  delayedOpVar <- newTVar EmptyDelayedOp
  return EditBox
    { editBoxTextVar = textVar
    , editBoxTextScriptVar = textScriptVar
    , editBoxPasswordModeVar = passwordModeVar
    , editBoxSelectionVar = selectionVar
    , editBoxScrollVar = scrollVar
    , editBoxSizeVar = sizeVar
    , editBoxLastMousePositionVar = lastMousePositionVar
    , editBoxMousePressedVar = mousePressedVar
    , editBoxFocusedVar = focusedVar
    , editBoxBlinkVar = blinkVar
    , editBoxDelayedOpVar = delayedOpVar
    }

instance Element EditBox where

  layoutElement EditBox
    { editBoxSizeVar = sizeVar
    } = writeTVar sizeVar

  dabElement EditBox
    { editBoxSizeVar = sizeVar
    } (Vec2 x y) =
    if x < 0 || y < 0 then return False
    else do
      size <- readTVar sizeVar
      let
        Vec2 sx sy = size
        in return $ x < sx && y < sy

  elementMouseCursor _ = return MouseCursorIBeam

  renderElement EditBox
    { editBoxTextVar = textVar
    , editBoxTextScriptVar = textScriptVar
    , editBoxPasswordModeVar = passwordModeVar
    , editBoxSelectionVar = selectionVar
    , editBoxScrollVar = scrollVar
    , editBoxSizeVar = sizeVar
    , editBoxLastMousePositionVar = lastMousePositionVar
    , editBoxFocusedVar = focusedVar
    , editBoxBlinkVar = blinkVar
    , editBoxDelayedOpVar = delayedOpVar
    } Drawer
    { drawerCanvas = canvas
    , drawerGlyphRenderer = glyphRenderer
    , drawerFrameTimeVar = frameTimeVar
    , drawerStyles = DrawerStyles
      { drawerEditFont = DrawerFont
        { drawerFontRenderableFontCache = renderableFontCache@RenderableFontCache
          { renderableFontCacheMaybeFontVar = maybeFontVar
          }
        , drawerFontShaper = SomeFontShaper fontShaper
        }
      , drawerLoweredStyleVariant = StyleVariant
        { styleVariantNormalStyle = normalStyle
        , styleVariantMousedStyle = mousedStyle
        , styleVariantSelectedFocusedStyle = selectedFocusedStyle
        , styleVariantSelectedUnfocusedStyle = selectedUnfocusedStyle
        }
      }
    } (Vec2 px py) = do
    passwordMode <- readTVar passwordModeVar
    text <- (\text -> if passwordMode then T.map (const '●') text else text) <$> readTVar textVar
    textScript <- readTVar textScriptVar
    size <- readTVar sizeVar
    maybeLastMousePosition <- readTVar lastMousePositionVar
    focused <- readTVar focusedVar
    let
      Vec2 sx sy = size
      moused = isJust maybeLastMousePosition
      style = if moused || focused then mousedStyle else normalStyle

    -- split text according to selection
    (selectionStart, selectionEnd) <- readTVar selectionVar
    let
      selectionMin = min selectionStart selectionEnd
      selectionMax = max selectionStart selectionEnd
      (textBefore, textSelected, textAfter) = splitTextBySelection text selectionMin selectionMax

      selectedStyle = if focused then selectedFocusedStyle else selectedUnfocusedStyle

    -- update blinking phase (only do calculations if we are focused)
    blink <-
      if focused then do
        frameTime <- readTVar frameTimeVar
        let
          blinkPeriod = 1
        oldBlink <- readTVar blinkVar
        let
          blink = snd (properFraction $ oldBlink + frameTime / blinkPeriod :: (Int, Float))
        writeTVar blinkVar blink
        return blink
      else return 0

    return $ do

      -- draw edit box
      drawBorderedRectangle canvas
        (Vec4 px (px + 1) (px + sx - 1) (px + sx))
        (Vec4 py (py + 1) (py + sy - 1) (py + sy))
        (styleFillColor style) (styleBorderColor style)

      -- constrain further rendering
      renderIntersectScissor $ Vec4 (px + 1) (py + 1) (px + sx - 1) (py + sy - 1)

      -- manually shape glyphs
      runs@[beforeRun, selectedRun, _afterRun] <- liftIO $ shapeText fontShaper [textBefore, textSelected, textAfter] textScript

      let
        -- special offset for cursor and selection relative to text
        hackyOffsetX = 1
        selectionMinX = hackyOffsetX + x_ (snd beforeRun)
        selectionMaxX = hackyOffsetX + x_ (snd selectedRun)
        cursorX = if selectionStart < selectionEnd then selectionMaxX else selectionMinX

        -- offset from left side
        textOffsetX = 1

      -- try to get font metrics
      maybeFont <- liftIO $ atomically $ readTMVar maybeFontVar
      let
        Vec4 _boxLeft boxTop _boxRight boxBottom = case maybeFont of
          Just RenderableFont
            { renderableFontMaxGlyphBox = maxGlyphBox
            } -> maxGlyphBox
          Nothing -> Vec4 0 0 0 0

      -- calculate scroll
      scroll <- liftIO $ atomically $ do
        scroll <- readTVar scrollVar
        let
          border = 3
          -- so this should be true: border < textPreX + cursorX < sx - 2 - border
          -- which means: border < textOffsetX - scroll + cursorX < sx - 2 - border
          -- border - textOffsetX - cursorX < -scroll < sx - textOffsetX - cursorX - border - 2
          -- cursorX + textOffsetX - sx + border + 2 < scroll < cursorX + textOffsetX - border
          minScroll = cursorX + textOffsetX - fromIntegral sx + border + 2
          maxScroll = cursorX + textOffsetX - border
        if scroll <= minScroll then do
          writeTVar scrollVar minScroll
          return minScroll
        else if scroll >= maxScroll then do
          let
            newScroll = max 0 $ maxScroll - fromIntegral sx / 3
          writeTVar scrollVar newScroll
          return newScroll
        else return scroll

      let
        textXY@(Vec2 textX _textY) = Vec2 (fromIntegral px + 1 + textOffsetX - scroll) (fromIntegral py + 1 + (fromIntegral (sy - 2) - boxTop - boxBottom) * 0.5)
        selectionTop = py + 2
        selectionBottom = py + sy - 2

        -- function calculating best text split for a given cursor position
        splitTextByX x = do
          let
            calc m = do
              let (a, b) = T.splitAt m text
              [(_, Vec2 cx _cy), _] <- shapeText fontShaper [a, b] textScript
              return cx
            step l r = if l + 1 >= r then return (l, r) else do
              let m = (l + r) `quot` 2
              cx <- calc m
              if x >= cx then step m r else step l m
            len = T.length text
          (l, r) <- step 0 len
          (minimumBy (\a b -> compare (abs $ x - fst a) (abs $ x - fst b)) <$>) . forM (filter (\i -> i >= 0 && i <= len) [(l - 1)..(r + 1)]) $ \i -> do
            cx <- calc i
            return (cx, i)

      -- get position of floating cursor
      maybeFloatingCursor <- case maybeLastMousePosition of
        Just (Vec2 qx _qy) -> (Just <$>) . liftIO $ splitTextByX $ fromIntegral (px + qx) - textX
        Nothing -> return Nothing

      -- process delayed op
      liftIO $ atomically $ do
        delayedOp <- readTVar delayedOpVar
        case delayedOp of
          EmptyDelayedOp -> return ()
          SetSelectionEndDelayedOp -> do
            case maybeFloatingCursor of
              Just (_, floatingCursor) -> do
                writeTVar selectionVar (selectionStart, floatingCursor)
                writeTVar blinkVar 0 -- reset blink
              Nothing -> return ()
            writeTVar delayedOpVar EmptyDelayedOp
          SetSelectionDelayedOp -> do
            case maybeFloatingCursor of
              Just (_, floatingCursor) -> do
                writeTVar selectionVar (floatingCursor, floatingCursor)
                writeTVar blinkVar 0 -- reset blink
              Nothing -> return ()
            writeTVar delayedOpVar EmptyDelayedOp

      -- draw selection
      unless (T.null textSelected) $ drawBorderedRectangle canvas
        (Vec4 (floor $ textX + selectionMinX - 1) (floor $ textX + selectionMinX) (floor $ textX + selectionMaxX + 1) (floor $ textX + selectionMaxX + 2))
        (Vec4 selectionTop (selectionTop + 1) (selectionBottom - 1) selectionBottom)
        (styleFillColor selectedStyle) (styleBorderColor selectedStyle)

      -- draw blinking cursor
      when (blink * 2 < 1) $ drawBorderedRectangle canvas
        (Vec4 (floor $ textX + cursorX) (floor $ textX + cursorX + 1) (floor $ textX + cursorX + 1) (floor $ textX + cursorX + 1))
        (Vec4 selectionTop (selectionTop + 1) (selectionBottom - 1) selectionBottom)
        (styleFillColor selectedStyle) (styleBorderColor selectedStyle)

      -- draw floating cursor
      case maybeFloatingCursor of
        Just (floatingCursorX, _) -> drawBorderedRectangle canvas
          (Vec4 (floor $ textX + floatingCursorX) (floor $ textX + floatingCursorX + 1) (floor $ textX + floatingCursorX + 1) (floor $ textX + floatingCursorX + 1))
          (Vec4 selectionTop (selectionTop + 1) (selectionBottom - 1) selectionBottom)
          (styleFillColor selectedStyle) (styleFillColor selectedStyle)
        Nothing -> return ()

      -- render glyphs
      renderGlyphs glyphRenderer renderableFontCache $
        forM_ (zip runs [styleTextColor style, styleTextColor selectedStyle, styleTextColor style]) $ \((shapedGlyphs, _advance), color) ->
          renderTextRun shapedGlyphs textXY color

  processInputEvent EditBox
    { editBoxTextVar = textVar
    , editBoxSelectionVar = selectionVar
    , editBoxLastMousePositionVar = lastMousePositionVar
    , editBoxMousePressedVar = mousePressedVar
    , editBoxBlinkVar = blinkVar
    , editBoxDelayedOpVar = delayedOpVar
    } inputEvent InputState
    { inputStateKeyboard = keyboardState
    , inputStateGetClipboardText = getClipboardText
    , inputStateSetClipboardText = setClipboardText
    } = case inputEvent of
    KeyboardInputEvent keyboardEvent -> case keyboardEvent of
      KeyDownEvent key -> case key of
        KeyBackSpace -> do
          text <- readTVar textVar
          (selectionStart, selectionEnd) <- readTVar selectionVar
          if selectionStart == selectionEnd then do
            let
              (textBefore, textAfter) = T.splitAt selectionEnd text
            when (T.length textBefore > 0) $ do
              writeTVar textVar $ mappend (T.init textBefore) textAfter
              writeTVar selectionVar (selectionEnd - 1, selectionEnd - 1)
              dontBlink
          else replaceSelection T.empty
          return True
        KeyDelete -> do
          text <- readTVar textVar
          (selectionStart, selectionEnd) <- readTVar selectionVar
          if selectionStart == selectionEnd then do
            let
              (textBefore, textAfter) = T.splitAt selectionEnd text
            when (T.length textAfter > 0) $ do
              writeTVar textVar $ mappend textBefore (T.tail textAfter)
              dontBlink
          else do
            shiftPressed <- isShiftPressed
            -- Shift+Del - cut to clipboard
            when shiftPressed $ setClipboardText =<< getSelectedText
            replaceSelection T.empty
          return True
        KeyLeft -> do
          (_selectionStart, selectionEnd) <- readTVar selectionVar
          moveCursor $ selectionEnd - 1
          return True
        KeyRight -> do
          (_selectionStart, selectionEnd) <- readTVar selectionVar
          moveCursor $ selectionEnd + 1
          return True
        KeyHome -> do
          moveCursor 0
          return True
        KeyEnd -> do
          text <- readTVar textVar
          moveCursor $ T.length text
          return True
        KeyInsert -> do
          controlPressed <- isControlPressed
          shiftPressed <- isShiftPressed
          if controlPressed then
            if shiftPressed then return False
            else do
              -- Ctrl+Ins - copy to clipboard
              setClipboardText =<< getSelectedText
              return True
          else
            if shiftPressed then do
              -- Shift+Ins - paste from clipboard
              getClipboardText replaceSelection
              return True
            else return False
        KeyA -> do
          controlPressed <- isControlPressed
          if controlPressed then do
            -- select all
            text <- readTVar textVar
            writeTVar selectionVar (0, T.length text)
            dontBlink
            return True
          else return False
        KeyC -> do
          controlPressed <- isControlPressed
          if controlPressed then do
            -- Ctrl+C - copy to clipboard
            setClipboardText =<< getSelectedText
            return True
          else return False
        KeyV -> do
          controlPressed <- isControlPressed
          if controlPressed then do
            -- Ctrl+V - paste from clipboard
            getClipboardText replaceSelection
            return True
          else return False
        KeyX -> do
          controlPressed <- isControlPressed
          if controlPressed then do
            -- Ctrl+X - cut to clipboard
            setClipboardText =<< getSelectedText
            replaceSelection T.empty
            return True
          else return False
        _ -> return False
      CharEvent char ->
        -- ignore control characters
        if char > '\x1f' then do
          replaceSelection $ T.singleton char
          return True
        else return False
      _ -> return False
    MouseInputEvent mouseEvent -> case mouseEvent of
      MouseDownEvent LeftMouseButton -> do
        maybeLastMousePosition <- readTVar lastMousePositionVar
        if isJust maybeLastMousePosition then do
          setDelayedOp SetSelectionDelayedOp
          writeTVar mousePressedVar True
          return True
        else return False
      MouseUpEvent LeftMouseButton -> do
        writeTVar mousePressedVar False
        return True
      CursorMoveEvent x y -> do
        let
          mousePosition = Vec2 x y
        writeTVar lastMousePositionVar $ Just mousePosition
        mousePressed <- readTVar mousePressedVar
        when mousePressed $ setDelayedOp SetSelectionEndDelayedOp
        return True
      _ -> return False
    MouseLeaveEvent -> do
      writeTVar lastMousePositionVar Nothing
      writeTVar mousePressedVar False
      return True
    where
      replaceSelection replacementText = do
        text <- readTVar textVar
        (selectionStart, selectionEnd) <- readTVar selectionVar
        let
          (textBefore, _textSelected, textAfter) = splitTextBySelection text selectionStart selectionEnd
        writeTVar textVar $ mconcat [textBefore, replacementText, textAfter]
        let
          newSelection = min selectionStart selectionEnd + T.length replacementText
        writeTVar selectionVar (newSelection, newSelection)
        dontBlink
      moveCursor position = do
        (selectionStart, _selectionEnd) <- readTVar selectionVar
        text <- readTVar textVar
        let
          newSelectionEnd = max 0 $ min (T.length text) position
        shiftPressed <- isShiftPressed
        let
          newSelectionStart = if shiftPressed then selectionStart else newSelectionEnd
        writeTVar selectionVar (newSelectionStart, newSelectionEnd)
        dontBlink
      dontBlink = writeTVar blinkVar 0
      isControlPressed = do
        controlLPressed <- getKeyState keyboardState KeyControlL
        controlRPressed <- getKeyState keyboardState KeyControlR
        return $ controlLPressed || controlRPressed
      isShiftPressed = do
        shiftLPressed <- getKeyState keyboardState KeyShiftL
        shiftRPressed <- getKeyState keyboardState KeyShiftR
        return $ shiftLPressed || shiftRPressed
      getSelectedText = do
        text <- readTVar textVar
        (selectionStart, selectionEnd) <- readTVar selectionVar
        let
          (_textBefore, textSelected, _textAfter) = splitTextBySelection text selectionStart selectionEnd
        return textSelected
      setDelayedOp newOp = do
        oldOp <- readTVar delayedOpVar
        when (newOp > oldOp) $ writeTVar delayedOpVar newOp

  focusElement EditBox
    { editBoxFocusedVar = focusedVar
    , editBoxBlinkVar = blinkVar
    } = do
    writeTVar focusedVar True
    -- reset blinking (it's just more pleasant to see cursor immediately)
    writeTVar blinkVar 0
    return True

  unfocusElement EditBox
    { editBoxFocusedVar = focusedVar
    } = writeTVar focusedVar False

instance HasText EditBox where
  setText EditBox
    { editBoxTextVar = textVar
    , editBoxSelectionVar = selectionVar
    } text = do
    writeTVar textVar text
    writeTVar selectionVar (0, 0)
  setTextScript EditBox
    { editBoxTextScriptVar = textScriptVar
    } = writeTVar textScriptVar
  getText EditBox
    { editBoxTextVar = textVar
    } = readTVar textVar

instance HasPassword EditBox where
  setPasswordMode EditBox
    { editBoxPasswordModeVar = passwordModeVar
    } = writeTVar passwordModeVar

instance HasPreferredSize EditBox where
  preferredSize Metrics
    { metricsMainWidth = width
    , metricsEditBoxHeight = height
    } _ = Vec2 width height

splitTextBySelection :: T.Text -> Int -> Int -> (T.Text, T.Text, T.Text)
splitTextBySelection text start end = (before, selected, after) where
  (before, selectedAndAfter) = T.splitAt (min start end) text
  (selected, after) = T.splitAt (abs $ start - end) selectedAndAfter