brillo-3.0.0: Brillo/Data/ViewState.hs
{-# LANGUAGE PatternGuards #-}
module Brillo.Data.ViewState (
Command (..),
CommandKey (..),
WheelDirection (..),
CommandConfig,
defaultCommandConfig,
ViewState (..),
viewStateInit,
viewStateInitWithConfig,
updateViewStateWithEvent,
updateViewStateWithEventMaybe,
)
where
import Brillo.Data.Point.Arithmetic qualified as Pt
import Brillo.Data.Vector
import Brillo.Data.ViewPort
import Brillo.Geometry.Angle
import Brillo.Internals.Interface.Backend
import Brillo.Internals.Interface.Event
import Control.Monad (mplus)
import Data.Map (Map)
import Data.Map qualified as Map
import Data.Maybe
-- | The commands suported by the view controller.
data Command
= CRestore
| CTranslate
| CRotate
| CScale
| -- bump zoom
CBumpZoomOut
| CBumpZoomIn
| -- bump translate
CBumpLeft
| CBumpRight
| CBumpUp
| CBumpDown
| -- bump rotate
CBumpClockwise
| CBumpCClockwise
deriving (Show, Eq, Ord)
-- | An input that can trigger a viewport command.
data CommandKey
= -- | A key or mouse button press.
CKey Key
| {-| Scrolling with a mouse wheel or a trackpad.
Only instant commands (i.e. not 'CTranslate', 'CRotate', and 'CScale')
can be triggered by scrolling.
They are applied proportionally to the scroll offset,
and zooming keeps the point under the cursor fixed.
-}
CMouseWheel WheelDirection
deriving (Show, Eq, Ord)
{-| Direction of a scroll.
The direction is the one reported by the operating system,
so it is reversed if \"natural scrolling\" is enabled.
-}
data WheelDirection
= -- | Scrolling up.
WheelUp
| -- | Scrolling down.
WheelDown
| -- | Scrolling left.
WheelLeft
| -- | Scrolling right.
WheelRight
deriving (Show, Eq, Ord)
type CommandConfig = [(Command, [(CommandKey, Maybe Modifiers)])]
{-| The default commands. Left click pans, wheel zooms, right click
rotates, "r" key resets.
-}
defaultCommandConfig :: CommandConfig
defaultCommandConfig =
[
( CRestore
, [(CKey $ Char 'r', Nothing)]
)
,
( CTranslate
,
[
( CKey $ MouseButton LeftButton
, Just (Modifiers{shift = Up, ctrl = Up, alt = Up})
)
]
)
,
( CScale
,
[
( CKey $ MouseButton LeftButton
, Just (Modifiers{shift = Up, ctrl = Down, alt = Up})
)
,
( CKey $ MouseButton RightButton
, Just (Modifiers{shift = Up, ctrl = Up, alt = Up})
)
]
)
,
( CRotate
,
[
( CKey $ MouseButton LeftButton
, Just (Modifiers{shift = Up, ctrl = Up, alt = Down})
)
,
( CKey $ MouseButton RightButton
, Just (Modifiers{shift = Up, ctrl = Down, alt = Up})
)
]
)
, -- bump zoom
( CBumpZoomOut
,
[ (CMouseWheel WheelDown, Nothing)
, (CKey $ SpecialKey KeyPageDown, Nothing)
]
)
,
( CBumpZoomIn
,
[ (CMouseWheel WheelUp, Nothing)
, (CKey $ SpecialKey KeyPageUp, Nothing)
]
)
, -- bump translate
( CBumpLeft
, [(CKey $ SpecialKey KeyLeft, Nothing)]
)
,
( CBumpRight
, [(CKey $ SpecialKey KeyRight, Nothing)]
)
,
( CBumpUp
, [(CKey $ SpecialKey KeyUp, Nothing)]
)
,
( CBumpDown
, [(CKey $ SpecialKey KeyDown, Nothing)]
)
, -- bump rotate
( CBumpClockwise
, [(CKey $ SpecialKey KeyHome, Nothing)]
)
,
( CBumpCClockwise
, [(CKey $ SpecialKey KeyEnd, Nothing)]
)
]
-- | Check if the provided key combination is some brillo viewport command.
isCommand ::
Map Command [(CommandKey, Maybe Modifiers)] ->
Command ->
CommandKey ->
Modifiers ->
Bool
isCommand commands c key keyMods
| Just csMatch <- Map.lookup c commands =
or $ map (isCommand2 c key keyMods) csMatch
| otherwise =
False
-- | Check if the provided key combination is some brillo viewport command.
isCommand2 ::
Command -> CommandKey -> Modifiers -> (CommandKey, Maybe Modifiers) -> Bool
isCommand2 _ key keyMods cMatch
| (keyC, mModsC) <- cMatch
, keyC == key
, case mModsC of
Nothing -> True
Just modsC -> modsC == keyMods =
True
| otherwise =
False
-- ViewControl State -----------------------------------------------------------
{-| State for controlling the viewport.
These are used by the viewport control component.
-}
data ViewState
= ViewState
{ viewStateCommands :: !(Map Command [(CommandKey, Maybe Modifiers)])
{- ^ The command list for the viewport controller.
These can be safely overwridden at any time by deleting
or adding entries to the list.
Entries at the front of the list take precedence.
-}
, viewStateScaleStep :: !Float
{- ^ How much to scale the world by for each step of the mouse wheel.
Fractional scroll offsets (e.g. from trackpads) scale the world
by the corresponding power of this value.
-}
, viewStateRotateFactor :: !Float
-- ^ How many degrees to rotate the world by for each pixel of x motion.
, viewStateScaleFactor :: !Float
-- ^ Ratio to scale the world by for each pixel of y motion.
, viewStateTranslateMark :: !(Maybe (Float, Float))
{- ^ During viewport translation,
where the mouse was clicked on the window to start the translate.
-}
, viewStateRotateMark :: !(Maybe (Float, Float))
{- ^ During viewport rotation,
where the mouse was clicked on the window to starte the rotate.
-}
, viewStateScaleMark :: !(Maybe (Float, Float))
{- ^ During viewport scale,
where the mouse was clicked on the window to start the scale.
-}
, viewStateViewPort :: ViewPort
-- ^ The current viewport.
}
-- | The initial view state.
viewStateInit :: ViewState
viewStateInit =
viewStateInitWithConfig defaultCommandConfig
-- | Initial view state, with user defined config.
viewStateInitWithConfig :: CommandConfig -> ViewState
viewStateInitWithConfig commandConfig =
ViewState
{ viewStateCommands = Map.fromList commandConfig
, viewStateScaleStep = 0.85
, viewStateRotateFactor = 0.6
, viewStateScaleFactor = 0.01
, viewStateTranslateMark = Nothing
, viewStateRotateMark = Nothing
, viewStateScaleMark = Nothing
, viewStateViewPort = viewPortInit
}
-- | Apply an event to a `ViewState`.
updateViewStateWithEvent :: Event -> ViewState -> ViewState
updateViewStateWithEvent ev viewState =
fromMaybe viewState $ updateViewStateWithEventMaybe ev viewState
{-| Like 'updateViewStateWithEvent', but returns 'Nothing' if no update
was needed.
-}
updateViewStateWithEventMaybe :: Event -> ViewState -> Maybe ViewState
updateViewStateWithEventMaybe ev viewState =
case ev of
EventKey key keyState keyMods pos ->
updateViewStateWithKeyMaybe key keyState keyMods pos viewState
EventScroll offset keyMods pos ->
updateViewStateWithScrollMaybe offset keyMods pos viewState
-- Note that only a translation or rotation applies,
-- not both at the same time.
EventMotion pos ->
motionScale (viewStateScaleMark viewState) pos viewState
`mplus` motionTranslate (viewStateTranslateMark viewState) pos viewState
`mplus` motionRotate (viewStateRotateMark viewState) pos viewState
EventResize _ -> Nothing
EventDrop _ -> Nothing
EventPick _ -> Nothing
updateViewStateWithKeyMaybe ::
Key -> KeyState -> Modifiers -> (Float, Float) -> ViewState -> Maybe ViewState
updateViewStateWithKeyMaybe key keyState keyMods pos viewState
-- Instant commands, zooming around the center of the window.
| keyState == Down
, Just viewState' <- runInstantCommands (CKey key) keyMods 1 Nothing viewState =
Just viewState'
-- Start Translation.
| isCommand' CTranslate
, keyState == Down
, not $ currentlyRotating || currentlyScaling =
Just $ viewState{viewStateTranslateMark = Just pos}
-- Start Rotation.
| isCommand' CRotate
, keyState == Down
, not $ currentlyTranslating || currentlyScaling =
Just $ viewState{viewStateRotateMark = Just pos}
-- Start Scale.
| isCommand' CScale
, keyState == Down
, not $ currentlyTranslating || currentlyRotating =
Just $ viewState{viewStateScaleMark = Just pos}
-- Kill current translate/rotate/scale command when the mouse button
-- is released.
| keyState == Up =
let killTranslate vs = vs{viewStateTranslateMark = Nothing}
killRotate vs = vs{viewStateRotateMark = Nothing}
killScale vs = vs{viewStateScaleMark = Nothing}
in Just $
(if currentlyTranslating then killTranslate else id) $
(if currentlyRotating then killRotate else id) $
(if currentlyScaling then killScale else id) $
viewState
| otherwise =
Nothing
where
isCommand' c = isCommand (viewStateCommands viewState) c (CKey key) keyMods
currentlyTranslating = isJust $ viewStateTranslateMark viewState
currentlyRotating = isJust $ viewStateRotateMark viewState
currentlyScaling = isJust $ viewStateScaleMark viewState
{-| Run the instant commands bound to the scroll directions of the offset.
Each axis is handled separately and runs its command
as many times as the absolute offset says,
so that fractional trackpad offsets result in fractional steps.
Zooming keeps the point under the cursor fixed.
-}
updateViewStateWithScrollMaybe ::
(Float, Float) -> Modifiers -> (Float, Float) -> ViewState -> Maybe ViewState
updateViewStateWithScrollMaybe (offsetX, offsetY) keyMods pos viewState =
case scrollAxis offsetX WheelLeft WheelRight viewState of
Nothing -> scrollAxis offsetY WheelUp WheelDown viewState
Just viewState' ->
Just $ fromMaybe viewState' $ scrollAxis offsetY WheelUp WheelDown viewState'
where
scrollAxis offset positiveDir negativeDir vs
| offset == 0 = Nothing
| otherwise =
runInstantCommands
(CMouseWheel $ if offset > 0 then positiveDir else negativeDir)
keyMods
(abs offset)
(Just pos)
vs
{-| Run the first instant (i.e. non-dragging) command
that is bound to the given key and modifiers.
-}
runInstantCommands ::
CommandKey ->
Modifiers ->
-- | Number of steps to apply the command for.
Float ->
-- | Point to zoom around. The center of the window if 'Nothing'.
Maybe (Float, Float) ->
ViewState ->
Maybe ViewState
runInstantCommands key keyMods steps zoomCenter viewState =
case filter isBound instantCommands of
[] -> Nothing
command : _ -> Just $ runInstantCommand command steps zoomCenter viewState
where
isBound c = isCommand (viewStateCommands viewState) c key keyMods
{-| The commands which take effect immediately,
in contrast to translating, rotating and scaling by dragging the mouse.
If an input is bound to several of them, the first one wins.
-}
instantCommands :: [Command]
instantCommands =
[ CRestore
, CBumpZoomOut
, CBumpZoomIn
, CBumpLeft
, CBumpRight
, CBumpUp
, CBumpDown
, CBumpClockwise
, CBumpCClockwise
]
-- | Apply an instant command for the given number of steps.
runInstantCommand ::
Command -> Float -> Maybe (Float, Float) -> ViewState -> ViewState
runInstantCommand command steps zoomCenter viewState =
case command of
CRestore -> withPort $ const viewPortInit
CBumpZoomOut -> withPort $ zoomBy (scaleStep ** steps)
CBumpZoomIn -> withPort $ zoomBy (1 / scaleStep ** steps)
CBumpLeft -> withPort $ \port -> motionBump port (20 * steps, 0)
CBumpRight -> withPort $ \port -> motionBump port (-20 * steps, 0)
CBumpUp -> withPort $ \port -> motionBump port (0, -20 * steps)
CBumpDown -> withPort $ \port -> motionBump port (0, 20 * steps)
CBumpClockwise -> withPort $ rotateBy (5 * steps)
CBumpCClockwise -> withPort $ rotateBy (-5 * steps)
CTranslate -> viewState
CRotate -> viewState
CScale -> viewState
where
scaleStep = viewStateScaleStep viewState
withPort f = viewState{viewStateViewPort = f (viewStateViewPort viewState)}
rotateBy degrees port = port{viewPortRotate = viewPortRotate port + degrees}
zoomBy factor port =
let port' = port{viewPortScale = viewPortScale port * factor}
in case zoomCenter of
Nothing -> port'
Just center -> keepPointFixed center port port'
{-| Adjust the translation of the new viewport
so that the picture point under the given screen point
is the same as with the old viewport.
-}
keepPointFixed :: (Float, Float) -> ViewPort -> ViewPort -> ViewPort
keepPointFixed screenPoint oldPort newPort =
newPort
{ viewPortTranslate =
viewPortTranslate newPort
Pt.+ invertViewPort newPort screenPoint
Pt.- invertViewPort oldPort screenPoint
}
-- | Offset a viewport.
motionBump :: ViewPort -> (Float, Float) -> ViewPort
motionBump
port@ViewPort
{ viewPortTranslate = trans
, viewPortScale = scale
, viewPortRotate = r
}
(bumpX, bumpY) =
port{viewPortTranslate = trans Pt.- o}
where
offset = (bumpX / scale, bumpY / scale)
o = rotateV (degToRad r) offset
-- | Apply a translation to the `ViewState`.
motionTranslate ::
Maybe (Float, Float) -> -- Location of first mark.
(Float, Float) -> -- Current position.
ViewState ->
Maybe ViewState
motionTranslate Nothing _ _ = Nothing
motionTranslate (Just (markX, markY)) (posX, posY) viewState =
Just $
viewState
{ viewStateViewPort = port{viewPortTranslate = trans Pt.- o}
, viewStateTranslateMark = Just (posX, posY)
}
where
port = viewStateViewPort viewState
trans = viewPortTranslate port
scale = viewPortScale port
r = viewPortRotate port
dX = markX - posX
dY = markY - posY
offset = (dX / scale, dY / scale)
o = rotateV (degToRad r) offset
-- | Apply a rotation to the `ViewState`.
motionRotate ::
Maybe (Float, Float) -> -- Location of first mark.
(Float, Float) -> -- Current position.
ViewState ->
Maybe ViewState
motionRotate Nothing _ _ = Nothing
motionRotate (Just (markX, _markY)) (posX, posY) viewState =
Just $
viewState
{ viewStateViewPort =
port{viewPortRotate = rotate - rotateFactor * (posX - markX)}
, viewStateRotateMark = Just (posX, posY)
}
where
port = viewStateViewPort viewState
rotate = viewPortRotate port
rotateFactor = viewStateRotateFactor viewState
-- | Apply a scale to the `ViewState`.
motionScale ::
Maybe (Float, Float) -> -- Location of first mark.
(Float, Float) -> -- Current position.
ViewState ->
Maybe ViewState
motionScale Nothing _ _ = Nothing
motionScale (Just (_markX, markY)) (posX, posY) viewState =
Just $
viewState
{ viewStateViewPort =
let
-- Limit the amount of downward scaling so it maxes
-- out at 1 percent of the original. There's not much
-- point scaling down to no pixels, or going negative
-- so that the image is inverted.
ss =
if posY > markY
then scale - scale * (scaleFactor * (posY - markY))
else scale + scale * (scaleFactor * (markY - posY))
ss' = max 0.01 ss
in
port{viewPortScale = ss'}
, viewStateScaleMark = Just (posX, posY)
}
where
port = viewStateViewPort viewState
scale = viewPortScale port
scaleFactor = viewStateScaleFactor viewState