diff --git a/src/Swarm/TUI/Controller.hs b/src/Swarm/TUI/Controller.hs index eaa1e775c2..71a62a0250 100644 --- a/src/Swarm/TUI/Controller.hs +++ b/src/Swarm/TUI/Controller.hs @@ -1,5 +1,4 @@ {-# LANGUAGE OverloadedStrings #-} -{-# LANGUAGE PatternSynonyms #-} -- | -- Module : Swarm.TUI.Controller @@ -80,33 +79,18 @@ import Swarm.Language.Requirement qualified as R import Swarm.Language.Syntax import Swarm.Language.Typed (Typed (..)) import Swarm.Language.Types +import Swarm.TUI.Controller.ControllerUtils +import Swarm.TUI.Editor.EditorController qualified as EC +import Swarm.TUI.Editor.Util qualified as EU import Swarm.TUI.Inventory.Sorting (cycleSortDirection, cycleSortOrder) import Swarm.TUI.List import Swarm.TUI.Model -import Swarm.TUI.View (generateModal) +import Swarm.TUI.View.ViewUtils (generateModal) import Swarm.Util hiding ((<<.=)) import Swarm.Version (NewReleaseFailure (..)) import System.Clock import Witch (into) --- | Pattern synonyms to simplify brick event handler -pattern Key :: V.Key -> BrickEvent n e -pattern Key k = VtyEvent (V.EvKey k []) - -pattern CharKey, ControlChar, MetaChar :: Char -> BrickEvent n e -pattern CharKey c = VtyEvent (V.EvKey (V.KChar c) []) -pattern ControlChar c = VtyEvent (V.EvKey (V.KChar c) [V.MCtrl]) -pattern MetaChar c = VtyEvent (V.EvKey (V.KChar c) [V.MMeta]) - -pattern ShiftKey :: V.Key -> BrickEvent n e -pattern ShiftKey k = VtyEvent (V.EvKey k [V.MShift]) - -pattern EscapeKey :: BrickEvent n e -pattern EscapeKey = VtyEvent (V.EvKey V.KEsc []) - -pattern FKey :: Int -> BrickEvent n e -pattern FKey c = VtyEvent (V.EvKey (V.KFun c) []) - -- | The top-level event handler for the TUI. handleEvent :: BrickEvent Name AppEvent -> EventM Name AppState () handleEvent = \case @@ -288,20 +272,74 @@ handleMainEvent ev = do VtyEvent vev | isJust (s ^. uiState . uiModal) -> handleModalEvent vev -- toggle creative mode if in "cheat mode" + + MouseDown (TerrainListItem pos) V.BLeft _ _ -> + uiState . uiWorldEditor . terrainList %= BL.listMoveTo pos ControlChar 'v' | s ^. uiState . uiCheatMode -> gameState . creativeMode %= not + -- toggle world editor mode if in "cheat mode" + ControlChar 'e' + | s ^. uiState . uiCheatMode -> + uiState . uiWorldEditor . isWorldEditorEnabled %= not + MouseDown n V.BRight _ mouseLoc -> do + let worldEditor = s ^. uiState . uiWorldEditor + case (n, worldEditor ^. isWorldEditorEnabled) of + -- "Eye Dropper" tool: + (WorldPanel, True) -> do + mouseCoordsM <- Brick.zoom gameState $ mouseLocToWorldCoords mouseLoc + case mouseCoordsM of + Nothing -> return () + Just coords -> uiState . uiWorldEditor . terrainList %= BL.listMoveToElement newVal + where + newVal = EU.getTerrainAt worldEditor (s ^. gameState . world) coords + _ -> continueWithoutRedraw + MouseDown n V.BLeft [V.MCtrl] mouseLoc -> + case n of + WorldPanel -> do + worldEditor <- use $ uiState . uiWorldEditor + when (worldEditor ^. isWorldEditorEnabled) $ do + let maybeTerrainType = fmap snd $ BL.listSelectedElement $ worldEditor ^. terrainList + case maybeTerrainType of + -- TODO: Use a MaybeT monad transformer? + Nothing -> return () + Just terrain -> do + mouseCoordsM <- Brick.zoom gameState $ mouseLocToWorldCoords mouseLoc + case mouseCoordsM of + Nothing -> return () + Just mouseCoords -> do + uiState . uiWorldEditor . paintedTerrain %= M.insert mouseCoords terrain + -- TODO: Screen updates are laggy, and the needsRedraw flag doesn't seem to help + _ -> return () MouseDown n _ _ mouseLoc -> case n of WorldPanel -> do - mouseCoordsM <- Brick.zoom gameState (mouseLocToWorldCoords mouseLoc) - uiState . uiWorldCursor .= mouseCoordsM + mouseCoordsM <- Brick.zoom gameState $ mouseLocToWorldCoords mouseLoc + case mouseCoordsM of + Nothing -> uiState . uiWorldCursor .= mouseCoordsM + Just mouseCoords -> do + selectorStage <- use $ uiState . uiWorldEditor . boundsSelectionStep + -- We swap the horizontal and vertical coordinate, and invert the vertical cooridnate. + -- TODO What is mouseLocToWorldCoords?? + let toWorldCoords (W.Coords (mx, my)) = W.Coords (my, -mx) + case selectorStage of + UpperLeftPending -> uiState . uiWorldEditor . boundsSelectionStep .= LowerRightPending mouseCoords + -- TODO: Validate that the lower-right click is below and to the right of the top-left coord + LowerRightPending upperLeftMouseCoords -> do + uiState . uiWorldEditor . editingBounds + .= Just (toWorldCoords upperLeftMouseCoords, toWorldCoords mouseCoords) + uiState . uiWorldEditor . boundsSelectionStep .= SelectionComplete + setFocus WorldEditorPanel + SelectionComplete -> uiState . uiWorldCursor .= mouseCoordsM REPLInput -> do - setFocus REPLPanel + setFocus REPLPanel -- TODO: Is this redundant??? handleREPLEvent ev _ -> continueWithoutRedraw MouseUp n _ _mouseLoc -> do case n of InventoryListItem pos -> uiState . uiInventory . traverse . _2 %= BL.listMoveTo pos + x@(WorldEditorPanelControl y) -> do + uiState . uiWorldEditor . editorFocusRing %= focusSetCurrent x + EC.activateWorldEditorFunction y _ -> return () setFocus $ case n of -- Adapt click event origin to their right panel. @@ -311,6 +349,7 @@ handleMainEvent ev = do InventoryList -> RobotPanel InventoryListItem _ -> RobotPanel InfoViewport -> InfoPanel + WorldEditorPanelControl _ -> WorldEditorPanel _ -> n -- dispatch any other events to the focused panel handler _ev -> do @@ -318,6 +357,7 @@ handleMainEvent ev = do case focusGetCurrent fring of Just REPLPanel -> handleREPLEvent ev Just WorldPanel -> handleWorldEvent ev + Just WorldEditorPanel -> EC.handleWorldEditorPanelEvent ev Just RobotPanel -> handleRobotPanelEvent ev Just InfoPanel -> handleInfoPanelEvent infoScroll ev _ -> continueWithoutRedraw @@ -363,21 +403,8 @@ toggleModal :: ModalType -> EventM Name AppState () toggleModal mt = do modal <- use $ uiState . uiModal case modal of - Nothing -> do - newModal <- gets $ flip generateModal mt - ensurePause - uiState . uiModal ?= newModal + Nothing -> openModal mt Just _ -> uiState . uiModal .= Nothing >> safeAutoUnpause - where - -- Set the game to AutoPause if needed - ensurePause = do - pause <- use $ gameState . paused - unless (pause || isRunningModal mt) $ do - gameState . runStatus .= AutoPause - --- | The running modals do not autopause the game. -isRunningModal :: ModalType -> Bool -isRunningModal mt = mt `elem` [RobotsModal, MessagesModal] handleModalEvent :: V.Event -> EventM Name AppState () handleModalEvent = \case @@ -394,6 +421,10 @@ handleModalEvent = \case Brick.zoom (uiState . uiModal . _Just . modalDialog) (handleDialogEvent ev) modal <- preuse $ uiState . uiModal . _Just . modalType case modal of + Just TerrainPaletteModal -> do + listWidget <- use $ uiState . uiWorldEditor . terrainList + newList <- nestEventM' listWidget $ BL.handleListEvent ev + uiState . uiWorldEditor . terrainList .= newList Just _ -> handleInfoPanelEvent modalScroll (VtyEvent ev) _ -> return () diff --git a/src/Swarm/TUI/Controller/ControllerUtils.hs b/src/Swarm/TUI/Controller/ControllerUtils.hs new file mode 100644 index 0000000000..b730c7f2da --- /dev/null +++ b/src/Swarm/TUI/Controller/ControllerUtils.hs @@ -0,0 +1,45 @@ +{-# LANGUAGE PatternSynonyms #-} + +module Swarm.TUI.Controller.ControllerUtils where + +import Brick hiding (Direction) +import Control.Lens +import Control.Monad (unless) +import Graphics.Vty qualified as V +import Swarm.Game.State +import Swarm.TUI.Model +import Swarm.TUI.View.ViewUtils (generateModal) + +-- | Pattern synonyms to simplify brick event handler +pattern Key :: V.Key -> BrickEvent n e +pattern Key k = VtyEvent (V.EvKey k []) + +pattern CharKey, ControlChar, MetaChar :: Char -> BrickEvent n e +pattern CharKey c = VtyEvent (V.EvKey (V.KChar c) []) +pattern ControlChar c = VtyEvent (V.EvKey (V.KChar c) [V.MCtrl]) +pattern MetaChar c = VtyEvent (V.EvKey (V.KChar c) [V.MMeta]) + +pattern ShiftKey :: V.Key -> BrickEvent n e +pattern ShiftKey k = VtyEvent (V.EvKey k [V.MShift]) + +pattern EscapeKey :: BrickEvent n e +pattern EscapeKey = VtyEvent (V.EvKey V.KEsc []) + +pattern FKey :: Int -> BrickEvent n e +pattern FKey c = VtyEvent (V.EvKey (V.KFun c) []) + +openModal :: ModalType -> EventM Name AppState () +openModal mt = do + newModal <- gets $ flip generateModal mt + ensurePause + uiState . uiModal ?= newModal + where + -- Set the game to AutoPause if needed + ensurePause = do + pause <- use $ gameState . paused + unless (pause || isRunningModal mt) $ do + gameState . runStatus .= AutoPause + +-- | The running modals do not autopause the game. +isRunningModal :: ModalType -> Bool +isRunningModal mt = mt `elem` [RobotsModal, MessagesModal] diff --git a/src/Swarm/TUI/Editor/EditorController.hs b/src/Swarm/TUI/Editor/EditorController.hs new file mode 100644 index 0000000000..7980aadf02 --- /dev/null +++ b/src/Swarm/TUI/Editor/EditorController.hs @@ -0,0 +1,46 @@ +module Swarm.TUI.Editor.EditorController where + +import Brick hiding (Direction) +import Brick.Focus +import Control.Lens +import Control.Monad.IO.Class (liftIO) +import Graphics.Vty qualified as V +import Swarm.Game.State +import Swarm.TUI.Controller.ControllerUtils +import Swarm.TUI.Editor.Util qualified as EU +import Swarm.TUI.Model + +------------------------------------------------------------ +-- World Editor panel events +------------------------------------------------------------ + +activateWorldEditorFunction :: WorldEditorFocusable -> EventM Name AppState () +activateWorldEditorFunction BrushSelector = openModal TerrainPaletteModal +activateWorldEditorFunction EntitySelector = + liftIO $ putStrLn "TODO" +activateWorldEditorFunction AreaSelector = do + selectorStage <- use $ uiState . uiWorldEditor . boundsSelectionStep + case selectorStage of + SelectionComplete -> uiState . uiWorldEditor . boundsSelectionStep .= UpperLeftPending + _ -> return () +activateWorldEditorFunction OutputPathSelector = + liftIO $ putStrLn "File selection" + +-- | Handle user input events in the robot panel. +handleWorldEditorPanelEvent :: BrickEvent Name AppEvent -> EventM Name AppState () +handleWorldEditorPanelEvent = \case + Key V.KEsc -> uiState . uiWorldEditor . boundsSelectionStep .= SelectionComplete + Key V.KEnter -> do + fring <- use $ uiState . uiWorldEditor . editorFocusRing + case focusGetCurrent fring of + Just (WorldEditorPanelControl x) -> activateWorldEditorFunction x + _ -> return () + ControlChar 's' -> do + worldEditor <- use $ uiState . uiWorldEditor + let fp = worldEditor ^. outputFilePath + maybeBounds <- use $ uiState . uiWorldEditor . editingBounds + w <- use $ gameState . world + liftIO $ writeFile fp $ EU.getEditedMapAsString worldEditor maybeBounds w + CharKey '\t' -> uiState . uiWorldEditor . editorFocusRing %= focusNext + Key V.KBackTab -> uiState . uiWorldEditor . editorFocusRing %= focusPrev + _ -> return () diff --git a/src/Swarm/TUI/Editor/EditorView.hs b/src/Swarm/TUI/Editor/EditorView.hs new file mode 100644 index 0000000000..f300c17d08 --- /dev/null +++ b/src/Swarm/TUI/Editor/EditorView.hs @@ -0,0 +1,95 @@ +module Swarm.TUI.Editor.EditorView where + +import Brick hiding (Direction) +import Brick.Focus +import Brick.Widgets.List qualified as BL +import Control.Lens hiding (Const, from) +import Data.List qualified as L +import Swarm.Game.World qualified as W +import Swarm.TUI.Attr +import Swarm.TUI.Border +import Swarm.TUI.Model +import Swarm.TUI.Panel + +import Swarm.TUI.View.ViewUtils + +drawWorldEditor :: FocusRing Name -> UIState -> Widget Name +drawWorldEditor toplevelFocusRing uis = + if worldEditor ^. isWorldEditorEnabled + then + panel + highlightAttr + toplevelFocusRing + WorldEditorPanel + ( plainBorder + -- TODO FIXME + & topLabels . rightLabel .~ (drawType <$> (uis ^. uiREPL . replType)) + ) + innerWidget + else emptyWidget + where + privateFocusRing = worldEditor ^. editorFocusRing + maybeCurrentFocus = focusGetCurrent privateFocusRing + + innerWidget = + padLeftRight 1 $ + hLimit 30 $ + vBox + [ brushWidget + , -- , entityWidget + areaWidget + , outputWidget + ] + + worldEditor = uis ^. uiWorldEditor + maybeSelectedTerrain = fmap snd $ BL.listSelectedElement $ worldEditor ^. terrainList + maybeAreaBounds = worldEditor ^. editingBounds + + -- TODO: Use withFocusRing + mkFormControl n w = + clickable n $ transformation w + where + transformation = + if Just n == maybeCurrentFocus + then withAttr BL.listSelectedFocusedAttr + else id + + brushWidget = + mkFormControl (WorldEditorPanelControl BrushSelector) $ + padRight (Pad 1) (str "Brush:") <+> brushWidgetContent + + brushWidgetContent = + maybe emptyWidget drawLabeledTerrainSwatch maybeSelectedTerrain + + -- entityWidget = + -- mkFormControl (WorldEditorPanelControl EntitySelector) $ + -- padRight (Pad 1) (str "Entity:") <+> entityWidgetContent + + -- entityWidgetContent = + -- maybe emptyWidget drawLabeledTerrainSwatch maybeSelectedTerrain + + areaContent = case worldEditor ^. boundsSelectionStep of + UpperLeftPending -> str "Click top-left" + LowerRightPending _wcoords -> str "Click bottom-right" + SelectionComplete -> maybe emptyWidget renderBounds maybeAreaBounds + + areaWidget = + mkFormControl (WorldEditorPanelControl AreaSelector) $ + vBox + [ str "Area:" + , areaContent + ] + + renderBounds (W.Coords primaryCorner@(x1, y1), W.Coords (x2, y2)) = + str $ L.intercalate " @ " [rectSize, show primaryCorner] + where + width = x2 - x1 + -- NOTE: The height coordinate is inverted so we do opposite subtraction order here: + height = y1 - y2 + rectSize = L.intercalate "x" [show width, show height] + + outputWidget = + mkFormControl (WorldEditorPanelControl OutputPathSelector) $ + padRight (Pad 1) (str "Output:") <+> outputWidgetContent + + outputWidgetContent = str $ worldEditor ^. outputFilePath diff --git a/src/Swarm/TUI/Editor/Util.hs b/src/Swarm/TUI/Editor/Util.hs new file mode 100644 index 0000000000..add6e651de --- /dev/null +++ b/src/Swarm/TUI/Editor/Util.hs @@ -0,0 +1,29 @@ +module Swarm.TUI.Editor.Util where + +import Control.Lens hiding (Const, from) +import Data.Char qualified as DC +import Data.Int (Int64) +import Data.Map qualified as Map +import Data.Maybe qualified as Maybe +import Swarm.Game.Entity (Entity) +import Swarm.Game.Terrain (TerrainType) +import Swarm.Game.World qualified as W +import Swarm.TUI.Model + +getTerrainAt :: WorldEditor -> W.World Int Entity -> W.Coords -> TerrainType +getTerrainAt editor w coords = case editor ^. isWorldEditorEnabled of + True -> Maybe.fromMaybe underlyingCell $ Map.lookup coords paintMap + False -> underlyingCell + where + paintMap = editor ^. paintedTerrain + underlyingCell = toEnum $ W.lookupTerrain coords w + +getEditedMapAsString :: WorldEditor -> Maybe (W.Coords, W.Coords) -> W.World Int Entity -> String +getEditedMapAsString _ Nothing _ = "EMPTY BOUNDS" +getEditedMapAsString worldEditor (Just (W.Coords (xLeft, yTop), W.Coords (xRight, yBottom))) w = + unlines $ map renderLine [yTop .. yBottom] + where + getTerrain = getTerrainAt worldEditor w + drawCell :: Int64 -> Int64 -> Char + drawCell rowIndex = DC.chr . (+ DC.ord '0') . fromEnum . getTerrain . W.Coords . (rowIndex,) + renderLine rowIndex = map (drawCell rowIndex) [xLeft .. xRight] diff --git a/src/Swarm/TUI/Model.hs b/src/Swarm/TUI/Model.hs index 63f548b01e..de91e7bbb0 100644 --- a/src/Swarm/TUI/Model.hs +++ b/src/Swarm/TUI/Model.hs @@ -60,6 +60,16 @@ module Swarm.TUI.Model ( _Separator, _InventoryEntry, _InstalledEntry, + WorldEditorFocusable (..), + BoundsSelectionStep (..), + WorldEditor (..), + isWorldEditorEnabled, + terrainList, + paintedTerrain, + editingBounds, + boundsSelectionStep, + editorFocusRing, + outputFilePath, -- ** UI Model UIState, @@ -68,6 +78,7 @@ module Swarm.TUI.Model ( uiCheatMode, uiFocusRing, uiWorldCursor, + uiWorldEditor, uiREPL, uiInventory, uiInventorySort, @@ -199,6 +210,7 @@ import Swarm.Game.ScenarioInfo ( _SISingle, ) import Swarm.Game.State +import Swarm.Game.Terrain (TerrainType) import Swarm.Game.World qualified as W import Swarm.Language.Types import Swarm.TUI.Inventory.Sorting @@ -224,6 +236,13 @@ data AppEvent | UpstreamVersion (Either NewReleaseFailure String) deriving (Show) +data WorldEditorFocusable + = BrushSelector + | EntitySelector + | AreaSelector + | OutputPathSelector + deriving (Eq, Ord, Show, Read, Bounded, Enum) + -- | 'Name' represents names to uniquely identify various components -- of the UI, such as forms, panels, caches, extents, and lists. data Name @@ -231,6 +250,10 @@ data Name REPLPanel | -- | The panel containing the world view. WorldPanel + | -- | The panel containing the world editor controls. + WorldEditorPanel + | -- | An individual control within the world editor panel. + WorldEditorPanelControl WorldEditorFocusable | -- | The panel showing robot info and inventory on the top left. RobotPanel | -- | The info panel on the bottom left. @@ -241,6 +264,10 @@ data Name WorldCache | -- | The cached extent for the world view. WorldExtent + | -- | The list of possible terrain materials. + TerrainList + | -- | The terrain item position in the TerrainList. + TerrainListItem Int | -- | The list of inventory items for the currently -- focused robot. InventoryList @@ -483,6 +510,7 @@ data ModalType | KeepPlayingModal | DescriptionModal Entity | GoalModal [Text] + | TerrainPaletteModal deriving (Eq, Show) data ButtonSelection = CancelButton | KeepPlayingButton | StartOverButton Seed ScenarioInfoPair | QuitButton | NextButton ScenarioInfoPair @@ -557,6 +585,37 @@ makePrisms ''InventoryListEntry -- UI state ------------------------------------------------------------ +data BoundsSelectionStep + = UpperLeftPending + | -- | Stores the *mouse coords* of the upper-left click + LowerRightPending W.Coords + | SelectionComplete + +data WorldEditor = WorldEditor + { _isWorldEditorEnabled :: Bool + , _terrainList :: BL.List Name TerrainType + , _paintedTerrain :: M.Map W.Coords TerrainType + , -- | Upper-left and lower-right coordinates + -- of the map to be saved. + _editingBounds :: Maybe (W.Coords, W.Coords) + , _boundsSelectionStep :: BoundsSelectionStep + , _editorFocusRing :: FocusRing Name + , _outputFilePath :: FilePath + } + +makeLenses ''WorldEditor + +initialWorldEditor :: WorldEditor +initialWorldEditor = + WorldEditor + False + (BL.list TerrainList (V.fromList listEnums) 1) + mempty + (Just (W.Coords (-10, -10), W.Coords (10, 10))) + SelectionComplete + (focusRing $ map WorldEditorPanelControl listEnums) + "output.txt" + -- | The main record holding the UI state. For access to the fields, -- see the lenses below. data UIState = UIState @@ -565,6 +624,7 @@ data UIState = UIState , _uiCheatMode :: Bool , _uiFocusRing :: FocusRing Name , _uiWorldCursor :: Maybe W.Coords + , _uiWorldEditor :: WorldEditor , _uiREPL :: REPLState , _uiInventory :: Maybe (Int, BL.List Name InventoryListEntry) , _uiInventorySort :: InventorySortOptions @@ -621,6 +681,9 @@ uiFocusRing :: Lens' UIState (FocusRing Name) -- | The last clicked position on the world view. uiWorldCursor :: Lens' UIState (Maybe W.Coords) +-- | World editor mode +uiWorldEditor :: Lens' UIState WorldEditor + -- | The state of REPL panel. uiREPL :: Lens' UIState REPLState @@ -819,8 +882,18 @@ focusedEntity = -- UIState initialization -- | The initial state of the focus ring. +-- NOTE: Normally, the Tab key might cycle through the members of the +-- focus ring. However, the REPL already uses Tab. So, to is not used +-- at all right now for navigating the toplevel focus ring. initFocusRing :: FocusRing Name -initFocusRing = focusRing [REPLPanel, InfoPanel, RobotPanel, WorldPanel] +initFocusRing = + focusRing + [ REPLPanel + , InfoPanel + , RobotPanel + , WorldPanel + , WorldEditorPanel + ] -- | The initial tick speed. initLgTicksPerSecond :: Int @@ -844,6 +917,7 @@ initUIState showMainMenu cheatMode = liftIO $ do , _uiCheatMode = cheatMode , _uiFocusRing = initFocusRing , _uiWorldCursor = Nothing + , _uiWorldEditor = initialWorldEditor , _uiREPL = initREPLState $ newREPLHistory history , _uiInventory = Nothing , _uiInventorySort = defaultSortOptions diff --git a/src/Swarm/TUI/View.hs b/src/Swarm/TUI/View.hs index 690226c300..3138be6a16 100644 --- a/src/Swarm/TUI/View.hs +++ b/src/Swarm/TUI/View.hs @@ -14,7 +14,6 @@ module Swarm.TUI.View ( -- * Dialog box drawDialog, - generateModal, chooseCursor, -- * Key hint menu @@ -86,17 +85,19 @@ import Swarm.Game.ScenarioInfo ( scenarioStatus, ) import Swarm.Game.State -import Swarm.Game.Terrain (terrainMap) +import Swarm.Game.Terrain (TerrainType, terrainMap) import Swarm.Game.World qualified as W import Swarm.Language.Pretty (prettyText) import Swarm.Language.Syntax import Swarm.Language.Typecheck (inferConst) -import Swarm.Language.Types (Polytype) import Swarm.TUI.Attr import Swarm.TUI.Border +import Swarm.TUI.Editor.EditorView qualified as EV +import Swarm.TUI.Editor.Util qualified as EU import Swarm.TUI.Inventory.Sorting (renderSortMethod) import Swarm.TUI.Model import Swarm.TUI.Panel +import Swarm.TUI.View.ViewUtils import Swarm.Util import Swarm.Version (NewReleaseFailure (..)) import System.Clock (TimeSpec (..)) @@ -295,21 +296,29 @@ drawGameUI s = & addCursorPos & addClock ) - (drawWorld (s ^. uiState . uiShowRobots) (s ^. gameState)) + ( drawWorld + (s ^. uiState . uiShowRobots) + (s ^. uiState . uiWorldEditor) + (s ^. gameState) + ) , drawKeyMenu s - , clickable REPLPanel $ - panel - highlightAttr - fr - REPLPanel - ( plainBorder - & topLabels . rightLabel .~ (drawType <$> (s ^. uiState . uiREPL . replType)) - ) - ( vLimit replHeight - . padBottom Max - . padLeftRight 1 - $ drawREPL s - ) + , hBox + [ clickable REPLPanel $ + panel + highlightAttr + fr + REPLPanel + ( plainBorder + & topLabels . rightLabel .~ (drawType <$> (s ^. uiState . uiREPL . replType)) + ) + ( vLimit replHeight + . padBottom Max + . padLeftRight 1 + $ drawREPL s + ) + , clickable WorldEditorPanel $ + EV.drawWorldEditor fr $ s ^. uiState + ] ] ] ] @@ -317,7 +326,7 @@ drawGameUI s = addCursorPos = case s ^. uiState . uiWorldCursor of Nothing -> id Just coord -> - let worldCursorInfo = drawWorldCursorInfo (s ^. gameState) coord + let worldCursorInfo = drawWorldCursorInfo (s ^. uiState . uiWorldEditor) (s ^. gameState) coord in bottomLabels . leftLabel ?~ padLeftRight 1 worldCursorInfo -- Add clock display in top right of the world view if focused robot -- has a clock installed @@ -326,8 +335,8 @@ drawGameUI s = moreTop = s ^. uiState . uiMoreInfoTop moreBot = s ^. uiState . uiMoreInfoBot -drawWorldCursorInfo :: GameState -> W.Coords -> Widget Name -drawWorldCursorInfo g coords@(W.Coords (y, x)) = +drawWorldCursorInfo :: WorldEditor -> GameState -> W.Coords -> Widget Name +drawWorldCursorInfo worldEditor g coords@(W.Coords (y, x)) = hBox $ tileMemberWidgets ++ [coordsWidget] where coordsWidget = @@ -346,7 +355,7 @@ drawWorldCursorInfo g coords@(W.Coords (y, x)) = where f cell preposition = [renderDisplay cell, txt preposition] - terrain = displayTerrainCell g coords + terrain = displayTerrainCell worldEditor g coords entity = displayEntityCell g coords robot = displayRobotCell g coords @@ -389,10 +398,6 @@ drawTime t showTicks = maybeDrawTime :: Integer -> Bool -> GameState -> Maybe (Widget n) maybeDrawTime t showTicks gs = guard (clockInstalled gs) $> drawTime t showTicks --- | Render the type of the current REPL input to be shown to the user. -drawType :: Polytype -> Widget Name -drawType = withAttr infoAttr . padLeftRight 1 . txt . prettyText - -- | Draw info about the current number of ticks per second. drawTPS :: AppState -> Widget Name drawTPS s = hBox (tpsInfo : rateInfo) @@ -425,10 +430,6 @@ chooseCursor s locs = case s ^. uiState . uiModal of Nothing -> showFirstCursor s locs Just _ -> Nothing --- | Width cap for modal and error message windows -maxModalWindowWidth :: Int -maxModalWindowWidth = 500 - -- | Render the error dialog window with a given error message renderErrorDialog :: Text -> Widget Name renderErrorDialog err = renderDialog (dialog (Just "Error") Nothing (maxModalWindowWidth `min` requiredWidth)) errContent @@ -471,77 +472,19 @@ drawModal s = \case QuitModal -> padBottom (Pad 1) $ hCenter $ txt (quitMsg (s ^. uiState . uiMenu)) GoalModal g -> padLeftRight 1 (displayParagraphs g) KeepPlayingModal -> padLeftRight 1 (displayParagraphs ["Have fun! Hit Ctrl-Q whenever you're ready to proceed to the next challenge or return to the menu."]) + TerrainPaletteModal -> drawTerrainSelector s -quitMsg :: Menu -> Text -quitMsg m = "Are you sure you want to " <> quitAction <> "? All progress on this scenario will be lost!" - where - quitAction = case m of - NoMenu -> "quit" - _ -> "return to the menu" +drawTerrainSelector :: AppState -> Widget Name +drawTerrainSelector s = + padAll 1 $ + hCenter $ + vLimit (length (listEnums :: [TerrainType])) $ + BL.renderListWithIndex listDrawTerrainElement True $ + s ^. uiState . uiWorldEditor . terrainList --- | Generate a fresh modal window of the requested type. -generateModal :: AppState -> ModalType -> Modal -generateModal s mt = Modal mt (dialog (Just title) buttons (maxModalWindowWidth `min` requiredWidth)) - where - currentScenario = s ^. uiState . scenarioRef - currentSeed = s ^. gameState . seed - haltingMessage = case s ^. uiState . uiMenu of - NoMenu -> Just "Quit" - _ -> Nothing - descriptionWidth = 100 - helpWidth = 80 - (title, buttons, requiredWidth) = - case mt of - HelpModal -> (" Help ", Nothing, helpWidth) - RobotsModal -> ("Robots", Nothing, descriptionWidth) - RecipesModal -> ("Available Recipes", Nothing, descriptionWidth) - CommandsModal -> ("Available Commands", Nothing, descriptionWidth) - MessagesModal -> ("Messages", Nothing, descriptionWidth) - WinModal -> - let nextMsg = "Next challenge!" - stopMsg = fromMaybe "Return to the menu" haltingMessage - continueMsg = "Keep playing" - in ( "" - , Just - ( 0 - , [ (nextMsg, NextButton scene) - | Just scene <- [nextScenario (s ^. uiState . uiMenu)] - ] - ++ [ (stopMsg, QuitButton) - , (continueMsg, KeepPlayingButton) - ] - ) - , sum (map length [nextMsg, stopMsg, continueMsg]) + 32 - ) - DescriptionModal e -> (descriptionTitle e, Nothing, descriptionWidth) - QuitModal -> - let stopMsg = fromMaybe ("Quit to" ++ maybe "" (" " ++) (into @String <$> curMenuName s) ++ " menu") haltingMessage - maybeStartOver = sequenceA ("Start over", StartOverButton currentSeed <$> currentScenario) - in ( "" - , Just - ( 0 - , catMaybes - [ Just ("Keep playing", CancelButton) - , maybeStartOver - , Just (stopMsg, QuitButton) - ] - ) - , T.length (quitMsg (s ^. uiState . uiMenu)) + 4 - ) - GoalModal _ -> - let goalModalTitle = case currentScenario of - Nothing -> "Goal" - Just (scenario, _) -> scenario ^. scenarioName - in (" " <> T.unpack goalModalTitle <> " ", Nothing, 80) - KeepPlayingModal -> ("", Just (0, [("OK", CancelButton)]), 80) - --- | Get the name of the current New Game menu. -curMenuName :: AppState -> Maybe Text -curMenuName s = case s ^. uiState . uiMenu of - NewGameMenu (_ :| (parentMenu : _)) -> - Just (parentMenu ^. BL.listSelectedElementL . to scenarioItemName) - NewGameMenu _ -> Just "Scenarios" - _ -> Nothing +listDrawTerrainElement :: Int -> Bool -> TerrainType -> Widget Name +listDrawTerrainElement pos _isSelected a = + clickable (TerrainListItem pos) $ drawLabeledTerrainSwatch a robotsListWidget :: AppState -> Widget Name robotsListWidget s = hCenter table @@ -594,7 +537,12 @@ robotsListWidget s = hCenter table locWidget = hBox [worldCell, txt $ " " <> locStr] where rloc@(V2 x y) = robot ^. robotLocation - worldCell = drawLoc (s ^. uiState . uiShowRobots) g (W.locToCoords rloc) + worldCell = + drawLoc + (s ^. uiState . uiShowRobots) + (s ^. uiState . uiWorldEditor) + g + (W.locToCoords rloc) locStr = from (show x) <> " " <> from (show y) statusWidget = case robot ^. machine of @@ -712,9 +660,6 @@ drawConst c = hBox [padLeft (Pad $ 13 - T.length constName) (txt constName), txt constName = syntax . constInfo $ c constSig = " : " <> prettyText (inferConst c) -descriptionTitle :: Entity -> String -descriptionTitle e = " " ++ from @Text (e ^. entityName) ++ " " - -- | Generate a pop-up widget to display the description of an entity. descriptionWidget :: AppState -> Entity -> Widget Name descriptionWidget s e = padLeftRight 1 (explainEntry s e) @@ -817,6 +762,7 @@ drawKeyMenu s = catMaybes [ may goal (NoHighlight, "^g", "goal") , may cheat (NoHighlight, "^v", "creative") + , may cheat (NoHighlight, "^e", "editor") , Just (NoHighlight, "^p", if isPaused then "unpause" else "pause") , Just (NoHighlight, "^o", "step") , Just (NoHighlight, "^zx", "speed") @@ -828,6 +774,8 @@ drawKeyMenu s = "pop out" | (s ^. uiState . uiMoreInfoBot) || (s ^. uiState . uiMoreInfoTop) -> Alert _ -> PanelSpecific + keyCmdsFor (Just WorldEditorPanel) = + [("^s", "save map")] keyCmdsFor (Just REPLPanel) = [ ("↓↑", "history") ] @@ -868,8 +816,8 @@ drawKeyCmd (h, key, cmd) = ------------------------------------------------------------ -- | Draw the current world view. -drawWorld :: Bool -> GameState -> Widget Name -drawWorld showRobots g = +drawWorld :: Bool -> WorldEditor -> GameState -> Widget Name +drawWorld showRobots we g = center . cached WorldCache . reportExtent WorldExtent @@ -881,14 +829,15 @@ drawWorld showRobots g = let w = ctx ^. availWidthL h = ctx ^. availHeightL ixs = range (viewingRegion g (fromIntegral w, fromIntegral h)) - render . vBox . map hBox . chunksOf w . map (drawLoc showRobots g) $ ixs + render . vBox . map hBox . chunksOf w . map (drawLoc showRobots we g) $ ixs -- | Render the 'Display' for a specific location. -drawLoc :: Bool -> GameState -> W.Coords -> Widget Name -drawLoc showRobots g = renderDisplay . displayLoc showRobots g +drawLoc :: Bool -> WorldEditor -> GameState -> W.Coords -> Widget Name +drawLoc showRobots we g = renderDisplay . displayLoc showRobots we g -displayTerrainCell :: GameState -> W.Coords -> Display -displayTerrainCell g coords = terrainMap M.! toEnum (W.lookupTerrain coords (g ^. world)) +displayTerrainCell :: WorldEditor -> GameState -> W.Coords -> Display +displayTerrainCell worldEditor g coords = + terrainMap M.! EU.getTerrainAt worldEditor (g ^. world) coords displayEntityCell, displayRobotCell :: GameState -> W.Coords -> [Display] displayRobotCell g coords = map (view robotDisplay) (robotsAtLocation (W.coordsToLoc coords) g) @@ -907,11 +856,11 @@ displayEntityCell g coords = maybeToList (displayForEntity <$> W.lookupEntity co -- | Get the 'Display' for a specific location, by combining the -- 'Display's for the terrain, entity, and robots at the location. -displayLoc :: Bool -> GameState -> W.Coords -> Display -displayLoc showRobots g coords = +displayLoc :: Bool -> WorldEditor -> GameState -> W.Coords -> Display +displayLoc showRobots worldEditor g coords = sconcat $ terrain NE.:| entity <> robots where - terrain = displayTerrainCell g coords + terrain = displayTerrainCell worldEditor g coords entity = displayEntityCell g coords robots = if showRobots diff --git a/src/Swarm/TUI/View/ViewUtils.hs b/src/Swarm/TUI/View/ViewUtils.hs new file mode 100644 index 0000000000..6bba60a6e4 --- /dev/null +++ b/src/Swarm/TUI/View/ViewUtils.hs @@ -0,0 +1,118 @@ +{-# LANGUAGE OverloadedStrings #-} + +module Swarm.TUI.View.ViewUtils where + +import Brick hiding (Direction) +import Brick.Widgets.Dialog +import Brick.Widgets.List qualified as BL +import Control.Lens hiding (Const, from) +import Data.List.NonEmpty (NonEmpty (..)) +import Data.Map qualified as M +import Data.Maybe (catMaybes, fromMaybe) +import Data.Text (Text) +import Data.Text qualified as T +import Swarm.Game.Display +import Swarm.Game.Entity as E +import Swarm.Game.Scenario (scenarioName) +import Swarm.Game.ScenarioInfo (scenarioItemName) +import Swarm.Game.State +import Swarm.Game.Terrain (TerrainType, terrainMap) +import Swarm.Language.Pretty (prettyText) +import Swarm.Language.Types (Polytype) +import Swarm.TUI.Attr +import Swarm.TUI.Model +import Swarm.Util +import Witch (from, into) + +-- | Generate a fresh modal window of the requested type. +generateModal :: AppState -> ModalType -> Modal +generateModal s mt = Modal mt (dialog (Just title) buttons (maxModalWindowWidth `min` requiredWidth)) + where + currentScenario = s ^. uiState . scenarioRef + currentSeed = s ^. gameState . seed + haltingMessage = case s ^. uiState . uiMenu of + NoMenu -> Just "Quit" + _ -> Nothing + descriptionWidth = 100 + helpWidth = 80 + (title, buttons, requiredWidth) = + case mt of + HelpModal -> (" Help ", Nothing, helpWidth) + RobotsModal -> ("Robots", Nothing, descriptionWidth) + RecipesModal -> ("Available Recipes", Nothing, descriptionWidth) + CommandsModal -> ("Available Commands", Nothing, descriptionWidth) + MessagesModal -> ("Messages", Nothing, descriptionWidth) + WinModal -> + let nextMsg = "Next challenge!" + stopMsg = fromMaybe "Return to the menu" haltingMessage + continueMsg = "Keep playing" + in ( "" + , Just + ( 0 + , [ (nextMsg, NextButton scene) + | Just scene <- [nextScenario (s ^. uiState . uiMenu)] + ] + ++ [ (stopMsg, QuitButton) + , (continueMsg, KeepPlayingButton) + ] + ) + , sum (map length [nextMsg, stopMsg, continueMsg]) + 32 + ) + DescriptionModal e -> (descriptionTitle e, Nothing, descriptionWidth) + QuitModal -> + let stopMsg = fromMaybe ("Quit to" ++ maybe "" (" " ++) (into @String <$> curMenuName s) ++ " menu") haltingMessage + maybeStartOver = sequenceA ("Start over", StartOverButton currentSeed <$> currentScenario) + in ( "" + , Just + ( 0 + , catMaybes + [ Just ("Keep playing", CancelButton) + , maybeStartOver + , Just (stopMsg, QuitButton) + ] + ) + , T.length (quitMsg (s ^. uiState . uiMenu)) + 4 + ) + GoalModal _ -> + let goalModalTitle = case currentScenario of + Nothing -> "Goal" + Just (scenario, _) -> scenario ^. scenarioName + in (" " <> T.unpack goalModalTitle <> " ", Nothing, 80) + KeepPlayingModal -> ("", Just (0, [("OK", CancelButton)]), 80) + TerrainPaletteModal -> ("Terrain", Nothing, w) + where + wordLength = maximum $ map (length . show) (listEnums :: [TerrainType]) + w = wordLength + 6 + +-- | Render the type of the current REPL input to be shown to the user. +drawType :: Polytype -> Widget Name +drawType = withAttr infoAttr . padLeftRight 1 . txt . prettyText + +drawLabeledTerrainSwatch :: TerrainType -> Widget Name +drawLabeledTerrainSwatch a = + tile <+> str materialName + where + tile = padRight (Pad 1) $ renderDisplay $ terrainMap M.! a + materialName = init $ show a + +descriptionTitle :: Entity -> String +descriptionTitle e = " " ++ from @Text (e ^. entityName) ++ " " + +-- | Width cap for modal and error message windows +maxModalWindowWidth :: Int +maxModalWindowWidth = 500 + +-- | Get the name of the current New Game menu. +curMenuName :: AppState -> Maybe Text +curMenuName s = case s ^. uiState . uiMenu of + NewGameMenu (_ :| (parentMenu : _)) -> + Just (parentMenu ^. BL.listSelectedElementL . to scenarioItemName) + NewGameMenu _ -> Just "Scenarios" + _ -> Nothing + +quitMsg :: Menu -> Text +quitMsg m = "Are you sure you want to " <> quitAction <> "? All progress on this scenario will be lost!" + where + quitAction = case m of + NoMenu -> "quit" + _ -> "return to the menu" diff --git a/swarm.cabal b/swarm.cabal index 9a5695a52b..e05d467e8b 100644 --- a/swarm.cabal +++ b/swarm.cabal @@ -111,10 +111,15 @@ library Swarm.Game.WorldGen Swarm.TUI.Attr Swarm.TUI.Border + Swarm.TUI.Controller.ControllerUtils + Swarm.TUI.Editor.EditorController + Swarm.TUI.Editor.Util + Swarm.TUI.Editor.EditorView Swarm.TUI.List Swarm.TUI.Panel Swarm.TUI.Model Swarm.TUI.View + Swarm.TUI.View.ViewUtils Swarm.TUI.Controller Swarm.TUI.Inventory.Sorting Swarm.App