diff --git a/src/Swarm/Game/Scenario/Status.hs b/src/Swarm/Game/Scenario/Status.hs index 5e1399e36..7f5375950 100644 --- a/src/Swarm/Game/Scenario/Status.hs +++ b/src/Swarm/Game/Scenario/Status.hs @@ -22,14 +22,39 @@ import Swarm.Game.Scenario.Scoring.Best import Swarm.Game.Scenario.Scoring.CodeSize import Swarm.Game.Scenario.Scoring.ConcreteMetrics import Swarm.Game.Scenario.Scoring.GenericMetrics +import Swarm.Game.WorldGen (Seed) import Swarm.Util.Lens (makeLensesNoSigs) +-- | These launch parameters are used in a number of ways: +-- * Serializing the seed/script path for saves +-- * Holding parse status from form fields, including Error info +-- * Carrying fully-validated launch parameters. +-- +-- Type parameters are utilized to support all of these use cases. +data ParameterizableLaunchParams b a = LaunchParms + { seedVal :: a (Maybe Seed) + , initialCode :: a (Maybe b) + } + +type SerializableLaunchParms = ParameterizableLaunchParams FilePath Identity +deriving instance Eq SerializableLaunchParms +deriving instance Ord SerializableLaunchParms +deriving instance Show SerializableLaunchParms +deriving instance Read SerializableLaunchParms +deriving instance Generic SerializableLaunchParms +deriving instance FromJSON SerializableLaunchParms +deriving instance ToJSON SerializableLaunchParms + -- | A "ScenarioStatus" stores the status of a scenario along with -- appropriate metadata: "NotStarted", or "Played". -- The "Played" status has two sub-states: "Attempted" or "Completed". data ScenarioStatus = NotStarted - | Played ProgressMetric BestRecords + | Played + SerializableLaunchParms + -- ^ initial seed and script to run + ProgressMetric + BestRecords deriving (Eq, Ord, Show, Read, Generic) instance FromJSON ScenarioStatus where @@ -39,6 +64,11 @@ instance ToJSON ScenarioStatus where toEncoding = genericToEncoding scenarioOptions toJSON = genericToJSON scenarioOptions +getLaunchParams :: ScenarioStatus -> SerializableLaunchParms +getLaunchParams = \case + NotStarted -> LaunchParms (pure Nothing) (pure Nothing) + Played x _ _ -> x + -- | A "ScenarioInfo" record stores metadata about a scenario: its -- canonical path and status. -- By way of the "ScenarioStatus" record, it stores the @@ -84,9 +114,9 @@ updateScenarioInfoOnFinish ticks completed si@(ScenarioInfo p prevPlayState) = case prevPlayState of - Played (Metric _ (ProgressStats start _currentPlayMetrics)) prevBestRecords -> + Played initialScript (Metric _ (ProgressStats start _currentPlayMetrics)) prevBestRecords -> ScenarioInfo p $ - Played newPlayMetric $ + Played initialScript newPlayMetric $ updateBest newPlayMetric prevBestRecords where el = (diffUTCTime `on` zonedTimeToUTC) z start diff --git a/src/Swarm/Game/State.hs b/src/Swarm/Game/State.hs index 9c365db21..970a2e6ed 100644 --- a/src/Swarm/Game/State.hs +++ b/src/Swarm/Game/State.hs @@ -88,6 +88,7 @@ module Swarm.Game.State ( CodeToRun (..), Sha1 (..), SolutionSource (..), + parseCodeFile, getParsedInitialCode, -- * Utilities @@ -112,6 +113,7 @@ module Swarm.Game.State ( toggleRunStatus, messageIsRecent, messageIsFromNearby, + getRunCodePath, ) where import Control.Algebra (Has) @@ -277,29 +279,33 @@ data SolutionSource | -- | Includes the SHA1 of the program text -- for the purpose of corroborating solutions -- on a leaderboard. - PlayerAuthored Sha1 + PlayerAuthored FilePath Sha1 data CodeToRun = CodeToRun SolutionSource ProcessedTerm -getParsedInitialCode :: Maybe FilePath -> ExceptT Text IO (Maybe CodeToRun) -getParsedInitialCode toRun = case toRun of - Nothing -> return Nothing - Just filepath -> do - contents <- liftIO $ TIO.readFile filepath +getRunCodePath :: CodeToRun -> Maybe FilePath +getRunCodePath (CodeToRun solutionSource _) = case solutionSource of + ScenarioSuggested -> Nothing + PlayerAuthored fp _ -> Just fp + +parseCodeFile :: FilePath -> IO (Either Text CodeToRun) +parseCodeFile filepath = do + contents <- TIO.readFile filepath + return $ do pt@(ProcessedTerm (Module (Syntax' srcLoc _ _) _) _ _) <- - ExceptT - . return - . left T.pack - $ processTermEither contents + left T.pack $ processTermEither contents let strippedText = stripSrc srcLoc contents programBytestring = TL.encodeUtf8 $ TL.fromStrict strippedText sha1Hash = showDigest $ sha1 programBytestring - return $ Just $ CodeToRun (PlayerAuthored $ Sha1 sha1Hash) pt + return $ CodeToRun (PlayerAuthored filepath $ Sha1 sha1Hash) pt where stripSrc :: SrcLoc -> Text -> Text stripSrc (SrcLoc start end) txt = T.drop start $ T.take end txt stripSrc NoLoc txt = txt +getParsedInitialCode :: Maybe FilePath -> ExceptT Text IO (Maybe CodeToRun) +getParsedInitialCode = traverse $ ExceptT . parseCodeFile + ------------------------------------------------------------ -- The main GameState record type ------------------------------------------------------------ diff --git a/src/Swarm/TUI/Attr.hs b/src/Swarm/TUI/Attr.hs index 7ab64990b..9251afd15 100644 --- a/src/Swarm/TUI/Attr.hs +++ b/src/Swarm/TUI/Attr.hs @@ -44,11 +44,13 @@ module Swarm.TUI.Attr ( greenAttr, redAttr, defAttr, + customEditFocusedAttr, ) where import Brick import Brick.Forms import Brick.Widgets.Dialog +import Brick.Widgets.Edit qualified as E import Brick.Widgets.List import Data.Bifunctor (bimap) import Data.Text (unpack) @@ -77,6 +79,7 @@ swarmAttrMap = (highlightAttr, fg V.cyan) , (invalidFormInputAttr, fg V.red) , (focusedFormInputAttr, V.defAttr) + , (customEditFocusedAttr, V.black `on` V.yellow) , (listSelectedFocusedAttr, bg V.blue) , (infoAttr, fg (V.rgbColor @Int 50 50 50)) , (buttonSelectedAttr, bg V.blue) @@ -168,6 +171,9 @@ boldAttr = attrName "bold" dimAttr = attrName "dim" defAttr = attrName "def" +customEditFocusedAttr :: AttrName +customEditFocusedAttr = attrName "custom" <> E.editFocusedAttr + -- | Some basic colors used in TUI. redAttr, greenAttr, blueAttr, yellowAttr, cyanAttr, lightCyanAttr, magentaAttr :: AttrName redAttr = attrName "red" diff --git a/src/Swarm/TUI/Controller.hs b/src/Swarm/TUI/Controller.hs index 105ffbb7d..01ca282b4 100644 --- a/src/Swarm/TUI/Controller.hs +++ b/src/Swarm/TUI/Controller.hs @@ -93,6 +93,9 @@ import Swarm.Language.Types import Swarm.Language.Value (Value (VKey, VUnit), prettyValue, stripVResult) import Swarm.TUI.Controller.Util import Swarm.TUI.Inventory.Sorting (cycleSortDirection, cycleSortOrder) +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.Goal @@ -133,7 +136,12 @@ handleEvent = \case -- quitGame function would have already halted the app). NoMenu -> const halt MainMenu l -> handleMainMenuEvent l - NewGameMenu l -> handleNewGameMenuEvent l + NewGameMenu l -> + if s ^. uiState . uiLaunchConfig . controls . fileBrowser . fbIsDisplayed + then handleFBEvent + else case s ^. uiState . uiLaunchConfig . controls . isDisplayedFor of + Nothing -> handleNewGameMenuEvent l + Just siPair -> handleLaunchOptionsEvent siPair MessagesMenu -> handleMainMessagesEvent AchievementsMenu l -> handleMainAchievementsEvent l AboutMenu -> pressAnyKey (MainMenu (mainMenu About)) @@ -222,7 +230,10 @@ handleMainMessagesEvent = \case where returnToMainMenu = uiState . uiMenu .= MainMenu (mainMenu Messages) -handleNewGameMenuEvent :: NonEmpty (BL.List Name ScenarioItem) -> BrickEvent Name AppEvent -> EventM Name AppState () +handleNewGameMenuEvent :: + NonEmpty (BL.List Name ScenarioItem) -> + BrickEvent Name AppEvent -> + EventM Name AppState () handleNewGameMenuEvent scenarioStack@(curMenu :| rest) = \case Key V.KEnter -> case snd <$> BL.listSelectedElement curMenu of @@ -231,6 +242,8 @@ handleNewGameMenuEvent scenarioStack@(curMenu :| rest) = \case Just (SICollection _ c) -> do cheat <- use $ uiState . uiCheatMode uiState . uiMenu .= NewGameMenu (NE.cons (mkScenarioList cheat c) scenarioStack) + CharKey 'o' -> showLaunchDialog + CharKey 'O' -> showLaunchDialog Key V.KEsc -> exitNewGameMenu scenarioStack CharKey 'q' -> exitNewGameMenu scenarioStack ControlChar 'q' -> halt @@ -238,6 +251,10 @@ handleNewGameMenuEvent scenarioStack@(curMenu :| rest) = \case menu' <- nestEventM' curMenu (handleListEvent ev) uiState . uiMenu .= NewGameMenu (menu' :| rest) _ -> continueWithoutRedraw + where + showLaunchDialog = case snd <$> BL.listSelectedElement curMenu of + Just (SISingle siPair) -> Brick.zoom (uiState . uiLaunchConfig) $ prepareLaunchDialog siPair + _ -> continueWithoutRedraw exitNewGameMenu :: NonEmpty (BL.List Name ScenarioItem) -> EventM Name AppState () exitNewGameMenu stk = do @@ -460,7 +477,7 @@ getNormalizedCurrentScenarioPath = saveScenarioInfoOnFinish :: (MonadIO m, MonadState AppState m) => FilePath -> m (Maybe ScenarioInfo) saveScenarioInfoOnFinish p = do - initialCode <- use $ gameState . initiallyRunCode + initialRunCode <- use $ gameState . initiallyRunCode t <- liftIO getZonedTime wc <- use $ gameState . winCondition let won = case wc of @@ -475,7 +492,7 @@ saveScenarioInfoOnFinish p = do currentScenarioInfo = runtimeState . scenarios . scenarioItemByPath p . _SISingle . _2 replHist <- use $ uiState . uiREPL . replHistory - let determinator = CodeSizeDeterminators initialCode $ replHist ^. replHasExecutedManualInput + let determinator = CodeSizeDeterminators initialRunCode $ replHist ^. replHasExecutedManualInput currentScenarioInfo %= updateScenarioInfoOnFinish determinator t ts won status <- preuse currentScenarioInfo diff --git a/src/Swarm/TUI/Launch/Controller.hs b/src/Swarm/TUI/Launch/Controller.hs new file mode 100644 index 000000000..2ec3ebe4f --- /dev/null +++ b/src/Swarm/TUI/Launch/Controller.hs @@ -0,0 +1,155 @@ +-- | +-- 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.Except (forM_, liftIO, when) +import Data.Maybe (listToMaybe) +import Graphics.Vty qualified as V +import Swarm.Game.ScenarioInfo +import Swarm.TUI.Controller.Util +import Swarm.TUI.Launch.Model +import Swarm.TUI.Launch.Prep (initFileBrowserWidget, makeFocusRingWith, parseWidgetParms, toValidatedParms) +import Swarm.TUI.Model +import Swarm.TUI.Model.Name +import Swarm.TUI.Model.StateUpdate +import Swarm.TUI.Model.UI +import Swarm.Util (listEnums) + +cacheValidatedInputs :: EventM Name AppState () +cacheValidatedInputs = do + launchControls <- use $ uiState . uiLaunchConfig . controls + parsedParams <- liftIO $ parseWidgetParms launchControls + uiState . uiLaunchConfig . editingParams .= parsedParams + + currentRing <- use $ uiState . uiLaunchConfig . controls . scenarioConfigFocusRing + + let eitherLaunchParams = toValidatedParms parsedParams + modifyRingMembers = case eitherLaunchParams of + Left _ -> filter (/= StartGameButton) + Right _ -> id + maybeCurrentFocus = focusGetCurrent currentRing + refocusRing = maybe id focusSetCurrent maybeCurrentFocus + + uiState . uiLaunchConfig . controls . scenarioConfigFocusRing .= refocusRing (makeFocusRingWith $ modifyRingMembers listEnums) + +-- | 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 = do + uiState . uiLaunchConfig . 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)) -> do + Brick.zoom (uiState . uiLaunchConfig . controls . seedValueEditor) (handleEditorEvent ev) + cacheValidatedInputs + _ -> 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 -> do + maybeSingleFile <- use $ uiState . uiLaunchConfig . controls . fileBrowser . maybeSelectedFile + configuredFB <- initFileBrowserWidget maybeSingleFile + uiState . uiLaunchConfig . controls . fileBrowser . fbWidget .= configuredFB + uiState . uiLaunchConfig . controls . fileBrowser . fbIsDisplayed .= True + StartGameButton -> do + params <- use $ uiState . uiLaunchConfig . editingParams + let eitherLaunchParams = toValidatedParms params + forM_ eitherLaunchParams $ \launchParams -> do + closeModal + startGameWithSeed siPair launchParams + + closeModal = uiState . uiLaunchConfig . controls . isDisplayedFor .= Nothing diff --git a/src/Swarm/TUI/Launch/Model.hs b/src/Swarm/TUI/Launch/Model.hs new file mode 100644 index 000000000..f9922cc0a --- /dev/null +++ b/src/Swarm/TUI/Launch/Model.hs @@ -0,0 +1,72 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TemplateHaskell #-} + +-- | +-- SPDX-License-Identifier: BSD-3-Clause +-- +-- Types for representing state of the launch dialog, +-- along with conversion functions for validated launch parameters. +module Swarm.TUI.Launch.Model where + +import Brick.Focus qualified as Focus +import Brick.Widgets.Edit +import Brick.Widgets.FileBrowser qualified as FB +import Control.Lens (makeLenses) +import Data.Functor.Identity (Identity (Identity)) +import Data.Text (Text) +import Swarm.Game.Scenario.Status (ParameterizableLaunchParams (LaunchParms), ScenarioInfoPair, SerializableLaunchParms) +import Swarm.Game.State (CodeToRun, getRunCodePath, parseCodeFile) +import Swarm.TUI.Model.Name + +type LaunchParms a = ParameterizableLaunchParams CodeToRun a + +-- | Use this to store error messages +-- on individual fields +type EditingLaunchParms = LaunchParms (Either Text) + +-- | In this stage in the UI pipeline, both fields +-- have already been validated, and "Nothing" means +-- that the field is simply absent. +type ValidatedLaunchParms = LaunchParms Identity + +toSerializableParams :: ValidatedLaunchParms -> SerializableLaunchParms +toSerializableParams (LaunchParms seedValue (Identity codeToRun)) = + LaunchParms seedValue $ pure $ getRunCodePath =<< codeToRun + +parseCode :: Maybe FilePath -> IO (Either Text (Maybe CodeToRun)) +parseCode maybeSelectedFile = case maybeSelectedFile of + Just codeFile -> do + eitherParsedCode <- parseCodeFile codeFile + return $ Just <$> eitherParsedCode + Nothing -> return $ Right Nothing + +fromSerializableParams :: SerializableLaunchParms -> IO EditingLaunchParms +fromSerializableParams (LaunchParms (Identity maybeSeedValue) (Identity maybeCodePath)) = do + eitherCode <- parseCode maybeCodePath + return $ LaunchParms (Right maybeSeedValue) eitherCode + +data FileBrowserControl = FileBrowserControl + { _fbWidget :: FB.FileBrowser Name + , _maybeSelectedFile :: Maybe FilePath + , _fbIsDisplayed :: Bool + } + +makeLenses ''FileBrowserControl + +-- | UI elements to configure scenario launch options +data LaunchControls = LaunchControls + { _fileBrowser :: FileBrowserControl + , _seedValueEditor :: Editor Text Name + , _scenarioConfigFocusRing :: Focus.FocusRing Name + , _isDisplayedFor :: Maybe ScenarioInfoPair + } + +makeLenses ''LaunchControls + +-- | UI elements to configure scenario launch options +data LaunchOptions = LaunchOptions + { _controls :: LaunchControls + , _editingParams :: EditingLaunchParms + } + +makeLenses ''LaunchOptions diff --git a/src/Swarm/TUI/Launch/Prep.hs b/src/Swarm/TUI/Launch/Prep.hs new file mode 100644 index 000000000..c4c6e7eaf --- /dev/null +++ b/src/Swarm/TUI/Launch/Prep.hs @@ -0,0 +1,121 @@ +{-# LANGUAGE OverloadedStrings #-} + +{- HLINT ignore "Use <$>" -} + +-- | +-- SPDX-License-Identifier: BSD-3-Clause +-- +-- Prepares and validates scenario launch parameters +module Swarm.TUI.Launch.Prep where + +import Brick (EventM) +import Brick.Focus qualified as Focus +import Brick.Widgets.Edit +import Brick.Widgets.FileBrowser qualified as FB +import Control.Arrow (left) +import Control.Lens ((.=), (^.)) +import Control.Monad.IO.Class (MonadIO, liftIO) +import Data.Functor.Identity (runIdentity) +import Data.Text qualified as T +import Swarm.Game.Scenario.Status (ParameterizableLaunchParams (..), ScenarioInfoPair, getLaunchParams, scenarioStatus) +import Swarm.Game.State (getRunCodePath) +import Swarm.TUI.Launch.Model +import Swarm.TUI.Model.Name +import Swarm.Util (listEnums) +import System.FilePath (takeDirectory) +import Text.Read (readEither) + +swarmLangFileExtension :: String +swarmLangFileExtension = "sw" + +toValidatedParms :: EditingLaunchParms -> Either T.Text ValidatedLaunchParms +toValidatedParms (LaunchParms eitherSeedVal eitherInitialCode) = do + maybeSeed <- eitherSeedVal + maybeParsedCode <- eitherInitialCode + return $ LaunchParms (pure maybeSeed) (pure maybeParsedCode) + +parseWidgetParms :: LaunchControls -> IO EditingLaunchParms +parseWidgetParms (LaunchControls (FileBrowserControl _fb maybeSelectedScript _) seedEditor _ _) = do + eitherParsedCode <- parseCode maybeSelectedScript + return $ LaunchParms eitherMaybeSeed eitherParsedCode + where + eitherMaybeSeed = + if T.null seedFieldText + then Right Nothing + else + fmap Just + . left T.pack + . readEither + . T.unpack + $ seedFieldText + + seedFieldText = mconcat $ getEditContents seedEditor + +makeFocusRingWith :: [ScenarioConfigPanelFocusable] -> Focus.FocusRing Name +makeFocusRingWith = Focus.focusRing . map (ScenarioConfigControl . ScenarioConfigPanelControl) + +initEditorWidget :: T.Text -> Editor T.Text Name +initEditorWidget = + editorText + (ScenarioConfigControl $ ScenarioConfigPanelControl SeedSelector) + (Just 1) -- only allow a single line + +-- | Called before any particular scenario is selected, so we +-- supply some "Nothing"s as defaults to the "ValidatedLaunchParms". +initConfigPanel :: IO LaunchOptions +initConfigPanel = do + -- NOTE: This is kind of pointless, because we must re-instantiate the FileBrowser + -- when it is first displayed, anyway. + fb <- + FB.newFileBrowser + FB.selectNonDirectories + (ScenarioConfigControl $ ScenarioConfigPanelControl ScriptSelector) + Nothing -- Initial working directory to display + return $ + LaunchOptions + (LaunchControls (FileBrowserControl fb Nothing False) myForm ring Nothing) + (LaunchParms (Right Nothing) (Right Nothing)) + where + myForm = initEditorWidget "" + ring = makeFocusRingWith listEnums + +initFileBrowserWidget :: + MonadIO m => + Maybe FilePath -> + m (FB.FileBrowser Name) +initFileBrowserWidget maybePlayedScript = do + fb <- + liftIO $ + FB.newFileBrowser + FB.selectNonDirectories + (ScenarioConfigControl $ ScenarioConfigPanelControl ScriptSelector) + (takeDirectory <$> maybePlayedScript) -- Initial working directory to display + return $ FB.setFileBrowserEntryFilter (Just $ FB.fileExtensionMatch swarmLangFileExtension) fb + +-- | If the selected scenario has been launched with an initial script before, +-- set the file browser to initially open that script's directory. +-- Then set the launch dialog to be displayed. +-- +-- Note that the FileBrowser widget normally allows multiple selections ("marked" files). +-- However, there do not exist any public "setters" set the marked files, so we have +-- some workarounds: +-- * When the user marks the first file, we immediately close the FileBrowser widget. +-- * We re-instantiate the FileBrowser from scratch every time it is opened, so that +-- it is not possible to mark more than one file. +-- * The "marked file" is persisted outside of the FileBrowser state, and the +-- "initial directory" is set upon instantiation from that external state. +prepareLaunchDialog :: + ScenarioInfoPair -> + EventM Name LaunchOptions () +prepareLaunchDialog siPair@(_, si) = do + let serializableLaunchParams = getLaunchParams $ si ^. scenarioStatus + launchEditingParams <- liftIO $ fromSerializableParams serializableLaunchParams + editingParams .= launchEditingParams + + let maybePlayedScript = case initialCode launchEditingParams of + Right codeToRun -> getRunCodePath =<< codeToRun + Left _ -> Nothing + + controls . fileBrowser . maybeSelectedFile .= maybePlayedScript + controls . seedValueEditor .= initEditorWidget (maybe "" (T.pack . show) $ runIdentity $ seedVal serializableLaunchParams) + controls . isDisplayedFor .= Just siPair diff --git a/src/Swarm/TUI/Launch/View.hs b/src/Swarm/TUI/Launch/View.hs new file mode 100644 index 000000000..fd3263ca3 --- /dev/null +++ b/src/Swarm/TUI/Launch/View.hs @@ -0,0 +1,185 @@ +{-# LANGUAGE OverloadedStrings #-} + +-- | +-- SPDX-License-Identifier: BSD-3-Clause +-- +-- Rendering of the scenario launch configuration dialog. +module Swarm.TUI.Launch.View where + +import Brick +import Brick.Focus +import Brick.Forms qualified as BF +import Brick.Widgets.Border +import Brick.Widgets.Center (centerLayer, hCenter) +import Brick.Widgets.Edit +import Brick.Widgets.Edit qualified as E +import Brick.Widgets.FileBrowser qualified as FB +import Control.Exception qualified as E +import Control.Lens +import Data.Either (isRight) +import Data.Text (Text) +import Data.Text qualified as T +import Swarm.Game.Scenario (scenarioSeed) +import Swarm.Game.Scenario.Status (ParameterizableLaunchParams (..)) +import Swarm.Game.State (getRunCodePath) +import Swarm.TUI.Attr +import Swarm.TUI.Launch.Model +import Swarm.TUI.Launch.Prep +import Swarm.TUI.Model.Name +import Swarm.TUI.View.Util (EllipsisSide (Beginning), withEllipsis) +import Swarm.Util (brackets, parens) + +drawFileBrowser :: FB.FileBrowser Name -> Widget Name +drawFileBrowser b = + centerLayer $ hLimit 50 $ ui <=> help + where + ui = + vLimit 15 $ + borderWithLabel (txt "Choose a file") $ + FB.renderFileBrowser True b + + footerRows = + map + (withDefAttr dimAttr . hCenter . txt) + [ "Up/Down: navigate" + , "/: search, Ctrl-C or Esc: cancel search" + , "Enter: change directory or select file" + , "Esc: quit" + ] + + help = + padTop (Pad 1) $ + vBox $ + [ case FB.fileBrowserException b of + Nothing -> emptyWidget + Just e -> + hCenter + . withDefAttr BF.invalidFormInputAttr + . txt + . T.pack + $ E.displayException e + ] + <> footerRows + +optionDescription :: ScenarioConfigPanelFocusable -> Maybe Text +optionDescription = \case + SeedSelector -> Just "Leaving this field blank will use the default seed for the scenario." + ScriptSelector -> Just "Selecting a script to be run upon start permits eligibility for code size scoring." + StartGameButton -> Nothing + +drawLaunchConfigPanel :: LaunchOptions -> [Widget Name] +drawLaunchConfigPanel (LaunchOptions lc launchParams) = + addFileBrowser [panelWidget] + where + validatedOptions = toValidatedParms launchParams + LaunchControls (FileBrowserControl fb _ isFbDisplayed) seedEditor ring displayedFor = lc + addFileBrowser = + if isFbDisplayed + then (drawFileBrowser fb :) + else id + + getFocusedConfigPanel :: Maybe ScenarioConfigPanelFocusable + getFocusedConfigPanel = case focusGetCurrent ring of + Just (ScenarioConfigControl (ScenarioConfigPanelControl x)) -> Just x + _ -> Nothing + + isFocused = (== getFocusedConfigPanel) . Just + + highlightIfFocused x = + if isFocused x + then withDefAttr highlightAttr + else id + + mkButton name label = + clickable (ScenarioConfigControl $ ScenarioConfigPanelControl name) + . highlightIfFocused name + . withAttr boldAttr + $ txt label + + mkSeedEditorWidget = + hLimit 10 $ + overrideAttr E.editFocusedAttr customEditFocusedAttr $ + renderEditor (txt . mconcat) (isFocused SeedSelector) seedEditor + seedEntryWidget = case seedVal launchParams of + Left _ -> mkSeedEditorWidget + Right x -> mkSeedEntryWidget x + + scenarioSeedText = maybe "random" show $ view scenarioSeed . fst =<< displayedFor + mkSeedEntryWidget seedEntryContent = + if isFocused SeedSelector + then mkSeedEditorWidget + else case seedEntryContent of + Just x -> str $ show x + Nothing -> + withDefAttr dimAttr $ + txt $ + T.unwords + [ "scenario default" + , parens $ T.pack scenarioSeedText + ] + + unspecifiedFileMessage = + if isFocused ScriptSelector + then str "<[Enter] to select>" + else withDefAttr dimAttr $ str "" + + fileEntryWidget = case initialCode launchParams of + Left _ -> str "" + Right maybeFilepath -> + maybe + unspecifiedFileMessage + (withEllipsis Beginning . T.pack) + (getRunCodePath =<< maybeFilepath) + + panelWidget = + centerLayer + . borderWithLabel (str " Configure scenario launch ") + . hLimit 60 + . padAll 1 + $ vBox widgetMembers + where + startButton = + hCenter . mkButton StartGameButton $ + T.unwords + [ ">>" + , "Launch with these settings" + , "<<" + ] + + widgetMembers = + [ controlsBox + , infoBox + , if isRight validatedOptions then startButton else emptyWidget + ] + + formatInfo header content = + hBox + [ padLeft (Pad 6) . withAttr boldAttr . txt $ brackets header + , padLeft (Pad 1) $ txtWrap content + ] + + infoContent = case validatedOptions of + Left errmsg -> withDefAttr BF.invalidFormInputAttr $ formatInfo "Error" errmsg + Right _ -> case optionDescription =<< getFocusedConfigPanel of + Just desc -> withDefAttr dimAttr $ formatInfo "Info" desc + Nothing -> str " " + + infoBox = + vLimit 4 + . padBottom Max + . padRight (Pad 2) + $ infoContent + + padControl widgetName label widgetObj = + padBottom (Pad 1) $ + padLeft (Pad 2) $ + hBox + [ mkButton widgetName (label <> ": ") + , widgetObj + ] + + controlsBox = + vBox + [ padControl ScriptSelector "Script" fileEntryWidget + , padControl SeedSelector "Seed" seedEntryWidget + ] diff --git a/src/Swarm/TUI/Model/Menu.hs b/src/Swarm/TUI/Model/Menu.hs index be9afccb8..7e4a09769 100644 --- a/src/Swarm/TUI/Model/Menu.hs +++ b/src/Swarm/TUI/Model/Menu.hs @@ -76,9 +76,10 @@ data MainMenuEntry deriving (Eq, Ord, Show, Read, Bounded, Enum) data Menu - = NoMenu -- We started playing directly from command line, no menu to show + = -- | We started playing directly from command line, no menu to show + NoMenu | MainMenu (BL.List Name MainMenuEntry) - | -- Stack of scenario item lists. INVARIANT: the currently selected + | -- | Stack of scenario item lists. INVARIANT: the currently selected -- menu item is ALWAYS the same as the scenario currently being played. -- See https://github.com/swarm-game/swarm/issues/1064 and -- https://github.com/swarm-game/swarm/pull/1065. @@ -104,7 +105,11 @@ mkScenarioList cheat = flip (BL.list ScenarioList) 1 . V.fromList . filterTest . mkNewGameMenu :: Bool -> ScenarioCollection -> FilePath -> Maybe Menu mkNewGameMenu cheat sc path = NewGameMenu . NE.fromList <$> go (Just sc) (splitPath path) [] where - go :: Maybe ScenarioCollection -> [FilePath] -> [BL.List Name ScenarioItem] -> Maybe [BL.List Name ScenarioItem] + go :: + Maybe ScenarioCollection -> + [FilePath] -> + [BL.List Name ScenarioItem] -> + Maybe [BL.List Name ScenarioItem] go _ [] stk = Just stk go Nothing _ _ = Nothing go (Just curSC) (thing : rest) stk = go nextSC rest (lst : stk) diff --git a/src/Swarm/TUI/Model/Name.hs b/src/Swarm/TUI/Model/Name.hs index 688a16e48..e3793cac6 100644 --- a/src/Swarm/TUI/Model/Name.hs +++ b/src/Swarm/TUI/Model/Name.hs @@ -13,6 +13,18 @@ data FocusablePanel InfoPanel deriving (Eq, Ord, Show, Read, Bounded, Enum) +data ScenarioConfigPanel + = ScenarioConfigFileSelector + | ScenarioConfigPanelControl ScenarioConfigPanelFocusable + deriving (Eq, Ord, Show, Read) + +data ScenarioConfigPanelFocusable + = -- | The file selector for launching a scenario with a script + ScriptSelector + | SeedSelector + | StartGameButton + deriving (Eq, Ord, Show, Read, Bounded, Enum) + data GoalWidget = ObjectivesList | GoalSummary @@ -46,6 +58,8 @@ data Name MenuList | -- | The list of achievements. AchievementList + | -- | An individual control within the scenario launch config panel + ScenarioConfigControl ScenarioConfigPanel | -- | The list of goals/objectives. GoalWidgets GoalWidget | -- | The list of scenario choices. diff --git a/src/Swarm/TUI/Model/StateUpdate.hs b/src/Swarm/TUI/Model/StateUpdate.hs index 3e95494e4..38273e3e9 100644 --- a/src/Swarm/TUI/Model/StateUpdate.hs +++ b/src/Swarm/TUI/Model/StateUpdate.hs @@ -8,6 +8,7 @@ module Swarm.TUI.Model.StateUpdate ( initAppStateForScenario, classicGame0, startGame, + startGameWithSeed, restartGame, attainAchievement, attainAchievement', @@ -44,6 +45,7 @@ import Swarm.Game.ScenarioInfo ( import Swarm.Game.State import Swarm.TUI.Attr (swarmAttrMap) import Swarm.TUI.Inventory.Sorting +import Swarm.TUI.Launch.Model (ValidatedLaunchParms, toSerializableParams) import Swarm.TUI.Model import Swarm.TUI.Model.Goal (emptyGoalDisplay) import Swarm.TUI.Model.Repl @@ -79,12 +81,12 @@ initAppState AppOpts {..} = do Right x -> (x, rs) Left e -> (ScenarioInfo path NotStarted, addWarnings rs e) execStateT - (startGameWithSeed userSeed (scenario, si) codeToRun) + (startGameWithSeed (scenario, si) $ LaunchParms (pure userSeed) (pure codeToRun)) (AppState gs ui newRs) -- | Load a 'Scenario' and start playing the game. startGame :: (MonadIO m, MonadState AppState m) => ScenarioInfoPair -> Maybe CodeToRun -> m () -startGame = startGameWithSeed Nothing +startGame siPair = startGameWithSeed siPair . LaunchParms (pure Nothing) . pure -- | Re-initialize the game from the stored reference to the current scenario. -- @@ -96,17 +98,16 @@ startGame = startGameWithSeed Nothing -- Since scenarios are stored as a Maybe in the UI state, we handle the Nothing -- case upstream so that the Scenario passed to this function definitely exists. restartGame :: (MonadIO m, MonadState AppState m) => Seed -> ScenarioInfoPair -> m () -restartGame currentSeed siPair = startGameWithSeed (Just currentSeed) siPair Nothing +restartGame currentSeed siPair = startGameWithSeed siPair $ LaunchParms (pure (Just currentSeed)) (pure Nothing) -- | Load a 'Scenario' and start playing the game, with the -- possibility for the user to override the seed. startGameWithSeed :: (MonadIO m, MonadState AppState m) => - Maybe Seed -> ScenarioInfoPair -> - Maybe CodeToRun -> + ValidatedLaunchParms -> m () -startGameWithSeed userSeed siPair@(_scene, si) toRun = do +startGameWithSeed siPair@(_scene, si) lp@(LaunchParms (Identity userSeed) (Identity toRun)) = do t <- liftIO getZonedTime ss <- use $ runtimeState . scenarios p <- liftIO $ normalizeScenarioPath ss (si ^. scenarioPath) @@ -116,7 +117,10 @@ startGameWithSeed userSeed siPair@(_scene, si) toRun = do . _SISingle . _2 . scenarioStatus - .= Played (Metric Attempted $ ProgressStats t emptyAttemptMetric) (prevBest t) + .= Played + (toSerializableParams lp) + (Metric Attempted $ ProgressStats t emptyAttemptMetric) + (prevBest t) scenarioToAppState siPair userSeed toRun -- Beware: currentScenarioPath must be set so that progress/achievements can be saved. -- It has just been cleared in scenarioToAppState. @@ -124,7 +128,7 @@ startGameWithSeed userSeed siPair@(_scene, si) toRun = do where prevBest t = case si ^. scenarioStatus of NotStarted -> emptyBest t - Played _ b -> b + Played _ _ b -> b -- | Modify the 'AppState' appropriately when starting a new scenario. scenarioToAppState :: diff --git a/src/Swarm/TUI/Model/UI.hs b/src/Swarm/TUI/Model/UI.hs index da51f9d40..0cf48f91a 100644 --- a/src/Swarm/TUI/Model/UI.hs +++ b/src/Swarm/TUI/Model/UI.hs @@ -13,6 +13,7 @@ module Swarm.TUI.Model.UI ( uiPlaying, uiCheatMode, uiFocusRing, + uiLaunchConfig, uiWorldCursor, uiREPL, uiInventory, @@ -74,6 +75,8 @@ import Swarm.Game.ScenarioInfo ( import Swarm.Game.World qualified as W import Swarm.TUI.Attr (swarmAttrMap) import Swarm.TUI.Inventory.Sorting +import Swarm.TUI.Launch.Model +import Swarm.TUI.Launch.Prep import Swarm.TUI.Model.Goal import Swarm.TUI.Model.Menu import Swarm.TUI.Model.Name @@ -93,6 +96,7 @@ data UIState = UIState , _uiPlaying :: Bool , _uiCheatMode :: Bool , _uiFocusRing :: FocusRing Name + , _uiLaunchConfig :: LaunchOptions , _uiWorldCursor :: Maybe W.Coords , _uiREPL :: REPLState , _uiInventory :: Maybe (Int, BL.List Name InventoryListEntry) @@ -141,6 +145,9 @@ uiPlaying :: Lens' UIState Bool -- | Cheat mode, i.e. are we allowed to turn creative mode on and off? uiCheatMode :: Lens' UIState Bool +-- | Configuration modal when launching a scenario +uiLaunchConfig :: Lens' UIState LaunchOptions + -- | The focus ring is the set of UI panels we can cycle among using -- the Tab key. uiFocusRing :: Lens' UIState (FocusRing Name) @@ -290,11 +297,13 @@ initUIState speedFactor showMainMenu cheatMode = do let history = maybe [] (map REPLEntry . T.lines) historyT startTime <- liftIO $ getTime Monotonic (warnings, achievements) <- liftIO loadAchievementsInfo + launchConfigPanel <- liftIO initConfigPanel let out = UIState { _uiMenu = if showMainMenu then MainMenu (mainMenu NewGame) else NoMenu , _uiPlaying = not showMainMenu , _uiCheatMode = cheatMode + , _uiLaunchConfig = launchConfigPanel , _uiFocusRing = initFocusRing , _uiWorldCursor = Nothing , _uiREPL = initREPLState $ newREPLHistory history diff --git a/src/Swarm/TUI/View.hs b/src/Swarm/TUI/View.hs index 7a272acac..8fb98d0b4 100644 --- a/src/Swarm/TUI/View.hs +++ b/src/Swarm/TUI/View.hs @@ -37,7 +37,12 @@ module Swarm.TUI.View ( import Brick hiding (Direction, Location) import Brick.Focus import Brick.Forms -import Brick.Widgets.Border (hBorder, hBorderWithLabel, joinableBorder, vBorder) +import Brick.Widgets.Border ( + hBorder, + hBorderWithLabel, + joinableBorder, + vBorder, + ) import Brick.Widgets.Center (center, centerLayer, hCenter) import Brick.Widgets.Dialog import Brick.Widgets.Edit (getEditContents, renderEditor) @@ -91,6 +96,8 @@ import Swarm.Language.Typecheck (inferConst) import Swarm.TUI.Attr import Swarm.TUI.Border import Swarm.TUI.Inventory.Sorting (renderSortMethod) +import Swarm.TUI.Launch.Model +import Swarm.TUI.Launch.View import Swarm.TUI.Model import Swarm.TUI.Model.Goal (goalsContent, hasAnythingToShow) import Swarm.TUI.Model.Repl (lastEntry) @@ -117,7 +124,7 @@ drawUI s -- quit the app instead. But just in case, we display the main menu anyway. NoMenu -> [drawMainMenuUI s (mainMenu NewGame)] MainMenu l -> [drawMainMenuUI s l] - NewGameMenu stk -> [drawNewGameMenuUI stk] + NewGameMenu stk -> drawNewGameMenuUI stk $ s ^. uiState . uiLaunchConfig AchievementsMenu l -> [drawAchievementsMenuUI s l] MessagesMenu -> [drawMainMessages s] AboutMenu -> [drawAboutMenuUI (s ^. uiState . appData . at "about")] @@ -165,35 +172,52 @@ drawLogo = centerLayer . vBox . map (hBox . T.foldr (\c ws -> drawThing c : ws) attrFor '▒' = dirtAttr attrFor _ = defAttr -drawNewGameMenuUI :: NonEmpty (BL.List Name ScenarioItem) -> Widget Name -drawNewGameMenuUI (l :| ls) = - padLeftRight 20 - . centerLayer - $ hBox - [ vBox - [ withAttr boldAttr . txt $ breadcrumbs ls - , txt " " - , vLimit 20 - . hLimit 35 - . BL.renderList (const $ padRight Max . drawScenarioItem) True - $ l - ] - , padLeft (Pad 5) (maybe (txt "") (drawDescription . snd) (BL.listSelectedElement l)) - ] +-- | When launching a game, a modal prompt may appear on another layer +-- to input seed and/or a script to run. +drawNewGameMenuUI :: + NonEmpty (BL.List Name ScenarioItem) -> + LaunchOptions -> + [Widget Name] +drawNewGameMenuUI (l :| ls) launchOptions = case displayedFor of + Nothing -> pure mainWidget + Just _ -> drawLaunchConfigPanel launchOptions <> pure mainWidget where + displayedFor = launchOptions ^. controls . isDisplayedFor + mainWidget = + vBox + [ padLeftRight 20 + . centerLayer + $ hBox + [ vBox + [ withAttr boldAttr . txt $ breadcrumbs ls + , txt " " + , vLimit 20 + . hLimit 35 + . BL.renderList (const $ padRight Max . drawScenarioItem) True + $ l + ] + , padLeft (Pad 5) (maybe (txt "") (drawDescription . snd) (BL.listSelectedElement l)) + ] + , launchOptionsMessage + ] + + launchOptionsMessage = case (displayedFor, snd <$> BL.listSelectedElement l) of + (Nothing, Just (SISingle _)) -> hCenter $ txt "Press 'o' for launch options" + _ -> emptyWidget + drawScenarioItem (SISingle (s, si)) = padRight (Pad 1) (drawStatusInfo s si) <+> txt (s ^. scenarioName) drawScenarioItem (SICollection nm _) = padRight (Pad 1) (withAttr boldAttr $ txt " > ") <+> txt nm drawStatusInfo s si = case si ^. scenarioStatus of NotStarted -> txt " ○ " - Played (Metric Attempted _) _ -> case s ^. scenarioObjectives of + Played _initialScript (Metric Attempted _) _ -> case s ^. scenarioObjectives of [] -> withAttr cyanAttr $ txt " ◉ " _ -> withAttr yellowAttr $ txt " ◎ " - Played (Metric Completed _) _ -> withAttr greenAttr $ txt " ● " + Played _initialScript (Metric Completed _) _ -> withAttr greenAttr $ txt " ● " describeStatus :: ScenarioStatus -> Widget n describeStatus = \case NotStarted -> withAttr cyanAttr $ txt "not started" - Played pm _best -> describeProgress pm + Played _initialScript pm _best -> describeProgress pm breadcrumbs :: [BL.List Name ScenarioItem] -> Text breadcrumbs = @@ -291,7 +315,7 @@ makeBestScoreRows scenarioStat = where getBests = case scenarioStat of NotStarted -> Nothing - Played _ best -> Just best + Played _initialScript _ best -> Just best makeBestRows b = map (makeBestRow hasMultiple) groups where diff --git a/src/Swarm/TUI/View/Objective.hs b/src/Swarm/TUI/View/Objective.hs index cfa93bebb..b5d578d51 100644 --- a/src/Swarm/TUI/View/Objective.hs +++ b/src/Swarm/TUI/View/Objective.hs @@ -88,7 +88,7 @@ drawGoalListItem _isSelected e = case e of Goal gs obj -> getCompletionIcon obj gs <+> titleWidget where textSource = obj ^. objectiveTeaser <|> obj ^. objectiveId <|> listToMaybe (obj ^. objectiveGoal) - titleWidget = maybe (txt "?") withEllipsis textSource + titleWidget = maybe (txt "?") (withEllipsis End) textSource singleGoalDetails :: GoalEntry -> Widget Name singleGoalDetails = \case diff --git a/src/Swarm/TUI/View/Util.hs b/src/Swarm/TUI/View/Util.hs index 05b0fe612..5cb706968 100644 --- a/src/Swarm/TUI/View/Util.hs +++ b/src/Swarm/TUI/View/Util.hs @@ -131,8 +131,10 @@ quitMsg m = "Are you sure you want to " <> quitAction <> "? All progress on this displayParagraphs :: [Text] -> Widget Name displayParagraphs = vBox . map (padBottom (Pad 1) . txtWrap) -withEllipsis :: Text -> Widget Name -withEllipsis t = +data EllipsisSide = Beginning | End + +withEllipsis :: EllipsisSide -> Text -> Widget Name +withEllipsis side t = Widget Greedy Fixed $ do ctx <- getContext let w = ctx ^. availWidthL @@ -140,7 +142,9 @@ withEllipsis t = tLength = T.length t newText = if tLength > w - then T.take (w - T.length ellipsis) t <> ellipsis + then case side of + Beginning -> ellipsis <> T.drop (w - T.length ellipsis) t + End -> T.take (w - T.length ellipsis) t <> ellipsis else t render $ txt newText diff --git a/swarm.cabal b/swarm.cabal index 4129ed5f0..98491a3c3 100644 --- a/swarm.cabal +++ b/swarm.cabal @@ -103,6 +103,10 @@ library Swarm.Game.Robot Swarm.Game.Scenario Swarm.Game.Scenario.Cell + Swarm.TUI.Launch.Controller + Swarm.TUI.Launch.Model + Swarm.TUI.Launch.Prep + Swarm.TUI.Launch.View Swarm.Game.Scenario.Objective Swarm.Game.Scenario.Objective.Graph Swarm.Game.Scenario.Objective.Logic