swarm-0.4: src/Swarm/TUI/Launch/Controller.hs
-- |
-- SPDX-License-Identifier: BSD-3-Clause
--
-- Event handling for the scenario launch configuration dialog.
module Swarm.TUI.Launch.Controller where
import Brick hiding (Direction, Location)
import Brick.Focus
import Brick.Widgets.Edit (handleEditorEvent)
import Brick.Widgets.FileBrowser
import Brick.Widgets.FileBrowser qualified as FB
import Control.Lens
import Control.Monad (forM_, when)
import Control.Monad.IO.Class (liftIO)
import Data.Maybe (listToMaybe)
import Graphics.Vty qualified as V
import Swarm.Game.Scenario.Status (ParameterizableLaunchParams (LaunchParams))
import Swarm.Game.ScenarioInfo
import Swarm.TUI.Controller.Util
import Swarm.TUI.Launch.Model
import Swarm.TUI.Launch.Prep (initFileBrowserWidget, makeFocusRingWith, parseSeedInput, parseWidgetParams, toValidatedParams)
import Swarm.TUI.Model
import Swarm.TUI.Model.Name
import Swarm.TUI.Model.StateUpdate
import Swarm.TUI.Model.UI
import Swarm.Util (listEnums)
updateFocusRing :: EditingLaunchParams -> EventM Name LaunchOptions ()
updateFocusRing parsedParams = do
currentRing <- use $ controls . scenarioConfigFocusRing
let eitherLaunchParams = toValidatedParams parsedParams
modifyRingMembers = case eitherLaunchParams of
Left _ -> filter (/= StartGameButton)
Right _ -> id
maybeCurrentFocus = focusGetCurrent currentRing
refocusRing = maybe id focusSetCurrent maybeCurrentFocus
controls . scenarioConfigFocusRing .= refocusRing (makeFocusRingWith $ modifyRingMembers listEnums)
cacheValidatedInputs :: EventM Name LaunchOptions ()
cacheValidatedInputs = do
launchControls <- use controls
parsedParams <- liftIO $ parseWidgetParams launchControls
editingParams .= parsedParams
updateFocusRing parsedParams
-- | Split this out from the combined parameter-validation function
-- because validating the seed is cheap, and shouldn't have to pay
-- the cost of re-parsing script code as the user types in the seed
-- selection field.
cacheValidatedSeedInput :: EventM Name LaunchOptions ()
cacheValidatedSeedInput = do
seedEditor <- use $ controls . seedValueEditor
let eitherMaybeSeed = parseSeedInput seedEditor
LaunchParams _ eitherParsedCode <- use editingParams
let newParams = LaunchParams eitherMaybeSeed eitherParsedCode
editingParams .= newParams
updateFocusRing newParams
-- | If the FileBrowser is in "search mode", then we allow
-- more of the key events to pass through. Otherwise,
-- we intercept things like "q" (for quit) and Space (so that
-- we can restrict file selection to at most one).
handleFBEvent ::
BrickEvent Name AppEvent ->
EventM Name AppState ()
handleFBEvent ev = do
fb <- use $ uiState . uiLaunchConfig . controls . fileBrowser . fbWidget
let isSearching = fileBrowserIsSearching fb
case (isSearching, ev) of
(False, Key V.KEsc) -> closeModal
(False, CharKey 'q') -> closeModal
(False, ControlChar 'q') -> closeModal
-- Intercept the "space" key so that it cannot be used to select files
-- (see note below).
(False, CharKey ' ') -> return ()
(_, VtyEvent e) -> do
(shouldClose, maybeSingleFile) <- Brick.zoom (uiState . uiLaunchConfig . controls . fileBrowser . fbWidget) $ do
handleFileBrowserEvent e
-- If the browser has a selected file after handling the
-- event (because the user pressed Enter), close the dialog.
case e of
V.EvKey V.KEnter [] -> do
b' <- get
case FB.fileBrowserSelection b' of
[] -> return (False, Nothing)
-- We only allow one file to be selected
-- by closing immediately.
-- This is a hack illustrated in the Brick FileBrowser demo:
-- https://github.com/jtdaugherty/brick/blob/4b40476d5d58c40720170d21503c11596bc9ee39/programs/FileBrowserDemo.hs#L68-L69
-- It is not foolproof on its own, so we also intercept
-- the "Space" key above.
xs -> return (True, FB.fileInfoFilePath <$> listToMaybe xs)
-- NOTE: The "Space" key also selects a file.
-- Apparently, even when directories are specified as
-- non-selectable via "FB.selectNonDirectories", the internal state
-- of the FileBrowser dialog
-- briefly adds a directory to its "fileBrowserSelection" list
-- when the "space" key is pressed.
-- So it is not enough to simply check whether the selection list
-- is nonempty after *any* keypress; we specifically have to listen for "Enter".
--
-- WARNING: There is still a bug when one presses the "space" key to mark
-- a directory, then presses "Enter" right afterward.
-- The directory will get selected, and then swarm will crash.
-- This is why we prevent the Space key from being handled by the FileBrowser
-- unless we are in file searching mode.
_ -> return (False, Nothing)
when shouldClose $ do
uiState . uiLaunchConfig . controls . fileBrowser . maybeSelectedFile .= maybeSingleFile
closeModal
_ -> return ()
where
closeModal = Brick.zoom (uiState . uiLaunchConfig) $ do
controls . fileBrowser . fbIsDisplayed .= False
cacheValidatedInputs
handleLaunchOptionsEvent ::
ScenarioInfoPair ->
BrickEvent Name AppEvent ->
EventM Name AppState ()
handleLaunchOptionsEvent siPair = \case
Key V.KBackTab ->
uiState . uiLaunchConfig . controls . scenarioConfigFocusRing %= focusPrev
Key V.KUp ->
uiState . uiLaunchConfig . controls . scenarioConfigFocusRing %= focusPrev
CharKey '\t' ->
uiState . uiLaunchConfig . controls . scenarioConfigFocusRing %= focusNext
Key V.KDown ->
uiState . uiLaunchConfig . controls . scenarioConfigFocusRing %= focusNext
MouseDown n _ _ _ ->
case n of
ScenarioConfigControl (ScenarioConfigPanelControl x) -> do
uiState . uiLaunchConfig . controls . scenarioConfigFocusRing %= focusSetCurrent n
activateFocusedControl x
_ -> return ()
CharKey ' ' -> activateControl
Key V.KEnter -> activateControl
Key V.KEsc -> closeModal
CharKey 'q' -> closeModal
ControlChar 'q' -> closeModal
ev -> do
fr <- use $ uiState . uiLaunchConfig . controls . scenarioConfigFocusRing
case focusGetCurrent fr of
Just (ScenarioConfigControl (ScenarioConfigPanelControl SeedSelector)) -> Brick.zoom (uiState . uiLaunchConfig) $ do
Brick.zoom (controls . seedValueEditor) (handleEditorEvent ev)
cacheValidatedSeedInput
_ -> return ()
where
activateControl = do
fr <- use $ uiState . uiLaunchConfig . controls . scenarioConfigFocusRing
case focusGetCurrent fr of
Just (ScenarioConfigControl (ScenarioConfigPanelControl item)) ->
activateFocusedControl item
_ -> return ()
activateFocusedControl item = case item of
SeedSelector -> return ()
ScriptSelector -> Brick.zoom (uiState . uiLaunchConfig . controls . fileBrowser) $ do
maybeSingleFile <- use maybeSelectedFile
configuredFB <- initFileBrowserWidget maybeSingleFile
fbWidget .= configuredFB
fbIsDisplayed .= True
StartGameButton -> do
params <- use $ uiState . uiLaunchConfig . editingParams
let eitherLaunchParams = toValidatedParams params
forM_ eitherLaunchParams $ \launchParams -> do
closeModal
startGameWithSeed siPair launchParams
closeModal = uiState . uiLaunchConfig . controls . isDisplayedFor .= Nothing