packages feed

swarm-0.7.0.0: src/swarm-tui/Swarm/TUI/Controller.hs

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}

-- |
-- SPDX-License-Identifier: BSD-3-Clause
--
-- Event handlers for the TUI.
module Swarm.TUI.Controller (
  -- * Event handling
  handleEvent,
  quitGame,

  -- ** Handling 'Swarm.TUI.Model.Frame' events
  runFrameUI,
  ticksPerFrameCap,
  runGameTickUI,

  -- ** REPL panel
  runBaseWebCode,
  handleREPLEvent,
  validateREPLForm,
  adjReplHistIndex,
  TimeDir (..),

  -- ** Info panel
  handleInfoPanelEvent,
) where

import Brick hiding (Direction, Location)
import Brick.Animation (stopAnimationManager)
import Brick.Focus
import Brick.Keybindings qualified as B
import Brick.Widgets.Dialog
import Brick.Widgets.Edit (Editor, applyEdit, editContentsL, handleEditorEvent)
import Brick.Widgets.List (handleListEvent, listElements)
import Brick.Widgets.List qualified as BL
import Brick.Widgets.TabularList.Grid qualified as BG
import Control.Applicative ((<|>))
import Control.Category ((>>>))
import Control.Lens as Lens
import Control.Monad (forM_, unless, void, when)
import Control.Monad.Extra (whenJust)
import Control.Monad.IO.Class (MonadIO (liftIO))
import Control.Monad.State (MonadState, execState)
import Data.List (find)
import Data.List.NonEmpty (NonEmpty (..))
import Data.List.NonEmpty qualified as NE
import Data.Map qualified as M
import Data.Maybe (fromMaybe, isJust, listToMaybe, mapMaybe)
import Data.Set (Set)
import Data.Set qualified as S
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.IO qualified as T
import Data.Text.Zipper qualified as TZ
import Data.Text.Zipper.Generic.Words qualified as TZ
import Data.Vector qualified as V
import Graphics.Vty qualified as V
import Swarm.Game.Achievement.Definitions
import Swarm.Game.CESK (CESK (Out), Frame (FApp, FExec, FSuspend))
import Swarm.Game.Entity hiding (empty)
import Swarm.Game.Land
import Swarm.Game.Robot.Concrete
import Swarm.Game.Scenario (scenarioMetadata, scenarioName)
import Swarm.Game.Scenario.Scoring.Best (scenarioBestByTime)
import Swarm.Game.Scenario.Scoring.GenericMetrics
import Swarm.Game.Scenario.Status (ScenarioPath (..), ScenarioWith (..), getScenario)
import Swarm.Game.ScenarioInfo
import Swarm.Game.State
import Swarm.Game.State.Landscape
import Swarm.Game.State.Robot
import Swarm.Game.State.Runtime
import Swarm.Game.State.Substate
import Swarm.Language.Capability (
  Capability (CGod),
  constCaps,
 )
import Swarm.Language.Context
import Swarm.Language.Key (KeyCombo, mkKeyCombo)
import Swarm.Language.Parser (readTerm')
import Swarm.Language.Parser.Core (defaultParserConfig)
import Swarm.Language.Parser.Lex (reservedWords)
import Swarm.Language.Parser.Util (showErrorPos)
import Swarm.Language.Pipeline (processParsedTerm', processTerm')
import Swarm.Language.Syntax hiding (Key)
import Swarm.Language.Typecheck (
  ContextualTypeErr (..),
 )
import Swarm.Language.Value (Value (VKey), emptyEnv, envTypes)
import Swarm.Log
import Swarm.ResourceLoading (getSwarmHistoryPath)
import Swarm.TUI.Controller.EventHandlers
import Swarm.TUI.Controller.SaveScenario (saveScenarioInfoOnQuit)
import Swarm.TUI.Controller.UpdateUI
import Swarm.TUI.Controller.Util
import Swarm.TUI.Editor.Controller qualified as EC
import Swarm.TUI.Editor.Model
import Swarm.TUI.Launch.Controller
import Swarm.TUI.Launch.Model
import Swarm.TUI.Launch.Prep (prepareLaunchDialog)
import Swarm.TUI.List
import Swarm.TUI.Model
import Swarm.TUI.Model.Dialog hiding (Completed)
import Swarm.TUI.Model.Menu
import Swarm.TUI.Model.Name
import Swarm.TUI.Model.Repl
import Swarm.TUI.Model.StateUpdate
import Swarm.TUI.Model.UI
import Swarm.TUI.Model.UI.Gameplay
import Swarm.TUI.View.Popup (startPopupAnimation)
import Swarm.TUI.View.Robot
import Swarm.TUI.View.Robot.Type
import Swarm.Util hiding (both, (<<.=))

-- | The top-level event handler for the TUI.
handleEvent :: BrickEvent Name AppEvent -> EventM Name AppState ()
handleEvent e = do
  playing <- use $ uiState . uiPlaying
  case e of
    -- the query for upstream version could finish at any time, so we have to handle it here
    AppEvent (UpstreamVersion ev) -> handleUpstreamVersionResponse ev
    AppEvent (Web (RunWebCode {..})) | not playing -> liftIO . webReply $ Rejected NoActiveGame
    AppEvent (PopupEvent event) -> event >> continueWithoutRedraw
    _ -> do
      -- Handle popup display at the very top level, so it is
      -- unaffected by any other state, e.g. even when starting or
      -- quitting a game, moving around the menu, the popup
      -- display will continue as normal.
      popupAnimState <- use $ playState . progression . uiPopupAnimationState
      forceRedraw <- case popupAnimState of
        AnimInactive -> do
          Brick.zoom (playState . progression . uiPopups) nextPopup
          startPopupIfNeeded
          pure False
        AnimScheduled -> pure False
        AnimActive _ -> pure True

      if playing
        then handleMainEvent forceRedraw e
        else handleMenuEvent e

startPopupIfNeeded :: EventM Name AppState ()
startPopupIfNeeded = do
  mPopup <- use $ playState . progression . uiPopups . currentPopup
  case mPopup of
    Just popup -> do
      -- Ensures we don't grab another popup while waiting for the animation manager to start the event.
      -- The animation state will be set to AnimActive when the animation manager actually starts the animation
      playState . progression . uiPopupAnimationState .= AnimScheduled
      animMgr <- use animationMgr
      startPopupAnimation animMgr popup
    Nothing -> pure ()

-- | Halt the app, properly cleaning up the animation manager.
haltApp :: EventM Name AppState ()
haltApp = use animationMgr >>= stopAnimationManager >> halt

handleUpstreamVersionResponse :: Either (Severity, Text) String -> EventM Name AppState ()
handleUpstreamVersionResponse ev = do
  case ev of
    Left (sev, e) -> runtimeState . eventLog %= logEvent SystemLog sev "Release" e
    Right _ -> pure ()
  runtimeState . upstreamRelease .= ev

handleMenuEvent :: BrickEvent Name AppEvent -> EventM Name AppState ()
handleMenuEvent e =
  use (uiState . uiMenu) >>= \case
    -- If we reach the NoMenu case when uiPlaying is False, just
    -- quit the app.  We should actually never reach this code (the
    -- quitGame function would have already halted the app).
    NoMenu -> haltApp
    MainMenu l -> handleMainMenuEvent l e
    NewGameMenu l -> do
      launchControls <- use $ uiState . uiLaunchConfig . controls
      if launchControls ^. fileBrowser . fbIsDisplayed
        then handleFBEvent e
        else case launchControls ^. isDisplayedFor of
          Nothing -> handleNewGameMenuEvent l e
          Just siPair -> handleLaunchOptionsEvent siPair e
    MessagesMenu -> handleMainMessagesEvent e
    AchievementsMenu l -> handleMainAchievementsEvent l e
    AboutMenu -> pressAnyKey (MainMenu (mainMenu About)) e

-- | The event handler for the main menu.
--
-- TODO: #2010 Finish porting Controller to KeyEventHandlers
handleMainMenuEvent ::
  BL.List Name MainMenuEntry -> BrickEvent Name AppEvent -> EventM Name AppState ()
handleMainMenuEvent menu = \case
  Key V.KEnter ->
    forM_ (snd <$> BL.listSelectedElement menu) $ \case
      NewGame -> do
        ss <- use $ playState . progression . scenarios
        uiState . uiMenu .= NewGameMenu (pure $ mkScenarioList $ pathifyCollection ss)
      Tutorial -> do
        ss <- use $ playState . progression . scenarios

        -- Extract the first unsolved tutorial challenge
        let tutorialCollection = getTutorials ss
            tutorials = scenarioCollectionToList tutorialCollection
            -- Find first unsolved tutorial, or first tutorial if all are solved
            firstUnsolved :: Maybe (ScenarioItem ScenarioInfo)
            firstUnsolved = find unsolved tutorials <|> listToMaybe tutorials
            unsolved = \case
              SISingle (ScenarioWith _ si) -> case si ^. scenarioStatus of
                Played _ _ best
                  | Metric Completed _ <- best ^. scenarioBestByTime -> False
                  | otherwise -> True
                _ -> True
              _ -> False

        case firstUnsolved of
          Just (SISingle firstUnsolvedInfo) -> do
            let firstUnsolvedName = firstUnsolvedInfo ^. getScenario . scenarioMetadata . scenarioName

            -- Now set up the menu stack as if the user had chosen "New Game > Tutorials > t"
            -- where t is the tutorial scenario we identified as the first unsolved one
            let topMenu =
                  BL.listFindBy
                    ((== tutorialsDirname) . T.unpack . scenarioItemName)
                    (mkScenarioList $ pathifyCollection ss)
                tutorialMenu =
                  BL.listFindBy
                    ((== firstUnsolvedName) . scenarioItemName)
                    (mkScenarioList $ pathifyCollection tutorialCollection)
                menuStack = tutorialMenu :| pure topMenu

            -- Finally, set the menu stack, and start the scenario!
            uiState . uiMenu .= NewGameMenu menuStack

            let remainingTutorials = maybe mempty (getScenariosAfterSelection tutorialMenu) $ BL.listSelected tutorialMenu
            startGame (pathifyCollection firstUnsolvedInfo :| remainingTutorials) Nothing

          -- This shouldn't normally happen, but it could if the
          -- correct data files aren't installed.  In that case, log
          -- an error.
          _ -> runtimeState . eventLog %= logEvent SystemLog Error "Tutorials" "No tutorials found!"
      Achievements -> uiState . uiMenu .= AchievementsMenu (BL.list AchievementList (V.fromList listAchievements) 1)
      Messages -> do
        runtimeState . eventLog . notificationsCount .= 0
        uiState . uiMenu .= MessagesMenu
      About -> do
        uiState . uiMenu .= AboutMenu
        Brick.zoom (playState . progression) $
          attainAchievement $
            GlobalAchievement LookedAtAboutScreen
      Quit -> haltApp
  CharKey 'q' -> haltApp
  ControlChar 'q' -> haltApp
  VtyEvent ev -> do
    menu' <- nestEventM' menu (handleListEvent ev)
    uiState . uiMenu .= MainMenu menu'
  _ -> pure ()

-- | If we are in a New Game menu, advance the menu to the next item in order.
--
--   NOTE: be careful to maintain the invariant that the currently selected
--   menu item is always the same as the currently played scenario!  `quitGame`
--   is the only place this function should be called.
advanceMenu :: Menu -> Menu
advanceMenu = _NewGameMenu . ix 0 %~ BL.listMoveDown

handleMainAchievementsEvent ::
  BL.List Name CategorizedAchievement ->
  BrickEvent Name AppEvent ->
  EventM Name AppState ()
handleMainAchievementsEvent l e = case e of
  Key V.KEsc -> returnToMainMenu
  CharKey 'q' -> returnToMainMenu
  ControlChar 'q' -> returnToMainMenu
  VtyEvent ev -> do
    l' <- nestEventM' l (handleListEvent ev)
    uiState . uiMenu .= AchievementsMenu l'
  _ -> pure ()
 where
  returnToMainMenu = uiState . uiMenu .= MainMenu (mainMenu Messages)

handleMainMessagesEvent :: BrickEvent Name AppEvent -> EventM Name AppState ()
handleMainMessagesEvent = \case
  Key V.KEsc -> returnToMainMenu
  CharKey 'q' -> returnToMainMenu
  ControlChar 'q' -> returnToMainMenu
  _ -> pure ()
 where
  returnToMainMenu = uiState . uiMenu .= MainMenu (mainMenu Messages)

-- TODO: #2010 Finish porting Controller to KeyEventHandlers
handleNewGameMenuEvent ::
  NonEmpty (BL.List Name (ScenarioItem ScenarioPath)) ->
  BrickEvent Name AppEvent ->
  EventM Name AppState ()
handleNewGameMenuEvent scenarioStack@(curMenu :| rest) = \case
  Key V.KEnter ->
    forM_ (BL.listSelectedElement curMenu) $ \(pos, item) -> case item of
      SISingle siPair -> do
        invalidateCache
        let remaining = getScenariosAfterSelection curMenu pos
        startGame (siPair :| remaining) Nothing
      SICollection _ c -> uiState . uiMenu .= NewGameMenu (NE.cons (mkScenarioList c) scenarioStack)
  CharKey 'o' -> showLaunchDialog
  CharKey 'O' -> showLaunchDialog
  Key V.KEsc -> exitNewGameMenu scenarioStack
  CharKey 'q' -> exitNewGameMenu scenarioStack
  ControlChar 'q' -> haltApp
  VtyEvent ev -> do
    menu' <- nestEventM' curMenu (handleListEvent ev)
    uiState . uiMenu .= NewGameMenu (menu' :| rest)
  _ -> pure ()
 where
  showLaunchDialog = case snd <$> BL.listSelectedElement curMenu of
    Just (SISingle (ScenarioWith s (ScenarioPath p))) -> do
      ss <- use $ playState . progression . scenarios
      let si = getScenarioInfoFromPath ss p
      Brick.zoom (uiState . uiLaunchConfig) $ prepareLaunchDialog $ ScenarioWith s si
    _ -> pure ()

exitNewGameMenu ::
  NonEmpty (BL.List Name (ScenarioItem ScenarioPath)) ->
  EventM Name AppState ()
exitNewGameMenu stk =
  uiState
    . uiMenu
    .= case snd (NE.uncons stk) of
      Nothing -> MainMenu (mainMenu NewGame)
      Just stk' -> NewGameMenu stk'

pressAnyKey :: Menu -> BrickEvent Name AppEvent -> EventM Name AppState ()
pressAnyKey m (VtyEvent (V.EvKey _ _)) = uiState . uiMenu .= m
pressAnyKey _ _ = pure ()

-- | The top-level event handler while we are running the game itself.
handleMainEvent :: Bool -> BrickEvent Name AppEvent -> EventM Name AppState ()
handleMainEvent forceRedraw ev = do
  s <- get
  let keyHandler = s ^. keyEventHandling . keyDispatchers . to mainGameDispatcher
  case ev of
    AppEvent ae -> case ae of
      -- If the game is paused, don't run any game ticks, but do redraw if needed.
      Frame ->
        if s ^. playState . scenarioState . gameState . temporal . paused
          then updateAndRedrawUI forceRedraw
          else runFrameUI forceRedraw
      Web (RunWebCode e r) -> Brick.zoom (playState . scenarioState) $ runBaseWebCode e r
      -- UpstreamVersion event should already be handled by top-level handler, so
      -- in theory this case cannot happen.
      UpstreamVersion _ -> pure ()
      -- PopupEvent event should already be handled by top-level handler, so this shouldn't happen.
      PopupEvent _ -> pure ()
    VtyEvent (V.EvResize _ _) -> invalidateCache
    EscapeKey
      | Just m <- s ^. playState . scenarioState . uiGameplay . uiDialogs . uiModal ->
          Brick.zoom (playState . scenarioState) $
            if s ^. playState . scenarioState . uiGameplay . uiDialogs . uiRobot . isDetailsOpened
              then uiGameplay . uiDialogs . uiRobot . isDetailsOpened .= False
              else closeModal m
    -- Pass to key handler (allows users to configure bindings)
    -- See Note [how Swarm event handlers work]
    VtyEvent (V.EvKey k m)
      | isJust (B.lookupVtyEvent k m keyHandler) -> void $ B.handleKey keyHandler k m
    -- pass keys on to modal event handler if a modal is open
    VtyEvent vev
      | isJust (s ^. playState . scenarioState . uiGameplay . uiDialogs . uiModal) -> handleModalEvent vev
    MouseDown (TerrainListItem pos) V.BLeft _ _ ->
      playState . scenarioState . uiGameplay . uiWorldEditor . terrainList %= BL.listMoveTo pos
    MouseDown (EntityPaintListItem pos) V.BLeft _ _ ->
      playState . scenarioState . uiGameplay . uiWorldEditor . entityPaintList %= BL.listMoveTo pos
    MouseDown WorldPositionIndicator _ _ _ ->
      playState . scenarioState . uiGameplay . uiWorldCursor .= Nothing
    MouseDown (FocusablePanel WorldPanel) V.BMiddle _ mouseLoc ->
      -- Eye Dropper tool
      Brick.zoom (playState . scenarioState) $ EC.handleMiddleClick mouseLoc
    MouseDown (FocusablePanel WorldPanel) V.BRight _ mouseLoc ->
      -- Eraser tool
      Brick.zoom (playState . scenarioState) $ EC.handleRightClick mouseLoc
    MouseDown (FocusablePanel WorldPanel) V.BLeft [V.MCtrl] mouseLoc ->
      -- Paint with the World Editor
      Brick.zoom (playState . scenarioState) $ EC.handleCtrlLeftClick mouseLoc
    MouseDown n _ _ mouseLoc ->
      case n of
        FocusablePanel WorldPanel -> Brick.zoom (playState . scenarioState) $ do
          mouseCoordsM <- Brick.zoom gameState $ mouseLocToWorldCoords mouseLoc
          shouldUpdateCursor <- EC.updateAreaBounds mouseCoordsM
          when shouldUpdateCursor $
            uiGameplay . uiWorldCursor .= mouseCoordsM
        REPLInput -> handleREPLEvent ev
        (UIShortcut "Help") -> Brick.zoom (playState . scenarioState) $ toggleMidScenarioModal HelpModal
        (UIShortcut "Robots") -> Brick.zoom (playState . scenarioState) $ toggleMidScenarioModal RobotsModal
        (UIShortcut "Commands") -> Brick.zoom (playState . scenarioState) $ toggleDiscoveryNotificationModal CommandsModal availableCommands
        (UIShortcut "Recipes") -> Brick.zoom (playState . scenarioState) $ toggleDiscoveryNotificationModal RecipesModal availableRecipes
        (UIShortcut "Messages") -> Brick.zoom (playState . scenarioState) toggleMessagesModal
        (UIShortcut "pause") -> Brick.zoom (playState . scenarioState) $ whenRunningPlayState safeTogglePause
        (UIShortcut "unpause") -> Brick.zoom (playState . scenarioState) $ whenRunningPlayState safeTogglePause
        (UIShortcut "step") -> whenRunningAppState runSingleTick
        (UIShortcut "speed-up") -> Brick.zoom (playState . scenarioState) $ whenRunningPlayState . modify $ adjustTPS (+)
        (UIShortcut "speed-down") -> Brick.zoom (playState . scenarioState) $ whenRunningPlayState . modify $ adjustTPS (-)
        (UIShortcut "hide REPL") -> Brick.zoom (playState . scenarioState) toggleREPLVisibility
        (UIShortcut "show REPL") -> Brick.zoom (playState . scenarioState) toggleREPLVisibility
        (UIShortcut "debug") -> showCESKDebug
        (UIShortcut "hide robots") -> Brick.zoom (playState . scenarioState . uiGameplay) hideRobots
        (UIShortcut "goal") -> Brick.zoom (playState . scenarioState) viewGoal
        _ -> continueWithoutRedraw
    MouseUp n _ _mouseLoc ->
      Brick.zoom (playState . scenarioState) $ do
        case n of
          InventoryListItem pos -> uiGameplay . uiInventory . uiInventoryList . traverse . _2 %= BL.listMoveTo pos
          x@(WorldEditorPanelControl y) -> do
            uiGameplay . uiWorldEditor . editorFocusRing %= focusSetCurrent x
            EC.activateWorldEditorFunction y
          _ -> return ()
        flip whenJust setFocus $ case n of
          -- Adapt click event origin to the right panel.  For the world
          -- view, we just use 'Brick.Widgets.Core.clickable'.  However,
          -- the other panels all have a viewport, requiring us to
          -- explicitly set their focus here.
          InventoryList -> Just RobotPanel
          InventoryListItem _ -> Just RobotPanel
          InfoViewport -> Just InfoPanel
          REPLViewport -> Just REPLPanel
          REPLInput -> Just REPLPanel
          WorldEditorPanelControl _ -> Just WorldEditorPanel
          _ -> Nothing
        case n of
          FocusablePanel x -> setFocus x
          _ -> return ()
    -- dispatch any other events to the focused panel handler
    _ev -> do
      fring <- use $ playState . scenarioState . uiGameplay . uiFocusRing
      case focusGetCurrent fring of
        Just (FocusablePanel x) -> case x of
          REPLPanel -> handleREPLEvent ev
          -- Pass to key handler (allows users to configure bindings)
          -- See Note [how Swarm event handlers work]
          WorldPanel | VtyEvent (V.EvKey k m) <- ev -> do
            wh <- use $ keyEventHandling . keyDispatchers . to worldDispatcher
            void $ B.handleKey wh k m
          WorldPanel | otherwise -> continueWithoutRedraw
          WorldEditorPanel -> Brick.zoom (playState . scenarioState) $ EC.handleWorldEditorPanelEvent ev
          RobotPanel -> handleRobotPanelEvent ev
          InfoPanel -> handleInfoPanelEvent infoScroll ev
        _ -> continueWithoutRedraw

closeModal :: Modal -> EventM Name ScenarioState ()
closeModal m = do
  safeAutoUnpause
  uiGameplay . uiDialogs . uiModal .= Nothing
  -- message modal is not autopaused, so update notifications when leaving it
  when (m ^. modalType == MidScenarioModal MessagesModal) $ do
    t <- use $ gameState . temporal . ticks
    gameState . messageInfo . lastSeenMessageTime .= t

-- TODO: #2010 Finish porting Controller to KeyEventHandlers
handleModalEvent :: V.Event -> EventM Name AppState ()
handleModalEvent = \case
  V.EvKey V.KEnter [] -> do
    modal <- preuse $ playState . scenarioState . uiGameplay . uiDialogs . uiModal . _Just . modalType
    case modal of
      Just (MidScenarioModal RobotsModal) -> do
        robotDialog <- use $ playState . scenarioState . uiGameplay . uiDialogs . uiRobot
        unless (robotDialog ^. isDetailsOpened) $ do
          g <- use $ playState . scenarioState . gameState
          let widget = robotDialog ^. robotsGridList
          forM_ (getSelectedRobot g widget) $ \rob -> Brick.zoom (playState . scenarioState . uiGameplay . uiDialogs . uiRobot) $ do
            isDetailsOpened .= True
            Brick.zoom robotDetailsPaneState $ updateRobotDetailsPane rob
      _ -> do
        menu <- use $ uiState . uiMenu

        mdialog <- preuse $ playState . scenarioState . uiGameplay . uiDialogs . uiModal . _Just . modalDialog
        Brick.zoom playState $ toggleEndScenarioModal QuitModal menu

        let isNoMenu = case menu of
              NoMenu -> True
              _ -> False

        case dialogSelection =<< mdialog of
          Just (Button QuitButton, _) -> quitGame isNoMenu
          Just (Button KeepPlayingButton, _) -> Brick.zoom playState $ toggleEndScenarioModal KeepPlayingModal menu
          Just (Button StartOverButton, StartOver currentSeed siPair) -> do
            invalidateCache
            restartGame currentSeed siPair
          Just (Button NextButton, Next remainingScenarios) -> do
            quitGame isNoMenu
            invalidateCache
            startGame remainingScenarios Nothing
          _ -> return ()
  ev -> Brick.zoom (playState . scenarioState) $ do
    Brick.zoom (uiGameplay . uiDialogs . uiModal . _Just . modalDialog) (handleDialogEvent ev)
    modal <- preuse $ uiGameplay . uiDialogs . uiModal . _Just . modalType
    case modal of
      Just (MidScenarioModal TerrainPaletteModal) ->
        refreshList $ uiGameplay . uiWorldEditor . terrainList
      Just (MidScenarioModal EntityPaletteModal) -> refreshList $ uiGameplay . uiWorldEditor . entityPaintList
      Just (MidScenarioModal GoalModal) -> case ev of
        V.EvKey (V.KChar '\t') [] -> uiGameplay . uiDialogs . uiGoal . focus %= focusNext
        _ -> do
          focused <- use $ uiGameplay . uiDialogs . uiGoal . focus
          case focusGetCurrent focused of
            Just (GoalWidgets w) -> case w of
              ObjectivesList -> do
                lw <- use $ uiGameplay . uiDialogs . uiGoal . listWidget
                newList <- refreshGoalList lw
                uiGameplay . uiDialogs . uiGoal . listWidget .= newList
              GoalSummary -> handleInfoPanelEvent modalScroll (VtyEvent ev)
            _ -> handleInfoPanelEvent modalScroll (VtyEvent ev)
      Just (MidScenarioModal StructuresModal) -> case ev of
        V.EvKey (V.KChar '\t') [] -> uiGameplay . uiDialogs . uiStructure . structurePanelFocus %= focusNext
        _ -> do
          focused <- use $ uiGameplay . uiDialogs . uiStructure . structurePanelFocus
          case focusGetCurrent focused of
            Just (StructureWidgets w) -> case w of
              StructuresList ->
                refreshList $ uiGameplay . uiDialogs . uiStructure . structurePanelListWidget
              StructureSummary -> handleInfoPanelEvent modalScroll (VtyEvent ev)
            _ -> handleInfoPanelEvent modalScroll (VtyEvent ev)
      Just (MidScenarioModal RobotsModal) -> do
        uiGame <- use uiGameplay
        g <- use gameState
        Brick.zoom (uiGameplay . uiDialogs . uiRobot) $ case ev of
          V.EvKey (V.KChar '\t') [] -> robotDetailsPaneState . detailFocus %= focusNext
          _ -> do
            isInDetailsMode <- use isDetailsOpened
            if isInDetailsMode
              then Brick.zoom (robotDetailsPaneState . logsList) $ handleListEvent ev
              else do
                Brick.zoom robotsGridList $ BG.handleGridListEvent (robotGridRenderers uiGame g) ev
                -- Ensure list widget content is updated immediately
                mRob <- use $ robotsGridList . to (getSelectedRobot g)
                forM_ mRob $ Brick.zoom robotDetailsPaneState . updateRobotDetailsPane
      _ -> handleInfoPanelEvent modalScroll (VtyEvent ev)
   where
    refreshGoalList lw = nestEventM' lw $ handleListEventWithSeparators ev shouldSkipSelection
    refreshList z = Brick.zoom z $ BL.handleListEvent ev

-- | Quit a game.
--
-- * writes out the updated REPL history to a @.swarm_history@ file
-- * saves current scenario status (InProgress/Completed)
-- * advances the menu to the next scenario IF the current one was won
-- * returns to the previous menu
quitGame :: Bool -> EventM Name AppState ()
quitGame isNoMenu = do
  -- Write out REPL history.
  history <- use $ playState . scenarioState . uiGameplay . uiREPL . replHistory
  let hist = mapMaybe getREPLSubmitted $ getLatestREPLHistoryItems maxBound history
  liftIO $ (`T.appendFile` T.unlines hist) =<< getSwarmHistoryPath True

  -- Save scenario status info.
  dOps <- use $ uiState . uiDebugOptions
  Brick.zoom playState $ saveScenarioInfoOnQuit dOps

  -- Automatically advance the menu to the next scenario iff the
  -- player has won the current one.
  wc <- use $ playState . scenarioState . gameState . winCondition
  case wc of
    WinConditions (Won _ _) _ -> uiState . uiMenu %= advanceMenu
    _ -> return ()

  -- Either quit the entire app (if the scenario was chosen directly
  -- from the command line) or return to the menu (if the scenario was
  -- chosen from the menu).
  if isNoMenu
    then haltApp
    else uiState . uiPlaying .= False

------------------------------------------------------------
-- REPL events
------------------------------------------------------------

-- | Handle a user input event for the REPL.
handleREPLEvent :: BrickEvent Name AppEvent -> EventM Name AppState ()
handleREPLEvent x = do
  s <- get
  let controlMode = s ^. playState . scenarioState . uiGameplay . uiREPL . replControlMode
  let keyHandler = s ^. keyEventHandling . keyDispatchers . to replDispatcher
  let menu = s ^. uiState . uiMenu
  case x of
    -- Pass to key handler (allows users to configure bindings)
    -- See Note [how Swarm event handlers work]
    VtyEvent (V.EvKey k m)
      | isJust (B.lookupVtyEvent k m keyHandler) ->
          void $ B.handleKey keyHandler k m
    -- Handle other events in a way appropriate to the current REPL
    -- control mode.
    _ -> Brick.zoom playState $ case controlMode of
      Typing -> handleREPLEventTyping menu x
      Piloting -> handleREPLEventPiloting menu x
      Handling -> case x of
        -- Handle keypresses using the custom installed handler
        VtyEvent (V.EvKey k mods) -> Brick.zoom scenarioState $ runInputHandler (mkKeyCombo mods k)
        -- Handle all other events normally
        _ -> handleREPLEventTyping menu x

-- | Run the installed input handler on a key combo entered by the user.
runInputHandler :: KeyCombo -> EventM Name ScenarioState ()
runInputHandler kc = do
  mhandler <- use $ gameState . gameControls . inputHandler
  forM_ mhandler $ \(_, handler) -> do
    -- Shouldn't be possible to get here if there is no input handler, but
    -- if we do somehow, just do nothing.

    -- Make sure the base is currently idle; if so, apply the
    -- installed input handler function to a `key` value
    -- representing the typed input.
    working <- use $ gameState . gameControls . replWorking
    unless working $ do
      s <- get
      let env = fromMaybe emptyEnv $ s ^? gameState . baseEnv
          store = s ^. gameState . baseStore
          handlerCESK = Out (VKey kc) store [FApp handler, FExec, FSuspend env]
      gameState . baseRobot . machine .= handlerCESK
      gameState %= execState (zoomRobots $ activateRobot 0)

-- | Handle a user "piloting" input event for the REPL.
--
-- TODO: #2010 Finish porting Controller to KeyEventHandlers
handleREPLEventPiloting :: Menu -> BrickEvent Name AppEvent -> EventM Name PlayState ()
handleREPLEventPiloting m x = case x of
  Key V.KUp -> inputCmd "move"
  Key V.KDown -> inputCmd "turn back"
  Key V.KLeft -> inputCmd "turn left"
  Key V.KRight -> inputCmd "turn right"
  ShiftKey V.KUp -> inputCmd "turn north"
  ShiftKey V.KDown -> inputCmd "turn south"
  ShiftKey V.KLeft -> inputCmd "turn west"
  ShiftKey V.KRight -> inputCmd "turn east"
  Key V.KDel -> inputCmd "selfdestruct"
  CharKey 'g' -> inputCmd "grab"
  CharKey 'h' -> inputCmd "harvest"
  CharKey 'd' -> inputCmd "drill forward"
  CharKey 'x' -> inputCmd "drill down"
  CharKey 's' -> inputCmd "scan forward"
  CharKey 'b' -> inputCmd "blocked"
  CharKey 'u' -> inputCmd "upload base"
  CharKey 'p' -> inputCmd "push"
  _ -> inputCmd "noop"
 where
  inputCmd cmdText = do
    scenarioState . uiGameplay . uiREPL %= setCmd (cmdText <> ";")
    Brick.zoom scenarioState $ modify validateREPLForm
    handleREPLEventTyping m $ Key V.KEnter

  setCmd nt theRepl =
    theRepl
      & replPromptText .~ nt
      & replPromptType .~ CmdPrompt []

runBaseWebCode :: (MonadState ScenarioState m, MonadIO m) => T.Text -> (WebInvocationState -> IO ()) -> m ()
runBaseWebCode uinput ureply = do
  s <- get
  if s ^. gameState . gameControls . replWorking
    then liftIO . ureply $ Rejected AlreadyRunning
    else do
      gameState . gameControls . replListener .= ureply . Complete . T.unpack
      runBaseCode uinput
        >>= liftIO . ureply . \case
          Left err -> Rejected . ParseError $ T.unpack err
          Right () -> InProgress

runBaseCode :: (MonadState ScenarioState m) => T.Text -> m (Either Text ())
runBaseCode uinput = do
  addREPLHistItem (REPLEntry Submitted) uinput
  resetREPL "" (CmdPrompt [])
  env <- fromMaybe emptyEnv <$> preuse (gameState . baseEnv)
  case processTerm' env uinput of
    Right mt -> do
      uiGameplay . uiREPL . replHistory . replHasExecutedManualInput .= True
      runBaseTerm mt
      return (Right ())
    Left err -> do
      addREPLHistItem REPLError err
      return (Left err)

-- | Handle a user input event for the REPL.
--
-- TODO: #2010 Finish porting Controller to KeyEventHandlers
handleREPLEventTyping :: Menu -> BrickEvent Name AppEvent -> EventM Name PlayState ()
handleREPLEventTyping m = \case
  -- Scroll the REPL on PageUp or PageDown
  Key V.KPageUp -> vScrollPage replScroll Brick.Up
  Key V.KPageDown -> vScrollPage replScroll Brick.Down
  k -> do
    -- On any other key event, jump to the bottom of the REPL then handle the event
    vScrollToEnd replScroll
    case k of
      Key V.KEnter -> Brick.zoom scenarioState $ do
        s <- get
        let theRepl = s ^. uiGameplay . uiREPL
            uinput = theRepl ^. replPromptText

        if not $ s ^. gameState . gameControls . replWorking
          then case theRepl ^. replPromptType of
            CmdPrompt _ -> do
              void $ runBaseCode uinput
              invalidateCacheEntry REPLHistoryCache
            SearchPrompt hist ->
              case lastEntry uinput hist of
                Nothing -> resetREPL "" (CmdPrompt [])
                Just found
                  | T.null uinput -> resetREPL "" (CmdPrompt [])
                  | otherwise -> do
                      resetREPL found (CmdPrompt [])
                      modify validateREPLForm
          else continueWithoutRedraw
      Key V.KUp -> Brick.zoom scenarioState $ modify $ adjReplHistIndex Older
      Key V.KDown -> Brick.zoom scenarioState $ do
        repl <- use $ uiGameplay . uiREPL
        let hist = repl ^. replHistory
            uinput = repl ^. replPromptText
        case repl ^. replPromptType of
          CmdPrompt {}
            | hist ^. replIndex == replLength hist && not (T.null uinput) ->
                -- Special case for hitting "Down" arrow while entering a new non-empty input:
                -- save the input in the history and make the REPL blank.
                do
                  addREPLHistItem (REPLEntry Stashed) uinput
                  resetREPL "" (CmdPrompt [])
                  modify validateREPLForm
          -- Otherwise, just move around in the history as normal.
          _ -> modify $ adjReplHistIndex Newer
      ControlChar 'r' ->
        Brick.zoom (scenarioState . uiGameplay . uiREPL) $ do
          uir <- get
          let uinput = uir ^. replPromptText
          case uir ^. replPromptType of
            CmdPrompt _ -> replPromptType .= SearchPrompt (uir ^. replHistory)
            SearchPrompt rh -> forM_ (lastEntry uinput rh) $ \found ->
              replPromptType .= SearchPrompt (removeEntry found rh)
      CharKey '\t' -> Brick.zoom scenarioState $ do
        s <- get
        let names = s ^.. gameState . baseEnv . envTypes . to assocs . traverse . _1
        uiGameplay . uiREPL %= tabComplete (CompletionContext (s ^. gameState . creativeMode)) names (s ^. gameState . landscape . terrainAndEntities . entityMap)
        modify validateREPLForm
      EscapeKey -> Brick.zoom scenarioState $ do
        formSt <- use $ uiGameplay . uiREPL . replPromptType
        case formSt of
          CmdPrompt {} -> continueWithoutRedraw
          SearchPrompt _ -> resetREPL "" (CmdPrompt [])
      ControlChar 'd' -> do
        text <- use $ scenarioState . uiGameplay . uiREPL . replPromptText
        if text == T.empty
          then toggleEndScenarioModal QuitModal m
          else continueWithoutRedraw
      MetaKey V.KBS ->
        Brick.zoom scenarioState $
          uiGameplay . uiREPL . replPromptEditor %= applyEdit TZ.deletePrevWord
      -- finally if none match pass the event to the editor
      ev -> do
        Brick.zoom (scenarioState . uiGameplay . uiREPL . replPromptEditor) $ case ev of
          CharKey c
            | c `elem` ("([{" :: String) -> insertMatchingPair c
            | c `elem` (")]}" :: String) -> insertOrMovePast c
          _ -> handleEditorEvent ev
        scenarioState . uiGameplay . uiREPL . replPromptType %= \case
          CmdPrompt _ -> CmdPrompt [] -- reset completions on any event passed to editor
          SearchPrompt a -> SearchPrompt a

        -- Now re-validate the input, unless only the cursor moved.
        case ev of
          Key V.KLeft -> pure ()
          Key V.KRight -> pure ()
          _ -> Brick.zoom scenarioState $ modify validateREPLForm

insertMatchingPair :: Char -> EventM Name (Editor Text Name) ()
insertMatchingPair c = modify . applyEdit $ TZ.insertChar c >>> TZ.insertChar (close c) >>> TZ.moveLeft
 where
  close = \case
    '(' -> ')'
    '[' -> ']'
    '{' -> '}'
    _ -> c

-- | Insert a character in an editor unless it matches the character
--   already at the cursor, in which case we just move past it
--   instead, without inserting an extra copy.
insertOrMovePast :: Char -> EventM Name (Editor Text Name) ()
insertOrMovePast c = do
  e <- get
  modify . applyEdit $ case TZ.currentChar (e ^. editContentsL) of
    Just c' | c' == c -> TZ.moveRight
    _ -> TZ.insertChar c

data CompletionType
  = FunctionName
  | EntityName
  deriving (Eq)

newtype CompletionContext = CompletionContext {ctxCreativeMode :: Bool}
  deriving (Eq)

-- | Reserved words corresponding to commands that can only be used in
--   creative mode.  We only autocomplete to these when in creative mode.
creativeWords :: Set Text
creativeWords =
  S.fromList
    . map (syntax . constInfo)
    . filter (\w -> constCaps w == Just CGod)
    $ allConst

-- | Try to complete the last word in a partially-entered REPL prompt using
--   reserved words and names in scope (in the case of function names) or
--   entity names (in the case of string literals).
tabComplete :: CompletionContext -> [Text] -> EntityMap -> REPLState -> REPLState
tabComplete CompletionContext {..} names em theRepl = case theRepl ^. replPromptType of
  SearchPrompt _ -> theRepl
  CmdPrompt mms
    -- Case 1: If completion candidates have already been
    -- populated via case (3), cycle through them.
    -- Note that tabbing through the candidates *does* update the value
    -- of "t", which one might think would narrow the candidate list
    -- to only that match and therefore halt the cycling.
    -- However, the candidate list only gets recomputed (repopulated)
    -- if the user subsequently presses a non-Tab key. Thus the current
    -- value of "t" is ignored for all Tab presses subsequent to the
    -- first.
    | (m : ms) <- mms -> setCmd (replacementFunc m) (ms ++ [m])
    -- Case 2: Require at least one letter to be typed in order to offer completions for
    -- function names.
    -- We allow suggestions for Entity Name strings without anything having been typed.
    | T.null lastWord && completionType == FunctionName -> setCmd t []
    -- Case 3: Typing another character in the REPL clears the completion candidates from
    -- the CmdPrompt, so when Tab is pressed again, this case then gets executed and
    -- repopulates them.
    | otherwise -> case candidateMatches of
        [] -> setCmd t []
        [m] -> setCmd (completeWith m) []
        -- Perform completion with the first candidate, then populate the list
        -- of all candidates with the current completion moved to the back
        -- of the queue.
        (m : ms) -> setCmd (completeWith m) (ms ++ [m])
 where
  -- checks the "parity" of the number of quotes. If odd, then there is an open quote.
  hasOpenQuotes = (== 1) . (`mod` 2) . T.count "\""

  completionType =
    if hasOpenQuotes t
      then EntityName
      else FunctionName

  replacementFunc = T.append $ T.dropWhileEnd replacementBoundaryPredicate t
  completeWith m = T.append t $ T.drop (T.length lastWord) m
  lastWord = T.takeWhileEnd replacementBoundaryPredicate t
  candidateMatches = filter (lastWord `T.isPrefixOf`) replacementCandidates

  (replacementCandidates, replacementBoundaryPredicate) = case completionType of
    EntityName -> (entityNames, (/= '"'))
    FunctionName -> (possibleWords, isIdentChar)

  possibleWords =
    names <> (if ctxCreativeMode then S.toList reservedWords else S.toList $ reservedWords `S.difference` creativeWords)

  entityNames = M.keys $ entitiesByName em

  t = theRepl ^. replPromptText
  setCmd nt ms =
    theRepl
      & replPromptText .~ nt
      & replPromptType .~ CmdPrompt ms

-- | Validate the REPL input when it changes: see if it parses and
--   typechecks, and set the color accordingly.
validateREPLForm :: ScenarioState -> ScenarioState
validateREPLForm s =
  case replPrompt of
    CmdPrompt _
      | T.null uinput ->
          let theType = s ^. gameState . gameControls . replStatus . replActiveType
           in s & uiGameplay . uiREPL . replType .~ theType
    CmdPrompt _
      | otherwise ->
          let env = fromMaybe emptyEnv $ s ^? gameState . baseEnv
              (theType, errSrcLoc) = case readTerm' defaultParserConfig uinput of
                Left err ->
                  let ((_y1, x1), (_y2, x2), _msg) = showErrorPos err
                   in (Nothing, Left (SrcLoc x1 x2))
                Right Nothing -> (Nothing, Right ())
                Right (Just theTerm) -> case processParsedTerm' env theTerm of
                  Right t -> (Just (t ^. sType), Right ())
                  Left err -> (Nothing, Left (cteSrcLoc err))
           in s
                & uiGameplay . uiREPL . replValid .~ errSrcLoc
                & uiGameplay . uiREPL . replType .~ theType
    SearchPrompt _ -> s
 where
  uinput = s ^. uiGameplay . uiREPL . replPromptText
  replPrompt = s ^. uiGameplay . uiREPL . replPromptType

-- | Update our current position in the REPL history.
adjReplHistIndex :: TimeDir -> ScenarioState -> ScenarioState
adjReplHistIndex d s =
  s
    & uiGameplay . uiREPL %~ moveREPL
    & validateREPLForm
 where
  moveREPL :: REPLState -> REPLState
  moveREPL theRepl =
    newREPL
      & applyWhen (replIndexIsAtInput (theRepl ^. replHistory)) saveLastEntry
      & applyWhen (oldEntry /= newEntry) showNewEntry
   where
    -- new AppState after moving the repl index
    newREPL :: REPLState
    newREPL = theRepl & replHistory %~ moveReplHistIndex d oldEntry

    saveLastEntry = replLast .~ theRepl ^. replPromptText
    showNewEntry = (replPromptEditor .~ newREPLEditor newEntry) . (replPromptType .~ CmdPrompt [])
    -- get REPL data
    getCurrEntry = fromMaybe (theRepl ^. replLast) . getCurrentItemText . view replHistory
    oldEntry = getCurrEntry theRepl
    newEntry = getCurrEntry newREPL

------------------------------------------------------------
-- Info panel events
------------------------------------------------------------

-- | Handle user events in the info panel (just scrolling).
--
-- TODO: #2010 Finish porting Controller to KeyEventHandlers
handleInfoPanelEvent :: ViewportScroll Name -> BrickEvent Name AppEvent -> EventM Name s ()
handleInfoPanelEvent vs = \case
  Key V.KDown -> vScrollBy vs 1
  Key V.KUp -> vScrollBy vs (-1)
  CharKey 'k' -> vScrollBy vs 1
  CharKey 'j' -> vScrollBy vs (-1)
  Key V.KPageDown -> vScrollPage vs Brick.Down
  Key V.KPageUp -> vScrollPage vs Brick.Up
  Key V.KHome -> vScrollToBeginning vs
  Key V.KEnd -> vScrollToEnd vs
  _ -> return ()

-- * Util

getScenariosAfterSelection ::
  BL.GenericList n V.Vector (ScenarioItem a) ->
  Int ->
  [ScenarioWith a]
getScenariosAfterSelection m selIndex =
  [x | SISingle x <- V.toList remaining]
 where
  remaining = snd $ BL.splitAt (selIndex + 1) $ listElements m