packages feed

monomer-1.2.0.0: src/Monomer/Widgets/Singles/Dial.hs

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

Dial widget, used for interacting with numeric values. It allows changing the
value by keyboard arrows, dragging the mouse or using the wheel.

Similar in objective to "Monomer.Widgets.Singles.Slider", but uses less space.
-}
{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE ConstraintKinds #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE StrictData #-}

module Monomer.Widgets.Singles.Dial (
  -- * Configuration
  DialValue,
  DialCfg,
  -- * Constructors
  dial,
  dial_,
  dialV,
  dialV_
) where

import Control.Applicative ((<|>))
import Control.Lens (ALens', (&), (^.), (.~), (<>~))
import Control.Monad
import Data.Default
import Data.Maybe
import Data.Text (Text)
import Data.Typeable (Typeable)
import GHC.Generics

import qualified Data.Sequence as Seq

import Monomer.Helper
import Monomer.Widgets.Single

import qualified Monomer.Lens as L

-- | Constraints for numeric types accepted by dial.
type DialValue a = (Eq a, Show a, Real a, FromFractional a, Typeable a)

{-|
Configuration options for dial:

- 'width': sets the max width/height of the dial.
- '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.
-}
data DialCfg s e a = DialCfg {
  _dlcWidth :: Maybe Double,
  _dlcWheelRate :: Maybe Rational,
  _dlcDragRate :: Maybe Rational,
  _dlcOnFocusReq :: [Path -> WidgetRequest s e],
  _dlcOnBlurReq :: [Path -> WidgetRequest s e],
  _dlcOnChangeReq :: [a -> WidgetRequest s e]
}

instance Default (DialCfg s e a) where
  def = DialCfg {
    _dlcWidth = Nothing,
    _dlcWheelRate = Nothing,
    _dlcDragRate = Nothing,
    _dlcOnFocusReq = [],
    _dlcOnBlurReq = [],
    _dlcOnChangeReq = []
  }

instance Semigroup (DialCfg s e a) where
  (<>) t1 t2 = DialCfg {
    _dlcWidth = _dlcWidth t2 <|> _dlcWidth t1,
    _dlcWheelRate = _dlcWheelRate t2 <|> _dlcWheelRate t1,
    _dlcDragRate = _dlcDragRate t2 <|> _dlcDragRate t1,
    _dlcOnFocusReq = _dlcOnFocusReq t1 <> _dlcOnFocusReq t2,
    _dlcOnBlurReq = _dlcOnBlurReq t1 <> _dlcOnBlurReq t2,
    _dlcOnChangeReq = _dlcOnChangeReq t1 <> _dlcOnChangeReq t2
  }

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

instance CmbWheelRate (DialCfg s e a) Rational where
  wheelRate rate = def {
    _dlcWheelRate = Just rate
  }

instance CmbDragRate (DialCfg s e a) Rational where
  dragRate rate = def {
    _dlcDragRate = Just rate
  }

instance CmbWidth (DialCfg s e a) where
  width w = def {
    _dlcWidth = Just w
  }

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

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

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

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

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

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

data DialState = DialState {
  _dlsMaxPos :: Integer,
  _dlsPos :: Integer
} deriving (Eq, Show, Generic)

-- | Creates a dial using the given lens, providing minimum and maximum values.
dial
  :: (DialValue a, WidgetEvent e)
  => ALens' s a
  -> a
  -> a
  -> WidgetNode s e
dial field minVal maxVal = dial_ field minVal maxVal def

{-|
Creates a dial using the given lens, providing minimum and maximum values.
Accepts config.
-}
dial_
  :: (DialValue a, WidgetEvent e)
  => ALens' s a
  -> a
  -> a
  -> [DialCfg s e a]
  -> WidgetNode s e
dial_ field minVal maxVal cfgs = dialD_ (WidgetLens field) minVal maxVal cfgs

{-|
Creates a dial using the given value and 'onChange' event handler, providing
minimum and maximum values.
-}
dialV
  :: (DialValue a, WidgetEvent e)
  => a
  -> (a -> e)
  -> a
  -> a
  -> WidgetNode s e
dialV value handler minVal maxVal = dialV_ value handler minVal maxVal def

{-|
Creates a dial using the given value and 'onChange' event handler, providing
minimum and maximum values.
Accepts config.
-}
dialV_
  :: (DialValue a, WidgetEvent e)
  => a
  -> (a -> e)
  -> a
  -> a
  -> [DialCfg s e a]
  -> WidgetNode s e
dialV_ value handler minVal maxVal configs = newNode where
  widgetData = WidgetValue value
  newConfigs = onChange handler : configs
  newNode = dialD_ widgetData minVal maxVal newConfigs

{-|
Creates a dial providing a 'WidgetData' instance, minimum and maximum values and
config.
-}
dialD_
  :: (DialValue a, WidgetEvent e)
  => WidgetData s a
  -> a
  -> a
  -> [DialCfg s e a]
  -> WidgetNode s e
dialD_ widgetData minVal maxVal configs = dialNode where
  config = mconcat configs
  state = DialState 0 0
  widget = makeDial widgetData minVal maxVal config state
  dialNode = defaultWidgetNode "dial" widget
    & L.info . L.focusable .~ True

makeDial
  :: (DialValue a, WidgetEvent e)
  => WidgetData s a
  -> a
  -> a
  -> DialCfg s e a
  -> DialState
  -> Widget s e
makeDial !field !minVal !maxVal !config !state = widget where
  widget = createSingle state def {
    singleFocusOnBtnPressed = False,
    singleGetBaseStyle = getBaseStyle,
    singleGetCurrentStyle = getCurrentStyle,
    singleInit = init,
    singleMerge = merge,
    singleFindByPoint = findByPoint,
    singleHandleEvent = handleEvent,
    singleGetSizeReq = getSizeReq,
    singleRender = render
  }

  dragRate
    | isJust (_dlcDragRate config) = fromJust (_dlcDragRate config)
    | otherwise = toRational (maxVal - minVal) / 1000

  getBaseStyle wenv node = Just style where
    style = collectTheme wenv L.dialStyle

  getCurrentStyle wenv node = style where
    (_, dialArea) = getDialInfo wenv node config
    style = currentStyle_ (currentStyleConfig dialArea) wenv node

  init wenv node = resultNode resNode where
    newState = newStateFromModel wenv node state
    resNode = node
      & L.widget .~ makeDial field minVal maxVal config newState

  merge wenv newNode oldNode oldState = resultNode resNode where
    newState
      | isNodePressed wenv newNode = oldState
      | otherwise = newStateFromModel wenv newNode oldState
    resNode = newNode
      & L.widget .~ makeDial field minVal maxVal config newState

  findByPoint wenv node path point
    | isVisible && pointInEllipse point dialArea = Just wni
    | otherwise = Nothing
    where
      isVisible = node ^. L.info . L.visible
      wni = node ^. L.info
      (_, dialArea) = getDialInfo wenv node config

  handleEvent wenv node target evt = case evt of
    Focus prev -> handleFocusChange node prev (_dlcOnFocusReq config)

    Blur next -> handleFocusChange node next (_dlcOnBlurReq config)

    KeyAction mod code KeyPressed
      | ctrlPressed && isKeyUp code -> handleNewPos (pos + warpSpeed)
      | ctrlPressed && isKeyDown code -> handleNewPos (pos - warpSpeed)
      | shiftPressed && isKeyUp code -> handleNewPos (pos + baseSpeed)
      | shiftPressed && isKeyDown code -> handleNewPos (pos - baseSpeed)
      | isKeyUp code -> handleNewPos (pos + fastSpeed)
      | isKeyDown code -> handleNewPos (pos - fastSpeed)
      where
        DialState maxPos pos = state
        ctrlPressed = isShortCutControl wenv mod
        baseSpeed = max 1 $ round (fromIntegral maxPos / 1000)
        fastSpeed = max 1 $ round (fromIntegral maxPos / 100)
        warpSpeed = max 1 $ round (fromIntegral maxPos / 10)
        vPos pos = clamp 0 maxPos pos
        newResult !newPos = addReqsEvts (resultNode newNode) newVal where
          newVal = valueFromPos minVal dragRate newPos
          !newState = state { _dlsPos = newPos }
          !newNode = node
            & L.widget .~ makeDial field minVal maxVal config newState
        handleNewPos !newPos
          | vPos newPos /= pos = Just $ newResult (vPos newPos)
          | otherwise = Nothing

    Move point
      | isNodePressed wenv node -> Just result where
        (_, start) = fromJust $ wenv ^. L.mainBtnPress
        (_, newVal) = posFromPoint minVal maxVal state dragRate start point
        result = addReqsEvts (resultReqs node [RenderOnce]) newVal

    ButtonAction point btn BtnPressed clicks
      | not (isNodeFocused wenv node) && not shiftPressed -> Just result where
        result = resultReqs node [SetFocus widgetId]

    ButtonAction point btn BtnReleased clicks -> Just result where
      reqs = [RenderOnce]
      newState = newStateFromModel wenv node state
      newNode = node
        & L.widget .~ makeDial field minVal maxVal config newState
      result = resultReqs newNode reqs

    WheelScroll _ (Point _ wy) wheelDirection -> Just result where
      DialState maxPos pos = state
      wheelCfg = fromMaybe (theme ^. L.sliderWheelRate) (_dlcWheelRate config)
      wheelRate = fromRational wheelCfg
      tmpPos = pos + round (wy * wheelRate)
      newPos = clamp 0 maxPos tmpPos
      newVal = valueFromPos minVal dragRate newPos
      reqs = [RenderOnce, IgnoreParentEvents]
      result = addReqsEvts (resultReqs node reqs) newVal
    _ -> Nothing
    where
      theme = currentTheme wenv node
      (_, dialArea) = getDialInfo wenv node config
      widgetId = node ^. L.info . L.widgetId
      path = node ^. L.info . L.path

      shiftPressed = wenv ^. L.inputStatus . L.keyMod . L.leftShift
      isSelectKey code = isKeyReturn code || isKeySpace code
      addReqsEvts result newVal = newResult where
        currVal = widgetDataGet (wenv ^. L.model) field
        reqs = widgetDataSet field newVal
          ++ fmap ($ newVal) (_dlcOnChangeReq config)
        newResult
          | currVal /= newVal = result
              & L.requests <>~ Seq.fromList reqs
          | otherwise = result

  getSizeReq wenv node = req where
    theme = currentTheme wenv node
    width = fromMaybe (theme ^. L.dialWidth) (_dlcWidth config)
    req = (fixedSize width, fixedSize width)

  render wenv node renderer = do
    drawArcBorder renderer dialArea start endSnd CW (Just sndColor) dialBW
    drawArcBorder renderer dialArea start endFg CW (Just fgColor) dialBW
    where
      (dialCenter, dialArea) = getDialInfo wenv node config
      DialState maxPos pos = newStateFromModel wenv node state
      posPct = fromIntegral pos / fromIntegral maxPos
      dialBW = max 1 (_rW dialArea * 0.15)
      style = getCurrentStyle wenv node
      fgColor = styleFgColor style
      sndColor = styleSndColor style
      start = 90 + 45
      endFg = start + 270 * posPct
      endSnd = 45

  newStateFromModel wenv node oldState = newState where
    currVal = widgetDataGet (wenv ^. L.model) field
    newMaxPos = round (toRational (maxVal - minVal) / dragRate)
    newPos = round (toRational (currVal - minVal) / dragRate)
    newState = oldState {
      _dlsMaxPos = newMaxPos,
      _dlsPos = newPos
    }

posFromPoint
  :: DialValue a
  => a
  -> a
  -> DialState
  -> Rational
  -> Point
  -> Point
  -> (Integer, a)
posFromPoint minVal maxVal state dragRate stPoint point = (newPos, newVal) where
  DialState maxPos pos = state
  Point _ dy = subPoint stPoint point
  tmpPos = pos + round dy
  newPos = clamp 0 maxPos tmpPos
  newVal = valueFromPos minVal dragRate newPos

valueFromPos :: DialValue a => a -> Rational -> Integer -> a
valueFromPos !minVal !dragRate !newPos = newVal where
  newVal = minVal + fromFractional (dragRate * fromIntegral newPos)

getDialInfo :: WidgetEnv s e -> WidgetNode s e -> DialCfg s e a -> (Point, Rect)
getDialInfo wenv node config = (dialCenter, dialArea) where
  theme = currentTheme wenv node
  style = currentStyle wenv node
  carea = getContentArea node style

  dialW = fromMaybe (theme ^. L.dialWidth) (_dlcWidth config)
  dialL = _rX carea + (_rW carea - dialW) / 2
  dialT = _rY carea + (_rH carea - dialW) / 2
  !dialCenter = Point (dialL + dialW / 2) (dialT + dialW / 2)
  !dialArea = Rect dialL dialT dialW dialW

currentStyleConfig :: Rect -> CurrentStyleCfg s e
currentStyleConfig dialArea = def
  & L.isHovered .~ isNodeHoveredEllipse_ dialArea