monomer-1.4.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, with support for valid ranges and decimal places.
@
numericField numericLens
@
With configuration options:
@
numericField_ numericLens [minValue 0, maxValue 100, decimals 2]
@
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.
-}
{-# LANGUAGE ConstraintKinds #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE UndecidableInstances #-}
module Monomer.Widgets.Singles.NumericField (
-- * Configuration
NumericFieldCfg,
FormattableNumber,
NumericTextConverter(..),
-- * Constructors
numericField,
numericField_,
numericFieldV,
numericFieldV_,
numericFieldD_
) 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 TextShow
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
{-|
Converts a numeric instance to and from 'Text'. Implementing this typeclass
is not necessary for instances of 'FromFractional'.
-}
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
-- | Constraints for numeric types accepted by numericField.
type FormattableNumber a
= (Eq a, Ord a, Show a, NumericTextConverter a, Typeable a)
{-|
Configuration options for numericField:
- '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.
- 'readOnly': Whether to prevent the user changing the input text.
- '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.
-}
data NumericFieldCfg s e a = NumericFieldCfg {
_nfcCaretWidth :: Maybe Double,
_nfcCaretMs :: Maybe Millisecond,
_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,
_nfcReadOnly :: 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,
_nfcReadOnly = 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,
_nfcReadOnly = _nfcReadOnly t2 <|> _nfcReadOnly 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) Millisecond 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 CmbReadOnly (NumericFieldCfg s e a) where
readOnly_ ro = def {
_nfcReadOnly = Just ro
}
instance FormattableNumber a => CmbMinValue (NumericFieldCfg s e a) a where
minValue value = def {
_nfcMinValue = Just value
}
instance FormattableNumber a => CmbMaxValue (NumericFieldCfg s e a) a where
maxValue value = def {
_nfcMaxValue = Just value
}
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
readOnly = fromMaybe False (_nfcReadOnly config)
initialValue
| isJust minVal = fromJust minVal
| isJust maxVal = fromJust maxVal
| otherwise = numericFromFractional 0
decimals = case _nfcDecimals config of
Just count -> max 0 count
Nothing
| isIntegral initialValue -> 0
| otherwise -> 2
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),
_ifcReadOnly = readOnly,
_ifcStyle = Just L.numericFieldStyle,
_ifcWheelHandler = if readOnly then Nothing else Just (handleWheel config),
_ifcDragHandler = if readOnly then Nothing else Just (handleDrag config),
_ifcDragCursor = Just CursorSizeV,
_ifcOnFocusReq = _nfcOnFocusReq config,
_ifcOnBlurReq = _nfcOnBlurReq config,
_ifcOnChangeReq = _nfcOnChangeReq config
}
wtype = WidgetType ("numericField-" <> showt (typeOf initialValue))
newNode = inputField_ wtype 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
| "Word" `isPrefixOf` name = True
| otherwise = False
where
typeName = show (typeOf val)
name
| "Maybe " `isPrefixOf` typeName = drop 6 typeName
| otherwise = typeName