packages feed

monomer-1.0.0.0: src/Monomer/Widgets/Singles/NumericField.hs

{-|
Module      : Monomer.Widgets.Singles.NumericField
Copyright   : (c) 2018 Francisco Vallarino
License     : BSD-3-Clause (see the LICENSE file)
Maintainer  : fjvallarino@gmail.com
Stability   : experimental
Portability : non-portable

Input field for numeric types.

Supports instances of the 'FromFractional' typeclass. Several basic types are
implemented, both for integer and floating point types.

Handles mouse wheel and shift + vertical drag to increase/decrease the number.

Configs:

- validInput: field indicating if the current input is valid. Useful to show
warnings in the UI, or disable buttons if needed.
- resizeOnChange: Whether input causes ResizeWidgets requests.
- selectOnFocus: Whether all input should be selected when focus is received.
- minValue: Minimum valid number.
- maxValue: Maximum valid number.
- wheelRate: The rate at which wheel movement affects the number.
- dragRate: The rate at which drag movement affects the number.
- onFocus: event to raise when focus is received.
- onFocusReq: WidgetRequest to generate when focus is received.
- onBlur: event to raise when focus is lost.
- onBlurReq: WidgetRequest to generate when focus is lost.
- onChange: event to raise when the value changes.
- onChangeReq: WidgetRequest to generate when the value changes.
- decimals: the maximum number of digits after the decimal separator. Defaults
to zero for integers and two for floating point types.
-}
{-# LANGUAGE ConstraintKinds #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE UndecidableInstances #-}

module Monomer.Widgets.Singles.NumericField (
  numericField,
  numericField_,
  numericFieldV,
  numericFieldV_
) where

import Control.Applicative ((<|>))
import Control.Lens ((^.), ALens', _1, _2, _3)
import Control.Monad (join)
import Data.Char
import Data.Default
import Data.Either
import Data.List (isPrefixOf)
import Data.Maybe
import Data.Text (Text)
import Data.Text.Read (signed, rational)
import Data.Typeable (Typeable, typeOf)

import qualified Data.Attoparsec.Text as A
import qualified Data.Text as T
import qualified Formatting as F

import Monomer.Core
import Monomer.Core.Combinators
import Monomer.Event.Types
import Monomer.Widgets.Singles.Base.InputField

import qualified Monomer.Lens as L
import qualified Monomer.Widgets.Util.Parser as P

class NumericTextConverter a where
  numericAcceptText :: Maybe a -> Maybe a -> Int -> Text -> (Bool, Bool, Maybe a)
  numericFromText :: Text -> Maybe a
  numericToText :: Int -> a -> Text
  numericToFractional :: Fractional b => a -> Maybe b
  numericFromFractional :: (Real b, Fractional b) => b -> a

instance {-# OVERLAPPABLE #-} FromFractional a => NumericTextConverter a where
  numericAcceptText minVal maxVal decimals text = result where
    accept = acceptNumberInput decimals text
    parsed = numericFromText text
    isValid = isJust parsed && numberInBounds minVal maxVal (fromJust parsed)
    fromText
      | isValid = parsed
      | otherwise = Nothing
    result = (accept, isValid, fromText)
  numericFromText text = case signed rational text of
    Right (frac :: Rational, _) -> Just (fromFractional frac)
    _ -> Nothing
  numericToText decimals value = F.sformat (F.fixed decimals) value
  numericToFractional = Just . realToFrac
  numericFromFractional = fromFractional

instance (FromFractional a, NumericTextConverter a) => NumericTextConverter (Maybe a) where
  numericAcceptText minVal maxVal decimals text
    | T.strip text == "" = (True, True, Just Nothing)
    | otherwise = (accept, isValid, result) where
      resp = numericAcceptText (join minVal) (join maxVal) decimals text
      (accept, isValid, tmpResult) = resp
      result
        | isJust tmpResult = Just tmpResult
        | otherwise = Nothing
  numericFromText = Just . numericFromText
  numericToText _ Nothing = ""
  numericToText decimals (Just value) = numericToText decimals value
  numericToFractional Nothing = Nothing
  numericToFractional (Just value) = numericToFractional value
  numericFromFractional = Just . numericFromFractional

type FormattableNumber a
  = (Eq a, Ord a, Show a, NumericTextConverter a, Typeable a)

data NumericFieldCfg s e a = NumericFieldCfg {
  _nfcCaretWidth :: Maybe Double,
  _nfcCaretMs :: Maybe Int,
  _nfcValid :: Maybe (WidgetData s Bool),
  _nfcValidV :: [Bool -> e],
  _nfcDecimals :: Maybe Int,
  _nfcMinValue :: Maybe a,
  _nfcMaxValue :: Maybe a,
  _nfcWheelRate :: Maybe Double,
  _nfcDragRate :: Maybe Double,
  _nfcResizeOnChange :: Maybe Bool,
  _nfcSelectOnFocus :: Maybe Bool,
  _nfcOnFocusReq :: [Path -> WidgetRequest s e],
  _nfcOnBlurReq :: [Path -> WidgetRequest s e],
  _nfcOnChangeReq :: [a -> WidgetRequest s e]
}

instance Default (NumericFieldCfg s e a) where
  def = NumericFieldCfg {
    _nfcCaretWidth = Nothing,
    _nfcCaretMs = Nothing,
    _nfcValid = Nothing,
    _nfcValidV = [],
    _nfcDecimals = Nothing,
    _nfcMinValue = Nothing,
    _nfcMaxValue = Nothing,
    _nfcWheelRate = Nothing,
    _nfcDragRate = Nothing,
    _nfcResizeOnChange = Nothing,
    _nfcSelectOnFocus = Nothing,
    _nfcOnFocusReq = [],
    _nfcOnBlurReq = [],
    _nfcOnChangeReq = []
  }

instance Semigroup (NumericFieldCfg s e a) where
  (<>) t1 t2 = NumericFieldCfg {
    _nfcCaretWidth = _nfcCaretWidth t2 <|> _nfcCaretWidth t1,
    _nfcCaretMs = _nfcCaretMs t2 <|> _nfcCaretMs t1,
    _nfcValid = _nfcValid t2 <|> _nfcValid t1,
    _nfcValidV = _nfcValidV t1 <> _nfcValidV t2,
    _nfcDecimals = _nfcDecimals t2 <|> _nfcDecimals t1,
    _nfcMinValue = _nfcMinValue t2 <|> _nfcMinValue t1,
    _nfcMaxValue = _nfcMaxValue t2 <|> _nfcMaxValue t1,
    _nfcWheelRate = _nfcWheelRate t2 <|> _nfcWheelRate t1,
    _nfcDragRate = _nfcDragRate t2 <|> _nfcDragRate t1,
    _nfcResizeOnChange = _nfcResizeOnChange t2 <|> _nfcResizeOnChange t1,
    _nfcSelectOnFocus = _nfcSelectOnFocus t2 <|> _nfcSelectOnFocus t1,
    _nfcOnFocusReq = _nfcOnFocusReq t1 <> _nfcOnFocusReq t2,
    _nfcOnBlurReq = _nfcOnBlurReq t1 <> _nfcOnBlurReq t2,
    _nfcOnChangeReq = _nfcOnChangeReq t1 <> _nfcOnChangeReq t2
  }

instance Monoid (NumericFieldCfg s e a) where
  mempty = def

instance CmbCaretWidth (NumericFieldCfg s e a) Double where
  caretWidth w = def {
    _nfcCaretWidth = Just w
  }

instance CmbCaretMs (NumericFieldCfg s e a) Int where
  caretMs ms = def {
    _nfcCaretMs = Just ms
  }

instance CmbValidInput (NumericFieldCfg s e a) s where
  validInput field = def {
    _nfcValid = Just (WidgetLens field)
  }

instance CmbValidInputV (NumericFieldCfg s e a) e where
  validInputV fn = def {
    _nfcValidV = [fn]
  }

instance CmbResizeOnChange (NumericFieldCfg s e a) where
  resizeOnChange_ resize = def {
    _nfcResizeOnChange = Just resize
  }

instance CmbSelectOnFocus (NumericFieldCfg s e a) where
  selectOnFocus_ sel = def {
    _nfcSelectOnFocus = Just sel
  }

instance FormattableNumber a => CmbMinValue (NumericFieldCfg s e a) a where
  minValue len = def {
    _nfcMinValue = Just len
  }

instance FormattableNumber a => CmbMaxValue (NumericFieldCfg s e a) a where
  maxValue len = def {
    _nfcMaxValue = Just len
  }

instance CmbWheelRate (NumericFieldCfg s e a) Double where
  wheelRate rate = def {
    _nfcWheelRate = Just rate
  }

instance CmbDragRate (NumericFieldCfg s e a) Double where
  dragRate rate = def {
    _nfcDragRate = Just rate
  }

instance CmbDecimals (NumericFieldCfg s e a) where
  decimals num = def {
    _nfcDecimals = Just num
  }

instance WidgetEvent e => CmbOnFocus (NumericFieldCfg s e a) e Path where
  onFocus fn = def {
    _nfcOnFocusReq = [RaiseEvent . fn]
  }

instance CmbOnFocusReq (NumericFieldCfg s e a) s e Path where
  onFocusReq req = def {
    _nfcOnFocusReq = [req]
  }

instance WidgetEvent e => CmbOnBlur (NumericFieldCfg s e a) e Path where
  onBlur fn = def {
    _nfcOnBlurReq = [RaiseEvent . fn]
  }

instance CmbOnBlurReq (NumericFieldCfg s e a) s e Path where
  onBlurReq req = def {
    _nfcOnBlurReq = [req]
  }

instance WidgetEvent e => CmbOnChange (NumericFieldCfg s e a) a e where
  onChange fn = def {
    _nfcOnChangeReq = [RaiseEvent . fn]
  }

instance CmbOnChangeReq (NumericFieldCfg s e a) s e a where
  onChangeReq req = def {
    _nfcOnChangeReq = [req]
  }

-- | Creates a numeric field using the given lens.
numericField
  :: (FormattableNumber a, WidgetEvent e)
  => ALens' s a -> WidgetNode s e
numericField field = numericField_ field def

-- | Creates a numeric field using the given lens. Accepts config.
numericField_
  :: (FormattableNumber a, WidgetEvent e)
  => ALens' s a
  -> [NumericFieldCfg s e a]
  -> WidgetNode s e
numericField_ field configs = widget where
  widget = numericFieldD_ (WidgetLens field) configs

-- | Creates a numeric field using the given value and onChange event handler.
numericFieldV
  :: (FormattableNumber a, WidgetEvent e)
  => a -> (a -> e) -> WidgetNode s e
numericFieldV value handler = numericFieldV_ value handler def

-- | Creates a numeric field using the given value and onChange event handler.
-- | Accepts config.
numericFieldV_
  :: (FormattableNumber a, WidgetEvent e)
  => a
  -> (a -> e)
  -> [NumericFieldCfg s e a]
  -> WidgetNode s e
numericFieldV_ value handler configs = newNode where
  widgetData = WidgetValue value
  newConfigs = onChange handler : configs
  newNode = numericFieldD_ widgetData newConfigs

-- | Creates a numeric field providing a WidgetData instance and config.
numericFieldD_
  :: forall s e a . (FormattableNumber a, WidgetEvent e)
  => WidgetData s a
  -> [NumericFieldCfg s e a]
  -> WidgetNode s e
numericFieldD_ widgetData configs = newNode where
  config = mconcat configs
  minVal = _nfcMinValue config
  maxVal = _nfcMaxValue config

  initialValue
    | isJust minVal = fromJust minVal
    | isJust maxVal = fromJust maxVal
    | otherwise = numericFromFractional 0
  decimals
    | isIntegral initialValue = 0
    | otherwise = max 0 $ fromMaybe 2 (_nfcDecimals config)
  defWidth
    | isIntegral initialValue = 50
    | otherwise = 70

  acceptText = numericAcceptText minVal maxVal decimals
  acceptInput text = acceptText text ^. _1
  validInput text = acceptText text ^. _2
  fromText text = acceptText text ^. _3
  toText = numericToText decimals

  inputConfig = InputFieldCfg {
    _ifcPlaceholder = Nothing,
    _ifcInitialValue = initialValue,
    _ifcValue = widgetData,
    _ifcValid = _nfcValid config,
    _ifcValidV = _nfcValidV config,
    _ifcFromText = fromText,
    _ifcToText = toText,
    _ifcAcceptInput = acceptInput,
    _ifcIsValidInput = validInput,
    _ifcDefCursorEnd = False,
    _ifcDefWidth = defWidth,
    _ifcCaretWidth = _nfcCaretWidth config,
    _ifcCaretMs = _nfcCaretMs config,
    _ifcDisplayChar = Nothing,
    _ifcResizeOnChange = fromMaybe False (_nfcResizeOnChange config),
    _ifcSelectOnFocus = fromMaybe True (_nfcSelectOnFocus config),
    _ifcStyle = Just L.numericFieldStyle,
    _ifcWheelHandler = Just (handleWheel config),
    _ifcDragHandler = Just (handleDrag config),
    _ifcDragCursor = Just CursorSizeV,
    _ifcOnFocusReq = _nfcOnFocusReq config,
    _ifcOnBlurReq = _nfcOnBlurReq config,
    _ifcOnChangeReq = _nfcOnChangeReq config
  }
  newNode = inputField_ "numericField" inputConfig

handleWheel
  :: FormattableNumber a
  => NumericFieldCfg s e a
  -> InputFieldState a
  -> Point
  -> Point
  -> WheelDirection
  -> (Text, Int, Maybe Int)
handleWheel config state point move dir = result where
  Point _ dy = move
  sign = if dir == WheelNormal then 1 else -1
  curValue = _ifsCurrValue state
  wheelRate
    | isIntegral curValue = fromMaybe 1 (_nfcWheelRate config)
    | otherwise = fromMaybe 0.1 (_nfcWheelRate config)
  result = handleMove config state wheelRate curValue (dy * sign)

handleDrag
  :: FormattableNumber a
  => NumericFieldCfg s e a
  -> InputFieldState a
  -> Point
  -> Point
  -> (Text, Int, Maybe Int)
handleDrag config state clickPos currPos = result where
  Point _ dy = subPoint clickPos currPos
  selValue = _ifsDragSelValue state
  dragRate
    | isIntegral selValue = fromMaybe 1 (_nfcDragRate config)
    | otherwise = fromMaybe 0.1 (_nfcDragRate config)
  result = handleMove config state dragRate selValue dy

handleMove
  :: forall s e a . FormattableNumber a
  => NumericFieldCfg s e a
  -> InputFieldState a
  -> Double
  -> a
  -> Double
  -> (Text, Int, Maybe Int)
handleMove config state rate value dy = result where
  decimals
    | isIntegral value = 0
    | otherwise = max 0 $ fromMaybe 2 (_nfcDecimals config)
  minVal = _nfcMinValue config
  maxVal = _nfcMaxValue config

  acceptText = numericAcceptText minVal maxVal decimals
  fromText text = acceptText text ^. _3
  toText = numericToText

  (valid, mParsedVal, parsedVal) = case numericToFractional value of
    Just val -> (True, mParsedVal, parsedVal) where
      tmpValue = realToFrac val + dy * rate
      mParsedVal = fromText (toText decimals (numericFromFractional tmpValue))
      parsedVal = fromJust mParsedVal
    Nothing -> (False, Nothing, undefined)
  newVal
    | isJust mParsedVal = parsedVal
    | valid && dy > 0 && isJust maxVal = fromJust maxVal
    | valid && dy < 0 && isJust minVal = fromJust minVal
    | otherwise = _ifsCurrValue state

  newText = toText decimals newVal
  newPos = _ifsCursorPos state
  newSel = _ifsSelStart state
  result = (newText, newPos, newSel)

acceptNumberInput :: Int -> Text -> Bool
acceptNumberInput decimals text = isRight (A.parseOnly parser text) where
  sign = A.option "" (P.single '-')
  number = A.takeWhile isDigit
  digit = T.singleton <$> A.digit
  dot = P.single '.'
  dots = if decimals > 0 then 1 else 0
  rest = P.join [P.upto dots dot, P.upto decimals digit]
  parser = P.join [sign, number, A.option "" rest] <* A.endOfInput

numberInBounds :: Ord a => Maybe a -> Maybe a -> a -> Bool
numberInBounds Nothing Nothing _ = True
numberInBounds (Just minVal) Nothing val = val >= minVal
numberInBounds Nothing (Just maxVal) val = val <= maxVal
numberInBounds (Just minVal) (Just maxVal) val = val >= minVal && val <= maxVal

isIntegral :: Typeable a => a -> Bool
isIntegral val
  | "Int" `isPrefixOf` name = True
  | "Fixed" `isPrefixOf` name = True
  | "Word" `isPrefixOf` name = True
  | otherwise = False
  where
    typeName = show (typeOf val)
    name
      | "Maybe " `isPrefixOf` typeName = drop 6 typeName
      | otherwise = typeName