packages feed

monomer-1.4.0.0: src/Monomer/Widgets/Containers/Tooltip.hs

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

Displays a text message above its child node when the pointer is on top and the
delay, if any, has ellapsed.

Tooltip styling is a bit unusual, since it is applied to the overlaid element.
This means padding will not be shown for the contained child element, but only
on the message when the tooltip is active. If you need padding around the child
element, you can use a "Monomer.Widgets.Containers.Box" around it.

@
tooltip "Click the button" (buttom \"Accept\" AcceptAction)
  \`styleBasic\` [textSize 16, bgColor steelBlue, paddingH 5, radius 5]
@
-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE StrictData #-}

module Monomer.Widgets.Containers.Tooltip (
  -- * Configuration
  TooltipCfg,
  tooltipDelay,
  tooltipFollow,
  -- * Constructors
  tooltip,
  tooltip_
) where

import Control.Applicative ((<|>))
import Control.Lens ((&), (^.), (.~), (%~), at)
import Control.Monad (forM_, when)
import Data.Default
import Data.Maybe
import Data.Text (Text)
import GHC.Generics

import qualified Data.Sequence as Seq

import Monomer.Widgets.Container

import qualified Monomer.Lens as L

{-|
Configuration options for tooltip:

- 'width': the maximum width of the tooltip. Used for multiline.
- 'height': the maximum height of the tooltip. Used for multiline.
- 'tooltipDelay': the delay in ms before the tooltip is displayed.
- 'tooltipFollow': if, after tooltip is displayed, it should follow the mouse.
-}
data TooltipCfg = TooltipCfg {
  _ttcDelay :: Maybe Millisecond,
  _ttcFollowCursor :: Maybe Bool,
  _ttcMaxWidth :: Maybe Double,
  _ttcMaxHeight :: Maybe Double
}

instance Default TooltipCfg where
  def = TooltipCfg {
    _ttcDelay = Nothing,
    _ttcFollowCursor = Nothing,
    _ttcMaxWidth = Nothing,
    _ttcMaxHeight = Nothing
  }

instance Semigroup TooltipCfg where
  (<>) s1 s2 = TooltipCfg {
    _ttcDelay = _ttcDelay s2 <|> _ttcDelay s1,
    _ttcFollowCursor = _ttcFollowCursor s2 <|> _ttcFollowCursor s1,
    _ttcMaxWidth = _ttcMaxWidth s2 <|> _ttcMaxWidth s1,
    _ttcMaxHeight = _ttcMaxHeight s2 <|> _ttcMaxHeight s1
  }

instance Monoid TooltipCfg where
  mempty = def

instance CmbMaxWidth TooltipCfg where
  maxWidth w = def {
    _ttcMaxWidth = Just w
  }

instance CmbMaxHeight TooltipCfg where
  maxHeight h = def {
    _ttcMaxHeight = Just h
  }

-- | Delay before the tooltip is displayed when child widget is hovered.
tooltipDelay :: Millisecond -> TooltipCfg
tooltipDelay ms = def {
  _ttcDelay = Just ms
}

-- | Whether the tooltip should move with the mouse after being displayed.
tooltipFollow :: TooltipCfg
tooltipFollow = def {
  _ttcFollowCursor = Just True
}

data TooltipState = TooltipState {
  _ttsLastPos :: Point,
  _ttsLastPosTs :: Millisecond
} deriving (Eq, Show, Generic)

-- | Creates a tooltip for the child widget.
tooltip :: Text -> WidgetNode s e -> WidgetNode s e
tooltip caption managed = tooltip_ caption def managed

-- | Creates a tooltip for the child widget. Accepts config.
tooltip_ :: Text -> [TooltipCfg] -> WidgetNode s e -> WidgetNode s e
tooltip_ caption configs managed = makeNode widget managed where
  config = mconcat configs
  state = TooltipState def maxBound
  widget = makeTooltip caption config state

makeNode :: Widget s e -> WidgetNode s e -> WidgetNode s e
makeNode widget managedWidget = defaultWidgetNode "tooltip" widget
  & L.info . L.focusable .~ False
  & L.children .~ Seq.singleton managedWidget

makeTooltip :: Text -> TooltipCfg -> TooltipState -> Widget s e
makeTooltip caption config state = widget where
  baseWidget = createContainer state def {
    containerAddStyleReq = False,
    containerGetBaseStyle = getBaseStyle,
    containerMerge = merge,
    containerHandleEvent = handleEvent,
    containerResize = resize
  }
  widget = baseWidget {
    widgetRender = render
  }

  delay = fromMaybe 1000 (_ttcDelay config)
  followCursor = fromMaybe False (_ttcFollowCursor config)

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

  merge wenv node oldNode oldState = result where
    newNode = node
      & L.widget .~ makeTooltip caption config oldState
    result = resultNode newNode

  handleEvent wenv node target evt = case evt of
    Leave point -> Just $ resultReqs newNode [RenderOnce] where
      newState = state {
        _ttsLastPos = Point (-1) (-1),
        _ttsLastPosTs = maxBound
      }
      newNode = node
        & L.widget .~ makeTooltip caption config newState

    Move point
      | isPointInNodeVp node point -> Just result where
        widgetId = node ^. L.info . L.widgetId
        prevDisplayed = tooltipDisplayed wenv node
        newState = state {
          _ttsLastPos = point,
          _ttsLastPosTs = wenv ^. L.timestamp
        }
        newNode = node
          & L.widget .~ makeTooltip caption config newState
        delayedRender = RenderEvery widgetId delay (Just 1)
        result
          | not prevDisplayed = resultReqs newNode [delayedRender]
          | prevDisplayed && followCursor = resultReqs node [RenderOnce]
          | otherwise = resultNode node

    _ -> Nothing

  -- Padding/border is not removed. Styles are only considerer for the overlay
  resize wenv node viewport children = resized where
    resized = (resultNode node, Seq.singleton viewport)

  render wenv node renderer = do
    forM_ children $ \child ->
      widgetRender (child ^. L.widget) wenv child renderer

    when tooltipVisible $
      createOverlay renderer $ do
        drawStyledAction renderer rect style $ \textRect -> do
          let textLines = alignTextLines style textRect fittedLines
          forM_ textLines (drawTextLine renderer style)
    where
      fontMgr = wenv ^. L.fontManager
      style = currentStyle wenv node
      children = node ^. L.children
      mousePos = wenv ^. L.inputStatus . L.mousePos

      scOffset = wenv ^. L.offset
      isDragging = isJust (wenv ^. L.dragStatus)
      maxW = wenv ^. L.windowSize . L.w
      maxH = wenv ^. L.windowSize . L.h

      targetW = fromMaybe maxW (_ttcMaxWidth config)
      targetH = fromMaybe maxH (_ttcMaxHeight config)
      targetSize = Size targetW targetH
      fittedLines = fitTextToSize fontMgr style Ellipsis MultiLine TrimSpaces
        Nothing targetSize caption
      textSize = getTextLinesSize fittedLines

      Size tw th = fromMaybe def (addOuterSize style textSize)
      TooltipState lastPos _ = state
      Point mx my
        | followCursor = addPoint scOffset mousePos
        | otherwise = addPoint scOffset lastPos
      rx
        | wenv ^. L.windowSize . L.w - mx > tw = mx
        | otherwise = wenv ^. L.windowSize . L.w - tw
      -- Add offset to have space between the tooltip and the cursor
      ry
        | wenv ^. L.windowSize . L.h - (my + 50) > th = my + 20
        | otherwise = my - th - 5
      rect = Rect rx ry tw th
      tooltipVisible = tooltipDisplayed wenv node && not isDragging

  tooltipDisplayed wenv node = displayed where
    TooltipState lastPos lastPosTs = state
    ts = wenv ^. L.timestamp
    viewport = node ^. L.info . L.viewport
    inViewport = pointInRect lastPos viewport
    delayEllapsed = ts - lastPosTs >= delay
    displayed = inViewport && delayEllapsed