packages feed

tricorder-0.6.0.0: src/Tricorder/CLI/UI/Keys.hs

module Tricorder.CLI.UI.Keys
    ( KeyEvent (..)
    , Config
    , keys
    , mkDispatcher
    , viewKeybindings
    , mkKeyConfig
    )
where

import Atelier.Effects.Console (Console)
import Atelier.Effects.Exit (Exit, exitFailure)
import Brick
    ( EventM
    , Widget
    , halt
    , txt
    , vBox
    , vScrollBy
    , viewportScroll
    )
import Brick.Keybindings
    ( Binding
    , BindingState
    , EventTrigger (..)
    , Handler (..)
    , KeyConfig
    , KeyDispatcher
    , KeyEventHandler (..)
    , KeyEvents
    , KeyHandler (..)
    , ToBinding (..)
    , allActiveBindings
    , binding
    , ctrl
    , keyDispatcher
    , keyEvents
    , newKeyConfig
    , onEvent
    , parseBindingList
    )
import Brick.Keybindings.Pretty (ppBinding)
import Brick.Widgets.Core (hBox)
import Control.Monad.State (gets, modify)
import Data.Aeson (FromJSON (..))
import Data.Default (Default (..))
import Effectful.Reader.Static (Reader, ask)
import Graphics.Vty (Key (..))
import Text.Casing (quietSnake)

import Atelier.Effects.Console qualified as Console
import Data.Map.Strict qualified as Map
import Data.Set qualified as Set
import Data.Text qualified as T

import Tricorder.CLI.UI.Misc (warn)
import Tricorder.CLI.UI.State
    ( Processed (Waiting)
    , State (..)
    , Viewports (..)
    , currentRoute
    , cycleTestFilter
    , viewToViewport
    )

import Tricorder.CLI.UI.Route qualified as Route


-- | [tag:keybinding_events] The TUI key events. This type is the source of truth
-- for the event names documented in README.md under "Custom Key Bindings", which
-- points back here with a matching @ref@. Whenever you add, remove, or rename a
-- 'KeyEvent', update that list to match — @tagref check@ flags the dangling
-- reference if this tag is renamed or dropped without touching the docs.
data KeyEvent
    = SwitchTabPrev
    | SwitchTabNext
    | ToggleHelp
    | CycleTestView
    | RestartDaemon
    | ScrollUp
    | ScrollDown
    | Quit
    deriving stock (Bounded, Enum, Eq, Ord, Show)


keyEventToText :: KeyEvent -> Text
keyEventToText = toText . quietSnake . show


keyEventTextMap :: Map Text KeyEvent
keyEventTextMap = Map.fromList $ (\e -> (keyEventToText e, e)) <$> universe


textToKeyEvent :: Text -> Maybe KeyEvent
textToKeyEvent = (`Map.lookup` keyEventTextMap)


eventToDesc :: KeyEvent -> Text
eventToDesc = \case
    ToggleHelp -> "toggle help"
    SwitchTabNext -> "switch to next tab"
    SwitchTabPrev -> "switch to previous tab"
    CycleTestView -> "cycle test tab views"
    RestartDaemon -> "restart daemon"
    ScrollUp -> "scroll up"
    ScrollDown -> "scroll down"
    Quit -> "quit"


keys :: KeyEvents KeyEvent
keys = keyEvents $ (\e -> (eventToDesc e, e)) <$> universe


eventToBinding :: KeyEvent -> [Binding]
eventToBinding = \case
    ToggleHelp -> [bind '?']
    SwitchTabPrev -> [bind 'h', binding KLeft []]
    SwitchTabNext -> [bind 'l', binding KRight []]
    CycleTestView -> [bind 't']
    RestartDaemon -> [bind 'R']
    ScrollUp -> [binding KUp []]
    ScrollDown -> [binding KDown []]
    Quit -> [bind 'q', ctrl 'c', binding KEsc []]


defaultBindings :: [(KeyEvent, [Binding])]
defaultBindings = (\e -> (e, eventToBinding e)) <$> universe


mkKeyConfig :: (Console :> es, Exit :> es, Reader Config :> es) => Eff es (KeyConfig KeyEvent)
mkKeyConfig = do
    customBindings <- parseCustomBindings
    pure $ newKeyConfig keys defaultBindings customBindings


newtype Config = Config (Map Text Text)
    deriving stock (Generic)
    deriving newtype (FromJSON)


instance Default Config where
    def = Config mempty


parseCustomBindings
    :: ( Console :> es
       , Exit :> es
       , Reader Config :> es
       )
    => Eff es [(KeyEvent, BindingState)]
parseCustomBindings = do
    Config cfg <- ask
    let (errors, customBindings) = partitionEithers $ uncurry parseEntry <$> Map.toList cfg
    unless (null errors) do
        Console.putTextLn "Error(s) encountered when attempting to parse key bindings:"
        traverse_ (Console.putTextLn . toText) errors
        exitFailure
    pure customBindings


parseEntry :: Text -> Text -> Either Text (KeyEvent, BindingState)
parseEntry ev binds =
    (,) <$> parsedEvent <*> parsedBinds
  where
    parsedEvent = parseKeyEvent ev
    parsedBinds = first toText $ parseBindingList binds


parseKeyEvent :: Text -> Either Text KeyEvent
parseKeyEvent ev = maybeToRight ("Unrecognized key event: " <> ev) $ textToKeyEvent ev


eventToHandler :: IO () -> KeyEvent -> KeyEventHandler KeyEvent (EventM Viewports State)
eventToHandler requestRestart = \case
    ToggleHelp -> onEvent ToggleHelp "Toggle help" do
        modify \s -> s {showHelp = not s.showHelp}
    SwitchTabNext -> onEvent SwitchTabNext "Switch to next tab" do
        modify \s ->
            s
                { route =
                    if s.route == maxBound
                        then minBound
                        else succ s.route
                }
    SwitchTabPrev -> onEvent SwitchTabPrev "Switch to previous tab" do
        modify \s ->
            s
                { route =
                    if s.route == minBound
                        then maxBound
                        else pred s.route
                }
    CycleTestView -> onEvent CycleTestView "Cycle between test tab views" do
        modify \s ->
            if
                | s.route /= Route.Tests -> s
                | otherwise -> s {testFilter = cycleTestFilter s.testFilter}
    RestartDaemon -> onEvent RestartDaemon "Restart the daemon" do
        liftIO requestRestart
        modify \s -> s {buildState = Waiting}
    ScrollUp -> onEvent ScrollUp "Scroll up" do
        mvp <- gets (viewToViewport . currentRoute)
        case mvp of
            Just vp -> vScrollBy (viewportScroll vp) (-1)
            Nothing -> pure ()
    ScrollDown -> onEvent ScrollDown "Scroll down" do
        mvp <- gets (viewToViewport . currentRoute)
        case mvp of
            Just vp ->
                vScrollBy (viewportScroll vp) 1
            Nothing -> pure ()
    Quit -> onEvent Quit "Exit" do
        halt


-- | Build the key dispatcher. @requestRestart@ is run (in 'IO') when the restart
-- key is pressed; it hands the request off to the worker that owns the daemon
-- control effects, since brick's 'EventM' cannot run them directly.
mkDispatcher
    :: (Console :> es, Exit :> es)
    => IO ()
    -> KeyConfig KeyEvent
    -> Eff es (KeyDispatcher KeyEvent (EventM Viewports State))
mkDispatcher requestRestart cfg =
    case keyDispatcher cfg $ eventToHandler requestRestart <$> universe of
        Left collisions -> do
            Console.putTextLn
                $ T.intercalate "\n\n"
                $ "Your key bindings have collisions:"
                    : (uncurry showCollision <$> collisions)
            exitFailure
        Right dispatcher -> pure dispatcher
  where
    showCollision binding' handlers =
        T.intercalate "\n"
            $ ("Key binding: " <> ppBinding binding')
                : (showCollidingHandler <$> handlers)
    showCollidingHandler handler =
        "  " <> case handler.khHandler.kehEventTrigger of
            ByEvent ev -> toText $ quietSnake $ show ev
            ByKey k -> show k


viewKeybindings :: (Ord k, Show k) => KeyConfig k -> [KeyEventHandler k m] -> Widget n
viewKeybindings kc =
    vBox
        . fmap (uncurry (viewEventAndTriggers kc))
        . Map.toList
        . foldr groupByEventName Map.empty
  where
    groupByEventName ev = Map.insertWith (<>) ev.kehHandler.handlerDescription [ev.kehEventTrigger]


viewEventAndTriggers :: (Ord k, Show k) => KeyConfig k -> Text -> [EventTrigger k] -> Widget n
viewEventAndTriggers kc eventName triggers =
    hBox
        [ warn $ txt $ eventName <> ": "
        , txt $ showBindings $ mconcat $ getBindings <$> triggers
        ]
  where
    showBindings = T.intercalate ", " . fmap ppBinding . sort . toList
    getBindings = \case
        ByKey k -> Set.singleton k
        ByEvent e -> Set.fromList $ allActiveBindings kc e