packages feed

nano-ui-0.1.0.0: lib/NanoUI/Frame/TextEdit/Menu.hs

{-# LANGUAGE DataKinds #-}

-- | Text-field context menu (Cut / Copy / Paste / Select All): opening,
-- picking, painting, spans and cursor.
module NanoUI.Frame.TextEdit.Menu
  ( textEditMenuWidth
  , textEditMenuRectAt
  , openTextEditMenu
  , finalizeTextEditMenuPick
  , closeTextEditMenuOnOutsideClick
  , closeTextEditMenuOnEscape
  , drawTextEditMenuOverlays
  , collectTextEditMenuSpans
  , textEditMenuCursorKind
  , textFieldWidgetAtMouse
  , applyTextFieldMenuAction
  ) where

import Control.Monad (forM, forM_, unless, when)
import Data.IORef (writeIORef)
import qualified Data.IntMap.Strict as IM
import qualified Data.Text as T
import NanoUI.Context
  ( Context (..)
  , TextInputMenu (..)
  , WidgetStore (..)
  , getStore
  , getTextInputMenu
  , intKey
  , isDisabled
  , markDirty
  , markEscapeConsumed
  , setTextInputMenu
  , widgetTheme
  , InteractionState (..)
  , modifyInteraction
  )
import NanoUI.Draw (pushRect, pushText)
import NanoUI.Font
  ( centeredTextY
  , menuItemPadX
  , menuItemRowH
  , menuMinW
  , menuOuterPad
  , menuSepH
  , widgetContentInset
  )
import NanoUI.Frame.Chrome (overlayMenuStyle, paintMenuAccent, paintMenuPanel)
import NanoUI.Frame.Hit (nodeClippedHit, overlayHitAllowed, widgetOverlayAllowed)
import NanoUI.Frame.TextArea.Content (isMouseOnTextAreaScrollBarAt)
import NanoUI.Id (WidgetId)
import NanoUI.Input
  ( Input (..)
  , Key (..)
  , UiCursorKind (..)
  , inputKeys
  , inputKeysElem
  , inputMousePos
  , inputMousePressed
  , inputMouseRightPressed
  , inputWindowSize
  )
import NanoUI.Layout.Arena (NodeType (NodeTextArea, NodeTextInput), findNodeRevM, getNodeType, getRect, getWidgetId)
import NanoUI.Style (Style (..), themeSeparator)
import NanoUI.Types (Color (..), Rect (..), Size (..), V2 (..), lerpColor, rectContains)
import NanoUI.Widgets.TextEditor (EditorMode (..), TextCommand (..), canRedo, canUndo)
import NanoUI.Widgets.TextField (applyTextFieldCommand, textFieldHistory, textFieldMode)

data TextEditMenuRow
  = TextEditMenuSep
  | TextEditMenuItem Int T.Text

-- | The menu's commands, in row order; a row's index is its item number.
textEditMenuCommands :: [TextCommand]
textEditMenuCommands = [Undo, Redo, Cut, Copy, Paste, SelectAll]

textEditMenuRows :: [TextEditMenuRow]
textEditMenuRows =
  [ TextEditMenuItem 0 "Undo"
  , TextEditMenuItem 1 "Redo"
  , TextEditMenuSep
  , TextEditMenuItem 2 "Cut"
  , TextEditMenuItem 3 "Copy"
  , TextEditMenuItem 4 "Paste"
  , TextEditMenuSep
  , TextEditMenuItem 5 "Select All"
  ]

-- Use the same row metrics as generic popup menus.
textEditMenuRowH :: TextEditMenuRow -> Float
textEditMenuRowH = \case
  TextEditMenuSep -> menuSepH
  TextEditMenuItem {} -> menuItemRowH

textEditMenuContentH :: Float
textEditMenuContentH = sum (map textEditMenuRowH textEditMenuRows)

textEditMenuWidth :: Context -> IO Float
textEditMenuWidth ctx = do
  ws <- mapM (fmap fst . ctxMeasureText ctx) [lbl | TextEditMenuItem _ lbl <- textEditMenuRows]
  pure (max menuMinW (maximum ws + 2 * menuItemPadX + 2 * menuOuterPad))

-- | Menu rect at the pointer, kept inside the window.
textEditMenuRectAt :: Float -> Float -> Float -> Size -> Rect
textEditMenuRectAt x y menuW (Size ww wh) =
  let h = 2 * menuOuterPad + textEditMenuContentH
   in Rect (max 0 (min x (ww - menuW))) (max 0 (min y (wh - h))) menuW h

textEditMenuContentRect :: Rect -> Rect
textEditMenuContentRect (Rect x y w _) =
  Rect (x + menuOuterPad) (y + menuOuterPad) (w - 2 * menuOuterPad) textEditMenuContentH

-- | Every row with its band spanning the full menu width.
textEditMenuLayout :: Rect -> [(TextEditMenuRow, Rect)]
textEditMenuLayout menuRect@(Rect mx _ mw _) =
  let Rect _ top _ _ = textEditMenuContentRect menuRect
      go _ [] = []
      go relY (entry : rest) =
        let h = textEditMenuRowH entry
         in (entry, Rect mx (top + relY) mw h) : go (relY + h) rest
   in go 0 textEditMenuRows

textEditMenuPickAction :: Rect -> V2 -> Maybe Int
textEditMenuPickAction menuRect mouse@(V2 _ my) =
  let Rect _ top _ _ = textEditMenuContentRect menuRect
   in if my < top || my >= top + textEditMenuContentH
        then Nothing
        else
          case [entry | (entry, row) <- textEditMenuLayout menuRect, rectContainsY row] of
            TextEditMenuItem action _ : _ -> Just action
            _ -> Nothing
  where
    rectContainsY (Rect _ ry _ rh) = let V2 _ py = mouse in py >= ry && py < ry + rh

textEditMenuItemFg :: Style -> Bool -> Color
textEditMenuItemFg style enabled =
  if enabled
    then styleFg style
    else lerpColor (styleFg style) (styleBg style) 0.55

openTextEditMenu :: Context -> Input -> IO ()
openTextEditMenu ctx inp =
  when (inputMouseRightPressed inp) $ do
    let mouse@(V2 mx my) = inputMousePos inp
    mWid <- textFieldWidgetAtMouse ctx mouse
    case mWid of
      Nothing -> pure ()
      Just wid -> do
        writeIORef (ctxFocusId ctx) wid
        menuW <- textEditMenuWidth ctx
        let menuRect = textEditMenuRectAt mx my menuW (inputWindowSize inp)
        setTextInputMenu ctx (Just (TextInputMenu wid menuRect))
        markDirty ctx

textFieldWidgetAtMouse :: Context -> V2 -> IO (Maybe WidgetId)
textFieldWidgetAtMouse ctx mouse = do
  let na = ctxNodeArena ctx
  mIdx <-
    findNodeRevM na $ \idx -> do
      nt <- getNodeType na idx
      if nt /= NodeTextInput && nt /= NodeTextArea
        then pure False
        else do
          wid <- getWidgetId na idx
          disabled <- isDisabled ctx wid
          if disabled
            then pure False
            else do
              (x, y, w, h) <- getRect na idx
              hit <- nodeClippedHit ctx idx (Rect x y w h) mouse
              if not hit
                then pure False
                else do
                  allowed <- overlayHitAllowed ctx idx mouse
                  if not allowed
                    then pure False
                    else
                      if nt == NodeTextArea
                        then not <$> isMouseOnTextAreaScrollBarAt ctx idx mouse
                        else pure True
  traverse (getWidgetId na) mIdx

finalizeTextEditMenuPick :: Context -> Input -> IO ()
finalizeTextEditMenuPick ctx inp =
  when (inputMousePressed inp) $ do
    mMenu <- getTextInputMenu ctx
    case mMenu of
      Just menu
        | rectContains (textInputMenuRect menu) (inputMousePos inp) ->
            case textEditMenuPickAction (textInputMenuRect menu) (inputMousePos inp) of
              Nothing -> setTextInputMenu ctx Nothing
              Just action -> do
                enabled <- textFieldMenuActionEnabled ctx (textInputMenuWidget menu) action
                if enabled
                  then applyTextFieldMenuAction ctx (textInputMenuWidget menu) action
                  else do
                    setTextInputMenu ctx Nothing
                    markDirty ctx
      _ -> pure ()

closeTextEditMenuOnOutsideClick :: Context -> Input -> IO ()
closeTextEditMenuOnOutsideClick ctx inp =
  when (inputMousePressed inp || inputMouseRightPressed inp) $ do
    mMenu <- getTextInputMenu ctx
    case mMenu of
      Just menu
        | not (rectContains (textInputMenuRect menu) (inputMousePos inp)) ->
            setTextInputMenu ctx Nothing
      _ -> pure ()

closeTextEditMenuOnEscape :: Context -> Input -> IO ()
closeTextEditMenuOnEscape ctx inp =
  when (inputKeysElem KeyEscape (inputKeys inp)) $
    getTextInputMenu ctx >>= \case
      Nothing -> pure ()
      Just _ -> do
        setTextInputMenu ctx Nothing
        markEscapeConsumed ctx
        markDirty ctx

textEditMenuCursorKind :: Context -> Input -> IO (Maybe UiCursorKind)
textEditMenuCursorKind ctx inp = do
  mMenu <- getTextInputMenu ctx
  let mouse = inputMousePos inp
  case mMenu of
    Just menu
      | rectContains (textInputMenuRect menu) mouse
      , Just action <- textEditMenuPickAction (textInputMenuRect menu) mouse -> do
          enabled <- textFieldMenuActionEnabled ctx (textInputMenuWidget menu) action
          pure (Just (if enabled then UiCursorPointer else UiCursorDefault))
    _ -> pure Nothing

drawTextEditMenuOverlays :: Context -> Input -> IO ()
drawTextEditMenuOverlays ctx inp = do
  mMenu <- getTextInputMenu ctx
  forM_ mMenu $ \menu -> do
    let wid = textInputMenuWidget menu
    allow <- widgetOverlayAllowed ctx wid
    when allow $ do
      theme <- widgetTheme ctx wid
      let da = ctxDrawArena ctx
          fm = ctxFontMetrics ctx
          menuRect = textInputMenuRect menu
          style = overlayMenuStyle theme
          Rect contentX _ _ _ = textEditMenuContentRect menuRect
          labelX = contentX + menuItemPadX + fst (widgetContentInset fm)
      paintMenuPanel da theme style menuRect
      forM_ (textEditMenuLayout menuRect) $ \case
        (TextEditMenuSep, Rect rx ry rw rh) ->
          pushRect da (Rect (rx + menuItemPadX) (ry + rh / 2) (rw - 2 * menuItemPadX) 1) (themeSeparator theme)
        (TextEditMenuItem action lbl, row@(Rect _ ry _ rh)) -> do
          enabled <- textFieldMenuActionEnabled ctx wid action
          when (enabled && rectContains row (inputMousePos inp)) $ do
            pushRect da row (styleHoverBg style)
            paintMenuAccent da theme row
          unless (T.null lbl) $ do
            (_, th) <- ctxMeasureText ctx lbl
            pushText da fm labelX (centeredTextY fm ry rh th) lbl (textEditMenuItemFg style enabled)

collectTextEditMenuSpans :: Context -> Input -> IO [(Rect, T.Text, Color, Color, Rect)]
collectTextEditMenuSpans ctx inp = do
  mMenu <- getTextInputMenu ctx
  case mMenu of
    Nothing -> pure []
    Just menu -> do
      let wid = textInputMenuWidget menu
      allow <- widgetOverlayAllowed ctx wid
      if not allow
        then pure []
        else do
          theme <- widgetTheme ctx wid
          let fm = ctxFontMetrics ctx
              menuRect = textInputMenuRect menu
              style = overlayMenuStyle theme
              Rect contentX _ _ _ = textEditMenuContentRect menuRect
              labelX = contentX + menuItemPadX + fst (widgetContentInset fm)
          fmap concat . forM (textEditMenuLayout menuRect) $ \case
            (TextEditMenuSep, _) -> pure []
            (TextEditMenuItem action lbl, row@(Rect _ ry _ rh)) -> do
              enabled <- textFieldMenuActionEnabled ctx wid action
              (tw, th) <- ctxMeasureText ctx lbl
              let bg
                    | enabled && rectContains row (inputMousePos inp) = styleHoverBg style
                    | otherwise = styleBg style
              pure [(Rect labelX (centeredTextY fm ry rh th) tw th, lbl, textEditMenuItemFg style enabled, bg, menuRect)]

applyTextFieldMenuAction :: Context -> WidgetId -> Int -> IO ()
applyTextFieldMenuAction ctx wid item =
  forM_ (take 1 (drop item textEditMenuCommands)) $ \cmd -> do
    modifyInteraction ctx (\s -> s {isTextEditLastAction = Just (wid, cmd)})
    applyTextFieldCommand ctx wid cmd

textFieldMenuActionEnabled :: Context -> WidgetId -> Int -> IO Bool
textFieldMenuActionEnabled ctx wid item = do
  store <- getStore ctx
  mMode <- textFieldMode ctx wid
  history <- textFieldHistory ctx wid
  let hasText = not (T.null (IM.findWithDefault "" (intKey wid) (storeText store)))
  case (mMode, drop item textEditMenuCommands) of
    (Just mode, cmd : _) -> case cmd of
      Undo -> pure (modeEditable mode && canUndo history)
      Redo -> pure (modeEditable mode && canRedo history)
      Cut -> pure (modeEditable mode && modeCopyable mode && hasText)
      Copy -> pure (modeCopyable mode && hasText)
      Paste
        | modeEditable mode -> maybe False (not . T.null) <$> ctxClipboardGet ctx
        | otherwise -> pure False
      _ -> pure hasText
    _ -> pure False