packages feed

nano-ui-0.1.0.0: lib/NanoUI/Widgets/NumericInput.hs

{-# LANGUAGE OverloadedStrings #-}

-- | Numeric field: a text field that only accepts numbers, with an up / down
-- stepper, arrow-key steps, and an optional hexadecimal mode.
module NanoUI.Widgets.NumericInput
  ( NumericInputConfig (..)
  , defaultNumericInputConfig
  , numericInput
  , numericInput'
  , numericInputConfigured
  , numericInputConfigured'
  )
where

import Control.Monad (when)
import Data.Char (isDigit, isHexDigit)
import Data.IntMap.Strict qualified as IM
import Data.Maybe (fromMaybe)
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.Read qualified as TR
import Effectful (Eff, type (:>))
import GHC.Clock (getMonotonicTime)
import NanoUI.Context (getStore, intKey, markDirty, registerFocusable, modifyStore)
import NanoUI.Input (Key (..), inputKeys, inputKeysElem, inputModifiers, inputMouseDown, inputMousePos, inputMousePressed, modShift)
import NanoUI.Layout.Arena (NodeType (..))
import NanoUI.Monad (Ui, askContext, askInput, nextId, uiIO)
import NanoUI.Store (WidgetStore (..), slotKey, Slot (..))
import NanoUI.Style (Layout (..), Sizing (..), defaultLayout)
import NanoUI.Types (Rect (..), rectContains)
import NanoUI.WidgetText (numericStepperRects, textInputFlagNumeric)
import NanoUI.Widgets.Behavior (keyboardFocused)
import NanoUI.Widgets.Node (Response, addWidgetStyled, respHovered, respRect, setChanged, setSubmitted)
import NanoUI.Widgets.TextEditor (singleLineMode)
import NanoUI.Widgets.TextInput (TextInputState (..), editTextInput, editorTextState, loadTextInputState, saveTextEditor, saveTextInputState)
import Numeric (showFFloat, showHex)

-- | How a numeric field reads, shows, and steps its value.
data NumericInputConfig = NumericInputConfig
  { nicMin :: !Double
    -- ^ Smallest value (default: no limit).
  , nicMax :: !Double
    -- ^ Largest value (default: no limit).
  , nicStep :: !Double
    -- ^ What one arrow key or stepper click adds (default 1). Shift steps ten
    -- times as far.
  , nicDecimals :: !Int
    -- ^ Digits after the decimal point, both shown and accepted (default 0).
  , nicHex :: !Bool
    -- ^ Show and accept the value as a whole hexadecimal number (default
    -- 'False'). 'nicDecimals' is ignored in this mode.
  , nicLayout :: !Layout
  }
  deriving (Eq, Show)

defaultNumericInputConfig :: NumericInputConfig
defaultNumericInputConfig =
  NumericInputConfig
    { nicMin = -1 / 0
    , nicMax = 1 / 0
    , nicStep = 1
    , nicDecimals = 0
    , nicHex = False
    , nicLayout = defaultLayout {layoutWidth = Grow 1, layoutMinW = 80}
    }

-- | Numeric field over whole numbers. Pass the current value; the result is
-- the value after this frame's typing, arrow keys, and stepper clicks.
--
-- Only digits, and a leading minus sign when the range reaches below zero, can
-- be typed. Up and Down step the value, Shift steps ten times as far, and
-- holding a stepper arrow repeats. Enter, a step, or leaving the field rewrites
-- the text as the value, clamped to the range.
{-# INLINE numericInput #-}
numericInput :: Ui :> es => Double -> Eff es Double
numericInput value = snd <$> numericInputConfigured' defaultNumericInputConfig value

{-# INLINE numericInput' #-}
numericInput' :: Ui :> es => Double -> Eff es (Response, Double)
numericInput' = numericInputConfigured' defaultNumericInputConfig

-- | 'numericInput' with a range, a step, decimal places, hexadecimal mode, or
-- its own layout.
--
-- @
-- byte' <- numericInputConfigured defaultNumericInputConfig {nicMin = 0, nicMax = 255, nicHex = True} byte
-- @
{-# INLINE numericInputConfigured #-}
numericInputConfigured :: Ui :> es => NumericInputConfig -> Double -> Eff es Double
numericInputConfigured cfg value = snd <$> numericInputConfigured' cfg value

numericInputConfigured' :: Ui :> es => NumericInputConfig -> Double -> Eff es (Response, Double)
numericInputConfigured' cfg value = do
  wid <- nextId
  ctx <- askContext
  inp <- askInput
  uiIO $ registerFocusable ctx wid
  store <- uiIO (getStore ctx)
  isFocus <- keyboardFocused wid
  let
    key = intKey wid
    given = clampNumber cfg value
    stored = IM.lookup key (storeText store)
    -- Unfocused, the field shows the caller's value; focused, it keeps the
    -- text being typed.
    text0 = if isFocus then fromMaybe (formatNumber cfg given) stored else formatNumber cfg given
    s0 = loadTextInputState store key text0
    lastValue = IM.findWithDefault given key (storeDouble store)
  mEdited <- if isFocus then uiIO (editTextInput ctx singleLineMode inp store key s0) else pure Nothing
  resp <- addWidgetStyled wid NodeTextInput "" 0 (nicLayout cfg) textInputFlagNumeric
  let
    -- An edit that would leave text no number can start with is dropped.
    typed = maybe s0 editorTextState mEdited
    s1 = if acceptsNumberText cfg (tisText typed) then typed else s0
    current
      | isFocus = maybe lastValue (clampNumber cfg) (parseNumber cfg (tisText s1))
      | otherwise = given
    Rect rx ry rw rh = respRect resp
    (upRect, downRect) = numericStepperRects rx ry rw rh
    mouse = inputMousePos inp
    keys = inputKeys inp
    over r dir = if respHovered resp && rectContains r mouse then dir else 0
    pressDir
      | inputMousePressed inp = over upRect 1 + over downRect (-1)
      | otherwise = 0 :: Int
    held0 = IM.findWithDefault 0 (slotKey SlotNumericHeld key) (storeInt store)
    holding =
      held0 /= 0
        && inputMouseDown inp
        && over (if held0 > 0 then upRect else downRect) held0 /= 0
    keyDir
      | not isFocus = 0
      | inputKeysElem KeyUp keys = 1
      | inputKeysElem KeyDown keys = -1
      | otherwise = 0
  now <- if pressDir /= 0 || holding then uiIO getMonotonicTime else pure 0
  let
    repeatAt0 = IM.findWithDefault 0 (slotKey SlotNumericRepeat key) (storeDouble store)
    -- A held arrow repeats after a pause.
    repeatDir = if pressDir == 0 && holding && now >= repeatAt0 then held0 else 0
    dir
      | pressDir /= 0 = pressDir
      | repeatDir /= 0 = repeatDir
      | otherwise = keyDir
    held1
      | pressDir /= 0 = pressDir
      | holding = held0
      | otherwise = 0
    repeatAt1
      | pressDir /= 0 = now + 0.4
      | repeatDir /= 0 = now + 0.06
      | held1 == 0 = 0
      | otherwise = repeatAt0
    scale = if modShift (inputModifiers inp) then 10 else 1
    final
      | dir /= 0 = clampNumber cfg (roundNumber cfg (current + fromIntegral dir * scale * nicStep cfg))
      | otherwise = current
    submitted = isFocus && inputKeysElem KeyEnter keys
    -- A step or Enter rewrites the text as the value, caret at its end.
    s2
      | dir /= 0 || submitted =
          let t = formatNumber cfg final
           in TextInputState t (T.length t) (T.length t)
      | otherwise = s1
    heldK = slotKey SlotNumericHeld key
    repeatK = slotKey SlotNumericRepeat key
    dirty =
      stored /= Just (tisText s2)
        || s2 /= s0
        || IM.lookup key (storeDouble store) /= Just final
        || held1 /= held0
        || repeatAt1 /= repeatAt0
  when dirty $
    uiIO $ do
      -- An accepted edit keeps its undo history; a rejected one or a step
      -- rewrites the text without it.
      let save = case mEdited of
            Just ed | editorTextState ed == s2 -> saveTextEditor key ed
            _ -> saveTextInputState key s2
      modifyStore ctx $ \st0 ->
        let st = save st0
         in st
              { storeInt = (if held1 == 0 then IM.delete heldK else IM.insert heldK held1) (storeInt st)
              , storeDouble = IM.insert key final (IM.insert repeatK repeatAt1 (storeDouble st))
              }
  -- Keep frames coming while an arrow is held so it can repeat.
  when (held1 /= 0) $ uiIO (markDirty ctx)
  pure (setSubmitted submitted (setChanged (final /= value) resp), final)

clampNumber :: NumericInputConfig -> Double -> Double
clampNumber cfg = max (nicMin cfg) . min (nicMax cfg)

-- | The value rounded to what the field shows.
roundNumber :: NumericInputConfig -> Double -> Double
roundNumber cfg v
  | nicHex cfg || nicDecimals cfg <= 0 = fromInteger (round v)
  | otherwise = fromInteger (round (v * scale)) / scale
  where
    scale = 10 ^ nicDecimals cfg

formatNumber :: NumericInputConfig -> Double -> Text
formatNumber cfg v
  | nicHex cfg =
      let n = round v :: Integer
       in (if n < 0 then "-" else "") <> T.toUpper (T.pack (showHex (abs n) ""))
  | nicDecimals cfg <= 0 = T.pack (show (round v :: Integer))
  | otherwise = T.pack (showFFloat (Just (nicDecimals cfg)) v "")

-- | Whether @t@ can stand in the field mid-edit: an optional minus sign when
-- the range reaches below zero, then digits (hexadecimal ones in hex mode),
-- and with decimal places, one point followed by at most that many digits.
acceptsNumberText :: NumericInputConfig -> Text -> Bool
acceptsNumberText cfg t =
  signOk && case T.splitOn "." body of
    [whole] -> T.all digit whole
    [whole, frac] -> decimals > 0 && T.all isDigit whole && T.all isDigit frac && T.length frac <= decimals
    _ -> False
  where
    (negative, body) = maybe (False, t) ((,) True) (T.stripPrefix "-" t)
    signOk = not negative || nicMin cfg < 0
    digit = if nicHex cfg then isHexDigit else isDigit
    decimals = if nicHex cfg then 0 else nicDecimals cfg

-- | The value the text reads as, once it reads as one.
parseNumber :: NumericInputConfig -> Text -> Maybe Double
parseNumber cfg t
  | nicHex cfg = case TR.signed TR.hexadecimal t of
      Right (n, rest) | T.null rest -> Just (fromInteger n)
      _ -> Nothing
  | otherwise = case TR.signed TR.rational (T.dropWhileEnd (== '.') t) of
      Right (v, rest) | T.null rest -> Just v
      _ -> Nothing