diff --git a/.hlint.yaml b/.hlint.yaml index 048ca5232..7f8d06b48 100644 --- a/.hlint.yaml +++ b/.hlint.yaml @@ -28,6 +28,8 @@ - {name: Data.List.head, within: []} - {name: Prelude.head, within: [Swarm.Web.Tournament.Database.Query]} - {name: Prelude.tail, within: []} + - {name: Prelude.maximum, within: [Swarm.Util]} + - {name: Prelude.minimum, within: []} - {name: Prelude.!!, within: [Swarm.Util.indexWrapNonEmpty, TestEval]} - {name: undefined, within: [Swarm.Language.Key, TestUtil]} - {name: fromJust, within: []} diff --git a/src/swarm-doc/Swarm/Doc/Wiki/Cheatsheet.hs b/src/swarm-doc/Swarm/Doc/Wiki/Cheatsheet.hs index 9db17ed77..477b21519 100644 --- a/src/swarm-doc/Swarm/Doc/Wiki/Cheatsheet.hs +++ b/src/swarm-doc/Swarm/Doc/Wiki/Cheatsheet.hs @@ -42,7 +42,7 @@ import Swarm.Language.Syntax (Const (..)) import Swarm.Language.Syntax qualified as Syntax import Swarm.Language.Text.Markdown as Markdown (docToMark) import Swarm.Language.Typecheck (inferConst) -import Swarm.Util (showT) +import Swarm.Util (maximum0, showT) -- * Types @@ -99,7 +99,7 @@ listToRow mw xs = wrap '|' . T.intercalate "|" $ zipWith format mw xs format w x = wrap ' ' x <> T.replicate (w - T.length x) " " maxWidths :: [[Text]] -> [Int] -maxWidths = map (maximum . map T.length) . transpose +maxWidths = map (maximum0 . map T.length) . transpose -- ** COMMANDS diff --git a/src/swarm-engine/Swarm/Game/State/Initialize.hs b/src/swarm-engine/Swarm/Game/State/Initialize.hs index ff0816e03..f670d1524 100644 --- a/src/swarm-engine/Swarm/Game/State/Initialize.hs +++ b/src/swarm-engine/Swarm/Game/State/Initialize.hs @@ -55,7 +55,7 @@ import Swarm.Game.World.Gen (Seed) import Swarm.Language.Capability (constCaps) import Swarm.Language.Syntax (allConst, erase) import Swarm.Language.Types -import Swarm.Util (binTuples, (?)) +import Swarm.Util (applyWhen, binTuples, (?)) import System.Clock qualified as Clock import System.Random (mkStdGen) @@ -143,14 +143,14 @@ pureScenarioToGameState scenario theSeed now toRun gsc = -- If we are in creative mode, give base all the things & ix baseID . robotInventory - %~ case scenario ^. scenarioOperation . scenarioCreative of - False -> id - True -> union (fromElems (map (0,) things)) + %~ applyWhen + (scenario ^. scenarioOperation . scenarioCreative) + (union (fromElems (map (0,) things))) & ix baseID . equippedDevices - %~ case scenario ^. scenarioOperation . scenarioCreative of - False -> id - True -> const (fromList devices) + %~ applyWhen + (scenario ^. scenarioOperation . scenarioCreative) + (const (fromList devices)) running = case robotList of [] -> False diff --git a/src/swarm-topography/Swarm/Game/Universe.hs b/src/swarm-topography/Swarm/Game/Universe.hs index 10781c586..0ebd702ed 100644 --- a/src/swarm-topography/Swarm/Game/Universe.hs +++ b/src/swarm-topography/Swarm/Game/Universe.hs @@ -13,6 +13,7 @@ import Control.Lens (makeLenses, view) import Data.Function (on) import Data.Int (Int32) import Data.Text (Text) +import Data.Text qualified as T import Data.Yaml (FromJSON, ToJSON, Value (Object), parseJSON, withText, (.:)) import GHC.Generics (Generic) import Linear (V2 (..)) @@ -82,3 +83,17 @@ defaultCosmicLocation = Cosmic DefaultRootSubworld origin offsetBy :: Cosmic Location -> V2 Int32 -> Cosmic Location offsetBy loc v = fmap (.+^ v) loc + +-- ** Rendering + +locationToString :: Location -> String +locationToString (Location x y) = + unwords $ map show [x, y] + +renderCoordsString :: Cosmic Location -> String +renderCoordsString (Cosmic sw coords) = + unwords $ locationToString coords : suffix + where + suffix = case sw of + DefaultRootSubworld -> [] + SubworldName swName -> ["in", T.unpack swName] diff --git a/src/swarm-tui/Swarm/TUI/Controller.hs b/src/swarm-tui/Swarm/TUI/Controller.hs index 4410422c8..1b9e01bae 100644 --- a/src/swarm-tui/Swarm/TUI/Controller.hs +++ b/src/swarm-tui/Swarm/TUI/Controller.hs @@ -27,7 +27,6 @@ module Swarm.TUI.Controller ( ) where -- See Note [liftA2 re-export from Prelude] -import Prelude hiding (Applicative (..)) import Brick hiding (Direction, Location) import Brick.Focus @@ -36,10 +35,11 @@ import Brick.Widgets.Dialog import Brick.Widgets.Edit (Editor, applyEdit, handleEditorEvent) import Brick.Widgets.List (handleListEvent) import Brick.Widgets.List qualified as BL +import Brick.Widgets.TabularList.Mixed import Control.Applicative (pure) import Control.Category ((>>>)) import Control.Lens as Lens -import Control.Monad (unless, void, when) +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) @@ -87,7 +87,7 @@ import Swarm.Language.Value (Value (VKey), envTypes) import Swarm.Log import Swarm.TUI.Controller.EventHandlers import Swarm.TUI.Controller.SaveScenario (saveScenarioInfoOnQuit) -import Swarm.TUI.Controller.UpdateUI (updateAndRedrawUI) +import Swarm.TUI.Controller.UpdateUI import Swarm.TUI.Controller.Util import Swarm.TUI.Editor.Controller qualified as EC import Swarm.TUI.Editor.Model @@ -101,7 +101,11 @@ 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.Robot (getList) +import Swarm.TUI.View.Robot.Type import Swarm.Util hiding (both, (<<.=)) +import Prelude hiding (Applicative (..)) -- ~~~~ Note [liftA2 re-export from Prelude] -- @@ -292,7 +296,11 @@ handleMainEvent forceRedraw ev = do Web (RunWebCode e r) -> runBaseWebCode e r UpstreamVersion _ -> error "version event should be handled by top-level handler" VtyEvent (V.EvResize _ _) -> invalidateCache - EscapeKey | Just m <- s ^. uiState . uiGameplay . uiDialogs . uiModal -> closeModal m + EscapeKey + | Just m <- s ^. uiState . uiGameplay . uiDialogs . uiModal -> + if s ^. uiState . uiGameplay . uiDialogs . uiRobot . isDetailsOpened + then uiState . 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) @@ -375,19 +383,30 @@ closeModal m = do handleModalEvent :: V.Event -> EventM Name AppState () handleModalEvent = \case V.EvKey V.KEnter [] -> do - mdialog <- preuse $ uiState . uiGameplay . uiDialogs . uiModal . _Just . modalDialog - toggleModal QuitModal - case dialogSelection =<< mdialog of - Just (Button QuitButton, _) -> quitGame - Just (Button KeepPlayingButton, _) -> toggleModal KeepPlayingModal - Just (Button StartOverButton, StartOver currentSeed siPair) -> do - invalidateCache - restartGame currentSeed siPair - Just (Button NextButton, Next siPair) -> do - quitGame - invalidateCache - startGame siPair Nothing - _ -> return () + modal <- preuse $ uiState . uiGameplay . uiDialogs . uiModal . _Just . modalType + case modal of + Just RobotsModal -> do + robotDialog <- use $ uiState . uiGameplay . uiDialogs . uiRobot + unless (robotDialog ^. isDetailsOpened) $ do + let widget = robotDialog ^. robotListContent . robotsListWidget + forM_ (BL.listSelectedElement $ getList widget) $ \x -> do + Brick.zoom (uiState . uiGameplay . uiDialogs . uiRobot) $ do + isDetailsOpened .= True + updateRobotDetailsPane $ snd x + _ -> do + mdialog <- preuse $ uiState . uiGameplay . uiDialogs . uiModal . _Just . modalDialog + toggleModal QuitModal + case dialogSelection =<< mdialog of + Just (Button QuitButton, _) -> quitGame + Just (Button KeepPlayingButton, _) -> toggleModal KeepPlayingModal + Just (Button StartOverButton, StartOver currentSeed siPair) -> do + invalidateCache + restartGame currentSeed siPair + Just (Button NextButton, Next siPair) -> do + quitGame + invalidateCache + startGame siPair Nothing + _ -> return () ev -> do Brick.zoom (uiState . uiGameplay . uiDialogs . uiModal . _Just . modalDialog) (handleDialogEvent ev) modal <- preuse $ uiState . uiGameplay . uiDialogs . uiModal . _Just . modalType @@ -418,6 +437,19 @@ handleModalEvent = \case refreshList $ uiState . uiGameplay . uiDialogs . uiStructure . structurePanelListWidget StructureSummary -> handleInfoPanelEvent modalScroll (VtyEvent ev) _ -> handleInfoPanelEvent modalScroll (VtyEvent ev) + Just RobotsModal -> Brick.zoom (uiState . uiGameplay . uiDialogs . uiRobot) $ case ev of + V.EvKey (V.KChar '\t') [] -> robotDetailsFocus %= focusNext + _ -> do + isInDetailsMode <- use isDetailsOpened + if isInDetailsMode + then Brick.zoom (robotListContent . robotDetailsPaneState . logsList) $ handleListEvent ev + else do + Brick.zoom (robotListContent . robotsListWidget) $ + handleMixedListEvent ev + + -- Ensure list widget content is updated immediately + widget <- use $ robotListContent . robotsListWidget + forM_ (BL.listSelectedElement $ getList widget) $ updateRobotDetailsPane . snd _ -> handleInfoPanelEvent modalScroll (VtyEvent ev) where refreshGoalList lw = nestEventM' lw $ handleListEventWithSeparators ev shouldSkipSelection diff --git a/src/swarm-tui/Swarm/TUI/Controller/EventHandlers/Frame.hs b/src/swarm-tui/Swarm/TUI/Controller/EventHandlers/Frame.hs index 73cc9b86e..1e161e57d 100644 --- a/src/swarm-tui/Swarm/TUI/Controller/EventHandlers/Frame.hs +++ b/src/swarm-tui/Swarm/TUI/Controller/EventHandlers/Frame.hs @@ -27,6 +27,7 @@ import Swarm.TUI.Controller.Util import Swarm.TUI.Model import Swarm.TUI.Model.Achievements (popupAchievement) import Swarm.TUI.Model.UI +import Swarm.TUI.Model.UI.Gameplay import System.Clock ticksPerFrameCap :: Int diff --git a/src/swarm-tui/Swarm/TUI/Controller/EventHandlers/Main.hs b/src/swarm-tui/Swarm/TUI/Controller/EventHandlers/Main.hs index 7bf59d637..8f958d443 100644 --- a/src/swarm-tui/Swarm/TUI/Controller/EventHandlers/Main.hs +++ b/src/swarm-tui/Swarm/TUI/Controller/EventHandlers/Main.hs @@ -27,6 +27,7 @@ import Swarm.TUI.Model.DebugOption (DebugOption (ToggleCreative, ToggleWorldEdit import Swarm.TUI.Model.Dialog.Goal import Swarm.TUI.Model.Event (MainEvent (..), SwarmEvent (..)) import Swarm.TUI.Model.UI +import Swarm.TUI.Model.UI.Gameplay import System.Clock (Clock (..), TimeSpec (..), getTime) -- | Main keybindings event handler while running the game itself. diff --git a/src/swarm-tui/Swarm/TUI/Controller/EventHandlers/REPL.hs b/src/swarm-tui/Swarm/TUI/Controller/EventHandlers/REPL.hs index b6b28c5b9..c83b3e7df 100644 --- a/src/swarm-tui/Swarm/TUI/Controller/EventHandlers/REPL.hs +++ b/src/swarm-tui/Swarm/TUI/Controller/EventHandlers/REPL.hs @@ -23,6 +23,7 @@ import Swarm.TUI.Model import Swarm.TUI.Model.Event import Swarm.TUI.Model.Repl import Swarm.TUI.Model.UI +import Swarm.TUI.Model.UI.Gameplay -- | Handle a user input key event for the REPL. -- diff --git a/src/swarm-tui/Swarm/TUI/Controller/EventHandlers/Robot.hs b/src/swarm-tui/Swarm/TUI/Controller/EventHandlers/Robot.hs index b2a0bcefb..116018dc4 100644 --- a/src/swarm-tui/Swarm/TUI/Controller/EventHandlers/Robot.hs +++ b/src/swarm-tui/Swarm/TUI/Controller/EventHandlers/Robot.hs @@ -32,6 +32,7 @@ import Swarm.TUI.List import Swarm.TUI.Model import Swarm.TUI.Model.Event import Swarm.TUI.Model.UI +import Swarm.TUI.Model.UI.Gameplay import Swarm.TUI.View.Util (generateModal) -- | Handle user input events in the robot panel. diff --git a/src/swarm-tui/Swarm/TUI/Controller/EventHandlers/World.hs b/src/swarm-tui/Swarm/TUI/Controller/EventHandlers/World.hs index b4dbe921a..65cf9800a 100644 --- a/src/swarm-tui/Swarm/TUI/Controller/EventHandlers/World.hs +++ b/src/swarm-tui/Swarm/TUI/Controller/EventHandlers/World.hs @@ -23,6 +23,7 @@ import Swarm.TUI.Controller.Util import Swarm.TUI.Model import Swarm.TUI.Model.Event import Swarm.TUI.Model.UI +import Swarm.TUI.Model.UI.Gameplay -- | Handle a user input event in the world view panel. worldEventHandlers :: [KeyEventHandler SwarmEvent (EventM Name AppState)] diff --git a/src/swarm-tui/Swarm/TUI/Controller/SaveScenario.hs b/src/swarm-tui/Swarm/TUI/Controller/SaveScenario.hs index 26f6b451e..2ba201111 100644 --- a/src/swarm-tui/Swarm/TUI/Controller/SaveScenario.hs +++ b/src/swarm-tui/Swarm/TUI/Controller/SaveScenario.hs @@ -26,6 +26,7 @@ import Swarm.TUI.Model import Swarm.TUI.Model.Achievements (attainAchievement') import Swarm.TUI.Model.Repl import Swarm.TUI.Model.UI +import Swarm.TUI.Model.UI.Gameplay import System.FilePath (splitDirectories) import Prelude hiding (Applicative (..)) diff --git a/src/swarm-tui/Swarm/TUI/Controller/UpdateUI.hs b/src/swarm-tui/Swarm/TUI/Controller/UpdateUI.hs index a2e30d42b..9c6f4645f 100644 --- a/src/swarm-tui/Swarm/TUI/Controller/UpdateUI.hs +++ b/src/swarm-tui/Swarm/TUI/Controller/UpdateUI.hs @@ -7,24 +7,28 @@ module Swarm.TUI.Controller.UpdateUI ( updateUI, updateAndRedrawUI, + updateRobotDetailsPane, ) where -import Brick hiding (Direction, Location) -import Brick.Focus - -- See Note [liftA2 re-export from Prelude] +import Brick hiding (Direction, Location, on) +import Brick.Focus import Brick.Widgets.List qualified as BL import Control.Applicative (liftA2, pure) import Control.Lens as Lens -import Control.Monad (unless, when) +import Control.Monad (forM_, unless, when) import Control.Monad.IO.Class (liftIO) import Data.Foldable (toList) +import Data.Function (on) import Data.List.Extra (enumerate) +import Data.Map qualified as M import Data.Maybe (isNothing) import Data.String (fromString) import Data.Text qualified as T +import Data.Vector qualified as V import Swarm.Game.Entity hiding (empty) import Swarm.Game.Robot +import Swarm.Game.Robot.Activity import Swarm.Game.Robot.Concrete import Swarm.Game.State import Swarm.Game.State.Landscape @@ -42,7 +46,11 @@ import Swarm.TUI.Model.Dialog.Popup (Popup (..), addPopup) import Swarm.TUI.Model.Name import Swarm.TUI.Model.Repl import Swarm.TUI.Model.UI +import Swarm.TUI.Model.UI.Gameplay import Swarm.TUI.View.Objective qualified as GR +import Swarm.TUI.View.Robot +import Swarm.TUI.View.Robot.Type +import Swarm.Util (applyJust) import Witch (into) import Prelude hiding (Applicative (..)) @@ -165,6 +173,8 @@ updateUI = do newPopups <- generateNotificationPopups + doRobotListUpdate g + let redraw = g ^. needsRedraw || inventoryUpdated @@ -174,6 +184,38 @@ updateUI = do || newPopups pure redraw +doRobotListUpdate :: GameState -> EventM Name AppState () +doRobotListUpdate g = do + gp <- use $ uiState . uiGameplay + dOps <- use $ uiState . uiDebugOptions + + let rd = + mkRobotDisplay $ + RobotRenderingContext + { _mygs = g + , _gameplay = gp + , _timing = gp ^. uiTiming + , _uiDbg = dOps + } + oldList = getList $ gp ^. uiDialogs . uiRobot . robotListContent . robotsListWidget + maybeOldSelected = snd <$> BL.listSelectedElement oldList + + -- Since we're replacing the entire contents of the list, we need to preserve the + -- selected row here. + maybeModificationFunc = + updateList . BL.listFindBy . ((==) `on` view (robot . robotID)) <$> maybeOldSelected + + uiState . uiGameplay . uiDialogs . uiRobot . robotListContent . robotsListWidget .= applyJust maybeModificationFunc rd + + Brick.zoom (uiState . uiGameplay . uiDialogs . uiRobot) $ + forM_ maybeOldSelected updateRobotDetailsPane + +updateRobotDetailsPane :: RobotWidgetRow -> EventM Name RobotDisplay () +updateRobotDetailsPane robotPayload = + Brick.zoom robotListContent $ do + robotDetailsPaneState . cmdHistogramList . BL.listElementsL .= V.fromList (M.toList (robotPayload ^. robot . activityCounts . commandsHistogram)) + robotDetailsPaneState . logsList . BL.listElementsL .= robotPayload ^. robot . robotLog + -- | Either pops up the updated Goals modal -- or pops up the Congratulations (Win) modal, or pops -- up the Condolences (Lose) modal. diff --git a/src/swarm-tui/Swarm/TUI/Controller/Util.hs b/src/swarm-tui/Swarm/TUI/Controller/Util.hs index dff5581f9..0ad84b013 100644 --- a/src/swarm-tui/Swarm/TUI/Controller/Util.hs +++ b/src/swarm-tui/Swarm/TUI/Controller/Util.hs @@ -2,6 +2,8 @@ -- | -- SPDX-License-Identifier: BSD-3-Clause +-- +-- Keyboard key event patterns and drawing utilities module Swarm.TUI.Controller.Util where import Brick hiding (Direction) @@ -35,15 +37,15 @@ import Swarm.Language.Capability (Capability (CDebug)) import Swarm.Language.Syntax hiding (Key) import Swarm.TUI.Model ( AppState, - FocusablePanel, ModalType (..), - Name (..), gameState, modalScroll, uiState, ) +import Swarm.TUI.Model.Name import Swarm.TUI.Model.Repl (REPLHistItem, REPLPrompt, REPLState, addREPLItem, replHistory, replPromptText, replPromptType) import Swarm.TUI.Model.UI +import Swarm.TUI.Model.UI.Gameplay import Swarm.TUI.View.Util (generateModal) import System.Clock (Clock (..), getTime) diff --git a/src/swarm-tui/Swarm/TUI/Editor/Controller.hs b/src/swarm-tui/Swarm/TUI/Editor/Controller.hs index e52e4b666..46b2cd537 100644 --- a/src/swarm-tui/Swarm/TUI/Editor/Controller.hs +++ b/src/swarm-tui/Swarm/TUI/Editor/Controller.hs @@ -29,6 +29,7 @@ import Swarm.TUI.Editor.Util qualified as EU import Swarm.TUI.Model import Swarm.TUI.Model.Name import Swarm.TUI.Model.UI +import Swarm.TUI.Model.UI.Gameplay import Swarm.Util (hoistMaybe) import Swarm.Util.Erasable (maybeToErasable) import System.Clock diff --git a/src/swarm-tui/Swarm/TUI/Editor/Masking.hs b/src/swarm-tui/Swarm/TUI/Editor/Masking.hs index 547640abd..8f43b9488 100644 --- a/src/swarm-tui/Swarm/TUI/Editor/Masking.hs +++ b/src/swarm-tui/Swarm/TUI/Editor/Masking.hs @@ -8,7 +8,7 @@ import Swarm.Game.Universe import Swarm.Game.World.Coords import Swarm.TUI.Editor.Model import Swarm.TUI.Editor.Util qualified as EU -import Swarm.TUI.Model.UI +import Swarm.TUI.Model.UI.Gameplay shouldHideWorldCell :: UIGameplay -> Coords -> Bool shouldHideWorldCell ui coords = diff --git a/src/swarm-tui/Swarm/TUI/Editor/View.hs b/src/swarm-tui/Swarm/TUI/Editor/View.hs index 4c73991e4..057db5183 100644 --- a/src/swarm-tui/Swarm/TUI/Editor/View.hs +++ b/src/swarm-tui/Swarm/TUI/Editor/View.hs @@ -20,6 +20,7 @@ import Swarm.TUI.Editor.Model import Swarm.TUI.Model import Swarm.TUI.Model.Name import Swarm.TUI.Model.UI +import Swarm.TUI.Model.UI.Gameplay import Swarm.TUI.Panel import Swarm.TUI.View.Attribute.Attr import Swarm.TUI.View.CellDisplay (renderDisplay) @@ -118,7 +119,7 @@ drawWorldEditor toplevelFocusRing uis = L.intersperse "@" [ EA.renderRectDimensions rectArea - , VU.locationToString upperLeftLoc + , locationToString upperLeftLoc ] where upperLeftLoc = coordsToLoc upperLeftCoord diff --git a/src/swarm-tui/Swarm/TUI/Model.hs b/src/swarm-tui/Swarm/TUI/Model.hs index ce15747a6..4a6caa675 100644 --- a/src/swarm-tui/Swarm/TUI/Model.hs +++ b/src/swarm-tui/Swarm/TUI/Model.hs @@ -13,7 +13,7 @@ module Swarm.TUI.Model ( -- $uilabel AppEvent (..), FocusablePanel (..), - Name (..), + Name (..), -- helps to minimize import lines -- ** Web command WebCommand (..), @@ -109,6 +109,7 @@ import Swarm.TUI.Model.Event (SwarmEvent) import Swarm.TUI.Model.Menu import Swarm.TUI.Model.Name import Swarm.TUI.Model.UI +import Swarm.TUI.Model.UI.Gameplay import Swarm.TUI.Model.WebCommand (RejectionReason (..), WebCommand (..), WebInvocationState (..)) import Swarm.Util.Lens (makeLensesNoSigs) import Text.Fuzzy qualified as Fuzzy diff --git a/src/swarm-tui/Swarm/TUI/Model/Name.hs b/src/swarm-tui/Swarm/TUI/Model/Name.hs index ecb455ffb..c584fa26d 100644 --- a/src/swarm-tui/Swarm/TUI/Model/Name.hs +++ b/src/swarm-tui/Swarm/TUI/Model/Name.hs @@ -63,6 +63,17 @@ data Button | NextButton deriving (Eq, Ord, Show, Read, Bounded, Enum) +-- | Robot details +data RobotDetailSubpane + = RobotLogPane + | RobotCommandHistogramPane + deriving (Eq, Ord, Show, Read, Bounded, Enum) + +data RobotsDisplayMode + = RobotList + | SingleRobotDetails RobotDetailSubpane + deriving (Eq, Ord, Show, Read) + -- | 'Name' represents names to uniquely identify various components -- of the UI, such as forms, panels, caches, extents, lists, and buttons. data Name @@ -106,6 +117,8 @@ data Name StructureWidgets StructureWidget | -- | The list of scenario choices. ScenarioList + | -- | The robots list + RobotsListDialog RobotsDisplayMode | -- | The scrollable viewport for the info panel. InfoViewport | -- | The scrollable viewport for any modal dialog. diff --git a/src/swarm-tui/Swarm/TUI/Model/StateUpdate.hs b/src/swarm-tui/Swarm/TUI/Model/StateUpdate.hs index 6b8361d73..6d728bd1f 100644 --- a/src/swarm-tui/Swarm/TUI/Model/StateUpdate.hs +++ b/src/swarm-tui/Swarm/TUI/Model/StateUpdate.hs @@ -87,6 +87,7 @@ import Swarm.TUI.Model.KeyBindings import Swarm.TUI.Model.Name import Swarm.TUI.Model.Repl import Swarm.TUI.Model.UI +import Swarm.TUI.Model.UI.Gameplay import Swarm.TUI.View.Attribute.Attr (getWorldAttrName, swarmAttrMap) import Swarm.TUI.View.Attribute.CustomStyling (toAttrPair) import Swarm.TUI.View.Structure qualified as SR diff --git a/src/swarm-tui/Swarm/TUI/Model/UI.hs b/src/swarm-tui/Swarm/TUI/Model/UI.hs index 72ef5d12f..cbfff8726 100644 --- a/src/swarm-tui/Swarm/TUI/Model/UI.hs +++ b/src/swarm-tui/Swarm/TUI/Model/UI.hs @@ -8,51 +8,14 @@ -- SPDX-License-Identifier: BSD-3-Clause module Swarm.TUI.Model.UI ( UIState (..), - UIGameplay (..), - UITiming (..), - UIInventory (..), - GoalDisplay (..), - uiGameplay, - uiPopups, - uiTiming, - uiInventory, uiMenu, uiPlaying, uiDebugOptions, - uiFocusRing, uiLaunchConfig, - uiWorldCursor, - uiWorldEditor, - uiREPL, - uiInventoryList, - uiInventorySort, - uiInventorySearch, - uiScrollToEnd, - uiModal, - uiGoal, - uiStructure, - uiDialogs, - uiIsAutoPlay, - uiAutoShowObjectives, + uiGameplay, uiAchievements, - lgTicksPerSecond, - lastFrameTime, - accumulatedTime, - tickCount, - frameCount, - frameTickCount, - lastInfoTime, - uiShowFPS, - uiShowREPL, - uiShowZero, - uiShowDebug, - uiShowRobots, - uiHideRobotsUntil, - uiInventoryShouldUpdate, - uiTPF, - uiFPS, uiAttrMap, - scenarioRef, + uiPopups, -- ** Initialization initFocusRing, @@ -63,29 +26,21 @@ module Swarm.TUI.Model.UI ( import Brick (AttrMap) import Brick.Focus -import Brick.Widgets.List qualified as BL import Control.Arrow ((&&&)) import Control.Effect.Accum import Control.Effect.Lift import Control.Lens hiding (from, (<.>)) -import Data.Bits (FiniteBits (finiteBitSize)) import Data.List.Extra (enumerate) import Data.Map (Map) import Data.Map qualified as M import Data.Sequence (Seq) import Data.Set (Set) -import Data.Text (Text) import Data.Text qualified as T import Swarm.Game.Achievement.Attainment import Swarm.Game.Achievement.Definitions import Swarm.Game.Achievement.Persistence import Swarm.Game.Failure (SystemFailure) import Swarm.Game.ResourceLoading (getSwarmHistoryPath) -import Swarm.Game.ScenarioInfo ( - ScenarioInfoPair, - ) -import Swarm.Game.Universe -import Swarm.Game.World.Coords import Swarm.TUI.Editor.Model import Swarm.TUI.Inventory.Sorting import Swarm.TUI.Launch.Model @@ -95,203 +50,14 @@ import Swarm.TUI.Model.Dialog import Swarm.TUI.Model.Menu import Swarm.TUI.Model.Name import Swarm.TUI.Model.Repl +import Swarm.TUI.Model.UI.Gameplay import Swarm.TUI.View.Attribute.Attr (swarmAttrMap) +import Swarm.TUI.View.Robot +import Swarm.TUI.View.Robot.Type import Swarm.Util -import Swarm.Util.Lens (makeLensesExcluding, makeLensesNoSigs) +import Swarm.Util.Lens (makeLensesNoSigs) import System.Clock -data UITiming = UITiming - { _uiShowFPS :: Bool - , _uiTPF :: Double - , _uiFPS :: Double - , _lgTicksPerSecond :: Int - , _tickCount :: Int - , _frameCount :: Int - , _frameTickCount :: Int - , _lastFrameTime :: TimeSpec - , _accumulatedTime :: TimeSpec - , _lastInfoTime :: TimeSpec - } - --- * Lenses for UITiming - -makeLensesExcluding ['_lgTicksPerSecond] ''UITiming - --- | A toggle to show the FPS by pressing @f@ -uiShowFPS :: Lens' UITiming Bool - --- | Computed ticks per milliseconds -uiTPF :: Lens' UITiming Double - --- | Computed frames per milliseconds -uiFPS :: Lens' UITiming Double - --- | The base-2 logarithm of the current game speed in ticks/second. --- Note that we cap this value to the range of +/- log2 INTMAX. -lgTicksPerSecond :: Lens' UITiming Int -lgTicksPerSecond = lens _lgTicksPerSecond safeSetLgTicks - where - maxLog = finiteBitSize (maxBound :: Int) - maxTicks = maxLog - 2 - minTicks = 2 - maxLog - safeSetLgTicks ui lTicks - | lTicks < minTicks = setLgTicks ui minTicks - | lTicks > maxTicks = setLgTicks ui maxTicks - | otherwise = setLgTicks ui lTicks - setLgTicks ui lTicks = ui {_lgTicksPerSecond = lTicks} - --- | A counter used to track how many ticks have happened since the --- last time we updated the ticks/frame statistics. -tickCount :: Lens' UITiming Int - --- | A counter used to track how many frames have been rendered since the --- last time we updated the ticks/frame statistics. -frameCount :: Lens' UITiming Int - --- | A counter used to track how many ticks have happened in the --- current frame, so we can stop when we get to the tick cap. -frameTickCount :: Lens' UITiming Int - --- | The time of the last info widget update -lastInfoTime :: Lens' UITiming TimeSpec - --- | The time of the last 'Swarm.TUI.Model.Frame' event. -lastFrameTime :: Lens' UITiming TimeSpec - --- | The amount of accumulated real time. Every time we get a 'Swarm.TUI.Model.Frame' --- event, we accumulate the amount of real time that happened since --- the last frame, then attempt to take an appropriate number of --- ticks to "catch up", based on the target tick rate. --- --- See https://gafferongames.com/post/fix_your_timestep/ . -accumulatedTime :: Lens' UITiming TimeSpec - -data UIInventory = UIInventory - { _uiInventoryList :: Maybe (Int, BL.List Name InventoryListEntry) - , _uiInventorySort :: InventorySortOptions - , _uiInventorySearch :: Maybe Text - , _uiShowZero :: Bool - , _uiInventoryShouldUpdate :: Bool - } - --- * Lenses for UIInventory - -makeLensesNoSigs ''UIInventory - --- | The order and direction of sorting inventory list. -uiInventorySort :: Lens' UIInventory InventorySortOptions - --- | The current search string used to narrow the inventory view. -uiInventorySearch :: Lens' UIInventory (Maybe Text) - --- | The hash value of the focused robot entity (so we can tell if its --- inventory changed) along with a list of the items in the --- focused robot's inventory. -uiInventoryList :: Lens' UIInventory (Maybe (Int, BL.List Name InventoryListEntry)) - --- | A toggle to show or hide inventory items with count 0 by pressing @0@ -uiShowZero :: Lens' UIInventory Bool - --- | Whether the Inventory ui panel should update -uiInventoryShouldUpdate :: Lens' UIInventory Bool - --- | State that backs various modal dialogs -data UIDialogs = UIDialogs - { _uiModal :: Maybe Modal - , _uiGoal :: GoalDisplay - , _uiStructure :: StructureDisplay - } - --- * Lenses for UIDialogs - -makeLensesNoSigs ''UIDialogs - --- | When this is 'Just', it represents a modal to be displayed on --- top of the UI, e.g. for the Help screen. -uiModal :: Lens' UIDialogs (Maybe Modal) - --- | Status of the scenario goal: whether there is one, and whether it --- has been displayed to the user initially. -uiGoal :: Lens' UIDialogs GoalDisplay - --- | Definition and status of a recognizable structure -uiStructure :: Lens' UIDialogs StructureDisplay - --- | The main record holding the gameplay UI state. For access to the fields, --- see the lenses below. -data UIGameplay = UIGameplay - { _uiFocusRing :: FocusRing Name - , _uiWorldCursor :: Maybe (Cosmic Coords) - , _uiWorldEditor :: WorldEditor Name - , _uiREPL :: REPLState - , _uiInventory :: UIInventory - , _uiScrollToEnd :: Bool - , _uiDialogs :: UIDialogs - , _uiIsAutoPlay :: Bool - , _uiAutoShowObjectives :: Bool - , _uiShowREPL :: Bool - , _uiShowDebug :: Bool - , _uiHideRobotsUntil :: TimeSpec - , _uiTiming :: UITiming - , _scenarioRef :: Maybe ScenarioInfoPair - } - --- * Lenses for UIGameplay - -makeLensesNoSigs ''UIGameplay - --- | Temporal information for gameplay UI -uiTiming :: Lens' UIGameplay UITiming - --- | Inventory information for gameplay UI -uiInventory :: Lens' UIGameplay UIInventory - --- | The focus ring is the set of UI panels we can cycle among using --- the @Tab@ key. -uiFocusRing :: Lens' UIGameplay (FocusRing Name) - --- | The last clicked position on the world view. -uiWorldCursor :: Lens' UIGameplay (Maybe (Cosmic Coords)) - --- | State of all World Editor widgets -uiWorldEditor :: Lens' UIGameplay (WorldEditor Name) - --- | The state of REPL panel. -uiREPL :: Lens' UIGameplay REPLState - --- | A flag telling the UI to scroll the info panel to the very end --- (used when a new log message is appended). -uiScrollToEnd :: Lens' UIGameplay Bool - --- | State that backs various modal dialogs -uiDialogs :: Lens' UIGameplay UIDialogs - --- | When running with @--autoplay@ the progress will not be saved. -uiIsAutoPlay :: Lens' UIGameplay Bool - --- | Do not open objectives modals on objective completion. -uiAutoShowObjectives :: Lens' UIGameplay Bool - --- | A toggle to expand or collapse the REPL by pressing @Ctrl-k@ -uiShowREPL :: Lens' UIGameplay Bool - --- | A toggle to show CESK machine debug view and step through it. --- --- Note that the ability to use it can be enabled by player robot --- gaining the capability, or being in creative mode or with --- the debug option 'Swarm.TUI.Model.DebugOption.DebugCESK'. -uiShowDebug :: Lens' UIGameplay Bool - --- | Hide robots on the world map. -uiHideRobotsUntil :: Lens' UIGameplay TimeSpec - --- | Whether to show or hide robots on the world map. -uiShowRobots :: Getter UIGameplay Bool -uiShowRobots = to (\ui -> ui ^. uiTiming . lastFrameTime > ui ^. uiHideRobotsUntil) - --- | The currently active Scenario description, useful for starting over. -scenarioRef :: Lens' UIGameplay (Maybe ScenarioInfoPair) - -- * Toplevel UIState definition data UIState = UIState @@ -405,6 +171,12 @@ initUIState UIInitOptions {..} = do { _uiModal = Nothing , _uiGoal = emptyGoalDisplay , _uiStructure = emptyStructureDisplay + , _uiRobot = + RobotDisplay + { _robotDetailsFocus = focusRing $ map (RobotsListDialog . SingleRobotDetails) enumerate + , _isDetailsOpened = False + , _robotListContent = emptyRobotDisplay debugOptions + } } , _uiIsAutoPlay = False , _uiAutoShowObjectives = autoShowObjectives diff --git a/src/swarm-tui/Swarm/TUI/Model/UI/Gameplay.hs b/src/swarm-tui/Swarm/TUI/Model/UI/Gameplay.hs new file mode 100644 index 000000000..f8f99bed6 --- /dev/null +++ b/src/swarm-tui/Swarm/TUI/Model/UI/Gameplay.hs @@ -0,0 +1,269 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} +{-# LANGUAGE RecordWildCards #-} +{-# LANGUAGE TemplateHaskell #-} +{-# LANGUAGE ViewPatterns #-} + +-- | +-- SPDX-License-Identifier: BSD-3-Clause +-- +-- Sub-records utilized by 'Swarm.TUI.Model.UI' +-- This exists as a separate module to avoid import cycles. +module Swarm.TUI.Model.UI.Gameplay ( + UIGameplay (..), + UITiming (..), + UIInventory (..), + GoalDisplay (..), + UIDialogs (..), + uiTiming, + uiInventory, + uiFocusRing, + uiWorldCursor, + uiWorldEditor, + uiREPL, + uiInventoryList, + uiInventorySort, + uiInventorySearch, + uiScrollToEnd, + uiModal, + uiGoal, + uiStructure, + uiRobot, + uiDialogs, + uiIsAutoPlay, + uiAutoShowObjectives, + lgTicksPerSecond, + lastFrameTime, + accumulatedTime, + tickCount, + frameCount, + frameTickCount, + lastInfoTime, + uiShowFPS, + uiShowREPL, + uiShowZero, + uiShowDebug, + uiShowRobots, + uiHideRobotsUntil, + uiInventoryShouldUpdate, + uiTPF, + uiFPS, + scenarioRef, +) where + +import Brick.Focus +import Brick.Widgets.List qualified as BL +import Control.Lens hiding (from, (<.>)) +import Data.Bits (FiniteBits (finiteBitSize)) +import Data.Text (Text) +import Swarm.Game.ScenarioInfo ( + ScenarioInfoPair, + ) +import Swarm.Game.Universe +import Swarm.Game.World.Coords +import Swarm.TUI.Editor.Model +import Swarm.TUI.Inventory.Sorting +import Swarm.TUI.Model.Dialog.Goal +import Swarm.TUI.Model.Dialog.Structure +import Swarm.TUI.Model.Menu +import Swarm.TUI.Model.Name +import Swarm.TUI.Model.Repl +import Swarm.TUI.View.Robot.Type +import Swarm.Util.Lens (makeLensesExcluding, makeLensesNoSigs) +import System.Clock + +data UITiming = UITiming + { _uiShowFPS :: Bool + , _uiTPF :: Double + , _uiFPS :: Double + , _lgTicksPerSecond :: Int + , _tickCount :: Int + , _frameCount :: Int + , _frameTickCount :: Int + , _lastFrameTime :: TimeSpec + , _accumulatedTime :: TimeSpec + , _lastInfoTime :: TimeSpec + } + +-- * Lenses for UITiming + +makeLensesExcluding ['_lgTicksPerSecond] ''UITiming + +-- | A toggle to show the FPS by pressing @f@ +uiShowFPS :: Lens' UITiming Bool + +-- | Computed ticks per milliseconds +uiTPF :: Lens' UITiming Double + +-- | Computed frames per milliseconds +uiFPS :: Lens' UITiming Double + +-- | The base-2 logarithm of the current game speed in ticks/second. +-- Note that we cap this value to the range of +/- log2 INTMAX. +lgTicksPerSecond :: Lens' UITiming Int +lgTicksPerSecond = lens _lgTicksPerSecond safeSetLgTicks + where + maxLog = finiteBitSize (maxBound :: Int) + maxTicks = maxLog - 2 + minTicks = 2 - maxLog + safeSetLgTicks ui lTicks + | lTicks < minTicks = setLgTicks ui minTicks + | lTicks > maxTicks = setLgTicks ui maxTicks + | otherwise = setLgTicks ui lTicks + setLgTicks ui lTicks = ui {_lgTicksPerSecond = lTicks} + +-- | A counter used to track how many ticks have happened since the +-- last time we updated the ticks/frame statistics. +tickCount :: Lens' UITiming Int + +-- | A counter used to track how many frames have been rendered since the +-- last time we updated the ticks/frame statistics. +frameCount :: Lens' UITiming Int + +-- | A counter used to track how many ticks have happened in the +-- current frame, so we can stop when we get to the tick cap. +frameTickCount :: Lens' UITiming Int + +-- | The time of the last info widget update +lastInfoTime :: Lens' UITiming TimeSpec + +-- | The time of the last 'Swarm.TUI.Model.Frame' event. +lastFrameTime :: Lens' UITiming TimeSpec + +-- | The amount of accumulated real time. Every time we get a 'Swarm.TUI.Model.Frame' +-- event, we accumulate the amount of real time that happened since +-- the last frame, then attempt to take an appropriate number of +-- ticks to "catch up", based on the target tick rate. +-- +-- See https://gafferongames.com/post/fix_your_timestep/ . +accumulatedTime :: Lens' UITiming TimeSpec + +data UIInventory = UIInventory + { _uiInventoryList :: Maybe (Int, BL.List Name InventoryListEntry) + , _uiInventorySort :: InventorySortOptions + , _uiInventorySearch :: Maybe Text + , _uiShowZero :: Bool + , _uiInventoryShouldUpdate :: Bool + } + +-- * Lenses for UIInventory + +makeLensesNoSigs ''UIInventory + +-- | The order and direction of sorting inventory list. +uiInventorySort :: Lens' UIInventory InventorySortOptions + +-- | The current search string used to narrow the inventory view. +uiInventorySearch :: Lens' UIInventory (Maybe Text) + +-- | The hash value of the focused robot entity (so we can tell if its +-- inventory changed) along with a list of the items in the +-- focused robot's inventory. +uiInventoryList :: Lens' UIInventory (Maybe (Int, BL.List Name InventoryListEntry)) + +-- | A toggle to show or hide inventory items with count 0 by pressing @0@ +uiShowZero :: Lens' UIInventory Bool + +-- | Whether the Inventory ui panel should update +uiInventoryShouldUpdate :: Lens' UIInventory Bool + +-- | State that backs various modal dialogs +data UIDialogs = UIDialogs + { _uiModal :: Maybe Modal + , _uiGoal :: GoalDisplay + , _uiStructure :: StructureDisplay + , _uiRobot :: RobotDisplay + } + +-- * Lenses for UIDialogs + +makeLensesNoSigs ''UIDialogs + +-- | When this is 'Just', it represents a modal to be displayed on +-- top of the UI, e.g. for the Help screen. +uiModal :: Lens' UIDialogs (Maybe Modal) + +-- | Status of the scenario goal: whether there is one, and whether it +-- has been displayed to the user initially. +uiGoal :: Lens' UIDialogs GoalDisplay + +-- | Definition and status of a recognizable structure +uiStructure :: Lens' UIDialogs StructureDisplay + +-- | Definition and status of a recognizable structure +uiRobot :: Lens' UIDialogs RobotDisplay + +-- | The main record holding the gameplay UI state. For access to the fields, +-- see the lenses below. +data UIGameplay = UIGameplay + { _uiFocusRing :: FocusRing Name + , _uiWorldCursor :: Maybe (Cosmic Coords) + , _uiWorldEditor :: WorldEditor Name + , _uiREPL :: REPLState + , _uiInventory :: UIInventory + , _uiScrollToEnd :: Bool + , _uiDialogs :: UIDialogs + , _uiIsAutoPlay :: Bool + , _uiAutoShowObjectives :: Bool + , _uiShowREPL :: Bool + , _uiShowDebug :: Bool + , _uiHideRobotsUntil :: TimeSpec + , _uiTiming :: UITiming + , _scenarioRef :: Maybe ScenarioInfoPair + } + +-- * Lenses for UIGameplay + +makeLensesNoSigs ''UIGameplay + +-- | Temporal information for gameplay UI +uiTiming :: Lens' UIGameplay UITiming + +-- | Inventory information for gameplay UI +uiInventory :: Lens' UIGameplay UIInventory + +-- | The focus ring is the set of UI panels we can cycle among using +-- the @Tab@ key. +uiFocusRing :: Lens' UIGameplay (FocusRing Name) + +-- | The last clicked position on the world view. +uiWorldCursor :: Lens' UIGameplay (Maybe (Cosmic Coords)) + +-- | State of all World Editor widgets +uiWorldEditor :: Lens' UIGameplay (WorldEditor Name) + +-- | The state of REPL panel. +uiREPL :: Lens' UIGameplay REPLState + +-- | A flag telling the UI to scroll the info panel to the very end +-- (used when a new log message is appended). +uiScrollToEnd :: Lens' UIGameplay Bool + +-- | State that backs various modal dialogs +uiDialogs :: Lens' UIGameplay UIDialogs + +-- | When running with @--autoplay@ the progress will not be saved. +uiIsAutoPlay :: Lens' UIGameplay Bool + +-- | Do not open objectives modals on objective completion. +uiAutoShowObjectives :: Lens' UIGameplay Bool + +-- | A toggle to expand or collapse the REPL by pressing @Ctrl-k@ +uiShowREPL :: Lens' UIGameplay Bool + +-- | A toggle to show CESK machine debug view and step through it. +-- +-- Note that the ability to use it can be enabled by player robot +-- gaining the capability, or being in creative mode or with +-- the debug option 'Swarm.TUI.Model.DebugOption.DebugCESK'. +uiShowDebug :: Lens' UIGameplay Bool + +-- | Hide robots on the world map. +uiHideRobotsUntil :: Lens' UIGameplay TimeSpec + +-- | Whether to show or hide robots on the world map. +uiShowRobots :: Getter UIGameplay Bool +uiShowRobots = to (\ui -> ui ^. uiTiming . lastFrameTime > ui ^. uiHideRobotsUntil) + +-- | The currently active Scenario description, useful for starting over. +scenarioRef :: Lens' UIGameplay (Maybe ScenarioInfoPair) diff --git a/src/swarm-tui/Swarm/TUI/View.hs b/src/swarm-tui/Swarm/TUI/View.hs index b5df6bb24..e552c6d87 100644 --- a/src/swarm-tui/Swarm/TUI/View.hs +++ b/src/swarm-tui/Swarm/TUI/View.hs @@ -131,6 +131,7 @@ import Swarm.TUI.Model.Event qualified as SE import Swarm.TUI.Model.KeyBindings (handlerNameKeysDescription) import Swarm.TUI.Model.Repl import Swarm.TUI.Model.UI +import Swarm.TUI.Model.UI.Gameplay import Swarm.TUI.Panel import Swarm.TUI.View.Achievement import Swarm.TUI.View.Attribute.Attr @@ -377,7 +378,7 @@ makeBestScoreRows scenarioStat = , Just $ describeProgress b ) where - maxLeftColumnWidth = maximum (map (T.length . describeCriteria) enumerate) + maxLeftColumnWidth = maximum0 (map (T.length . describeCriteria) enumerate) mkCriteriaRow = withAttr dimAttr . padLeft Max @@ -508,7 +509,7 @@ drawWorldCursorInfo worldEditor g cCoords = Cosmic _ coords = cCoords coordsWidget = str $ renderCoordsString $ fmap coordsToLoc cCoords - tileMembers = terrain : mapMaybe merge [entity, robot] + tileMembers = terrain : mapMaybe merge [entity, r] tileMemberWidgets = map (padRight $ Pad 1) . concat @@ -526,7 +527,7 @@ drawWorldCursorInfo worldEditor g cCoords = terrain = displayTerrainCell worldEditor ri cCoords entity = displayEntityCell worldEditor ri cCoords - robot = displayRobotCell g cCoords + r = displayRobotCell g cCoords merge = fmap sconcat . NE.nonEmpty . filter (not . (^. invisible)) @@ -604,6 +605,7 @@ drawDialog :: AppState -> Widget Name drawDialog s = case s ^. uiState . uiGameplay . uiDialogs . uiModal of Just (Modal mt d) -> renderDialog d $ case mt of GoalModal -> drawModal s mt + RobotsModal -> drawModal s mt _ -> maybeScroll ModalViewport $ drawModal s mt Nothing -> emptyWidget @@ -611,7 +613,7 @@ drawDialog s = case s ^. uiState . uiGameplay . uiDialogs . uiModal of drawModal :: AppState -> ModalType -> Widget Name drawModal s = \case HelpModal -> helpWidget (s ^. gameState . randomness . seed) (s ^. runtimeState . webPort) (s ^. keyEventHandling) - RobotsModal -> robotsListWidget s + RobotsModal -> drawRobotsModal $ s ^. uiState . uiGameplay . uiDialogs . uiRobot RecipesModal -> availableListWidget (s ^. gameState) RecipeList CommandsModal -> commandsListWidget (s ^. gameState) MessagesModal -> availableListWidget (s ^. gameState) MessageList @@ -687,7 +689,7 @@ helpWidget theSeed mport keyState = keyHandlerToText = handlerNameKeysDescription (keyState ^. keyConfig) -- Get maximum width of the table columns so it all neatly aligns txtFilled n t = padRight (Pad $ max 0 (n - textWidth t)) $ txt t - (maxN, maxK, maxD) = map3 (maximum . map textWidth) . unzip3 $ keyHandlerToText <$> allEventHandlers + (maxN, maxK, maxD) = map3 (maximum0 . map textWidth) . unzip3 $ keyHandlerToText <$> allEventHandlers map3 f (n, k, d) = (f n, f k, f d) data NotificationList = RecipeList | MessageList @@ -1018,14 +1020,14 @@ drawRobotPanel s | Just r <- s ^. gameState . to focusedRobot , Just (_, lst) <- s ^. uiState . uiGameplay . uiInventory . uiInventoryList = let drawClickableItem pos selb = clickable (InventoryListItem pos) . drawItem (lst ^. BL.listSelectedL) pos selb - row = + details = [ txt (r ^. robotName) , padLeft (Pad 2) . str . renderCoordsString $ r ^. robotLocation , padLeft (Pad 2) $ renderDisplay (r ^. robotDisplay) ] in padBottom Max $ vBox - [ hCenter $ hBox row + [ hCenter $ hBox details , withLeftPaddedVScrollBars . padLeft (Pad 1) . padTop (Pad 1) $ BL.renderListWithIndex drawClickableItem True lst ] diff --git a/src/swarm-tui/Swarm/TUI/View/CellDisplay.hs b/src/swarm-tui/Swarm/TUI/View/CellDisplay.hs index 8d9320c0e..99c1883c6 100644 --- a/src/swarm-tui/Swarm/TUI/View/CellDisplay.hs +++ b/src/swarm-tui/Swarm/TUI/View/CellDisplay.hs @@ -48,7 +48,7 @@ import Swarm.TUI.Editor.Masking import Swarm.TUI.Editor.Model import Swarm.TUI.Editor.Util qualified as EU import Swarm.TUI.Model.Name -import Swarm.TUI.Model.UI +import Swarm.TUI.Model.UI.Gameplay import Swarm.TUI.View.Attribute.Attr import Swarm.Util (applyWhen) import Witch (from) diff --git a/src/swarm-tui/Swarm/TUI/View/Popup.hs b/src/swarm-tui/Swarm/TUI/View/Popup.hs index 96b30e62f..dfd1c8e71 100644 --- a/src/swarm-tui/Swarm/TUI/View/Popup.hs +++ b/src/swarm-tui/Swarm/TUI/View/Popup.hs @@ -14,9 +14,10 @@ import Control.Lens ((^.)) import Swarm.Game.Achievement.Definitions (title) import Swarm.Game.Achievement.Description (describe) import Swarm.Language.Syntax (constInfo, syntax) -import Swarm.TUI.Model (AppState, Name, uiState) +import Swarm.TUI.Model (AppState, uiState) import Swarm.TUI.Model.Dialog.Popup (Popup (..), currentPopup, popupFrames) import Swarm.TUI.Model.Event qualified as SE +import Swarm.TUI.Model.Name import Swarm.TUI.Model.UI (uiPopups) import Swarm.TUI.View.Attribute.Attr (notifAttr) import Swarm.TUI.View.Util (bindingText) diff --git a/src/swarm-tui/Swarm/TUI/View/Robot.hs b/src/swarm-tui/Swarm/TUI/View/Robot.hs index 93ff2415b..18e30c5cc 100644 --- a/src/swarm-tui/Swarm/TUI/View/Robot.hs +++ b/src/swarm-tui/Swarm/TUI/View/Robot.hs @@ -1,20 +1,42 @@ +{-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE NoGeneralizedNewtypeDeriving #-} -- | -- SPDX-License-Identifier: BSD-3-Clause -- -- A UI-centric model for presentation of Robot details. -module Swarm.TUI.View.Robot where +module Swarm.TUI.View.Robot ( + emptyRobotDisplay, + RobotRenderingContext (..), + mkRobotDisplay, + getList, + updateList, + -- renderRobotsList, + renderDutyCycle, + drawRobotsModal, +) where -import Brick hiding (Direction, Location) -import Brick.Widgets.Center (hCenter) -import Brick.Widgets.Table qualified as BT +import Brick +import Brick.Widgets.Border +import Brick.Widgets.List qualified as BL +import Brick.Widgets.TabularList.Mixed +import Control.Lens hiding (from, (<.>)) import Control.Lens as Lens hiding (Const, from) import Data.IntMap qualified as IM +import Data.List (mapAccumL) +import Data.List.NonEmpty (NonEmpty ((:|))) +import Data.List.NonEmpty qualified as NE import Data.Map qualified as M import Data.Maybe (fromMaybe) -import Linear +import Data.Sequence (Seq) +import Data.Sequence qualified as S +import Data.Set (Set) +import Data.Text qualified as T +import Data.Vector (Vector) +import Data.Vector qualified as V +import Linear (V2 (..), distance) import Numeric (showFFloat) import Swarm.Game.CESK (CESK (..)) import Swarm.Game.Entity as E @@ -28,17 +50,229 @@ import Swarm.Game.State.Substate import Swarm.Game.Tick (addTicks) import Swarm.Game.Universe import Swarm.Game.World.Coords -import Swarm.TUI.Model -import Swarm.TUI.Model.DebugOption (DebugOption (..)) -import Swarm.TUI.Model.UI +import Swarm.TUI.Model.DebugOption +import Swarm.TUI.Model.Name +import Swarm.TUI.Model.UI.Gameplay import Swarm.TUI.View.Attribute.Attr import Swarm.TUI.View.CellDisplay -import Swarm.TUI.View.Util as VU -import Swarm.Util +import Swarm.TUI.View.Robot.Details +import Swarm.TUI.View.Robot.Type +import Swarm.TUI.View.Shared (tabControlFooter) +import Swarm.Util (applyWhen, maximum0, maximumNE) import Swarm.Util.UnitInterval import Swarm.Util.WindowedCounter qualified as WC import System.Clock (TimeSpec (..)) +extractColWidth :: ColWidth -> Int +extractColWidth (ColW x) = x + +getMaxWidth :: ColWidth -> ColWidth -> ColWidth +getMaxWidth (ColW w1) (ColW w2) = ColW $ max w1 w2 + +data RobotRenderingContext = RobotRenderingContext + { _mygs :: GameState + , _gameplay :: UIGameplay + , _timing :: UITiming + , _uiDbg :: Set DebugOption + } + +makeLenses ''RobotRenderingContext + +mkRobotDisplay :: RobotRenderingContext -> MixedTabularList Name RobotWidgetRow Widths +mkRobotDisplay c = + mixedTabularList + (RobotsListDialog RobotList) + (mkLibraryEntries c) + (LstItmH 1) + (computeColumnWidths $ c ^. uiDbg) + assignRowWidthForConstructor + +emptyRobotDisplay :: Set DebugOption -> RobotListContent +emptyRobotDisplay uiDebug = + RobotListContent + { _robotsListWidget = mixedTabularList (RobotsListDialog RobotList) mempty (LstItmH 1) (computeColumnWidths uiDebug) assignRowWidthForConstructor + , _robotsListRenderers = + MixedRenderers + { cell = drawCell uiDebug + , rowHdr = Just rowHdr + , colHdr = Just $ colHdr uiDebug + , colHdrRowHdr = Just $ ColHdrRowHdr $ \_ _ -> vLimit 1 (fill ' ') <=> hBorder + } + , _robotDetailsPaneState = + RobotDetailsPaneState + { _logsList = BL.list (RobotsListDialog $ SingleRobotDetails RobotLogPane) mempty 1 + , _cmdHistogramList = BL.list (RobotsListDialog $ SingleRobotDetails RobotCommandHistogramPane) mempty 1 + } + } + +renderRobotsList :: RobotListContent -> Widget Name +renderRobotsList rd = + vLimit 30 $ + renderMixedTabularList (rd ^. robotsListRenderers) (LstFcs True) (rd ^. robotsListWidget) + +columnHdrAttr :: AttrName +columnHdrAttr = attrName "columnHeader" + +rowHdrAttr :: AttrName +rowHdrAttr = attrName "rowHeader" + +colHdr :: Set DebugOption -> MixedColHdr Name Widths +colHdr uiDebug = + MixedColHdr + { draw = \_ (MColC (Ix ci)) -> case hdrs V.!? ci of + Just ch -> withAttr columnHdrAttr (str ch) <=> hBorder + Nothing -> emptyWidget + , widths = \(Widths ws) -> zipWith getMaxWidth ws (map (ColW . length) $ V.toList hdrs) + , height = ColHdrH 2 + } + where + hdrs = colHdrs uiDebug + +-- | Enumerates the rows by position (not 'RID'). +rowHdr :: RowHdr Name RobotWidgetRow +rowHdr = + RowHdr + { draw = \_ (WdthD wd) (RowHdrCtxt (Sel s)) rh -> + let attrFn = + if s + then id + else withAttr rowHdrAttr + in attrFn $ padRight (Pad $ if wd > 0 then 0 else 1) $ padLeft Max (str $ show rh) + , width = \_ rh -> RowHdrW . (+ 2) . maximum0 $ map (length . show) rh + , toRH = \_ (Ix i) -> i + 1 + } + +-- | Note that with the current two constructors, this happens to be analogous +-- to 'Brick.Types.Size'. However, we'd like to reserve the right to add +-- more constructors/growth modes and not be bound by the Brick semantics, so +-- we have our own definition here. +data ColumnExpansion + = Grow + | Minimal + deriving (Eq) + +data ColumnAttributes = Col + { headingString :: String + , expansionPolicy :: ColumnExpansion + } + +getColumnAttrList :: Set DebugOption -> NonEmpty ColumnAttributes +getColumnAttrList dbgOptions = + NE.map ($ headingStrings) $ getAccessorList dbgOptions + where + headingStrings = + RobotRow + { rowID = Col "ID" Minimal + , rowName = Col "Name" Grow + , rowAge = Col "Age" Minimal + , rowPos = Col "Pos" Minimal + , rowItems = Col "Items" Minimal + , rowStatus = Col "Status" Minimal + , rowActns = Col "Actns" Minimal + , rowCmds = Col "Cmds" Minimal + , rowCycles = Col "Cycles" Minimal + , rowActivity = Col "Activity" Grow + , rowLog = Col "Log" Minimal + } + +colHdrs :: Set DebugOption -> Vector String +colHdrs = + V.fromList + . NE.toList + . NE.map headingString + . getColumnAttrList + +getAccessorList :: Set DebugOption -> NonEmpty (RobotRow a -> a) +getAccessorList dbgOptions = + applyWhen debugRID (NE.cons rowID) mainListSuffix + where + debugRID = dbgOptions ^. Lens.contains ListRobotIDs + + mainListSuffix = + rowName + :| [ rowAge + , rowPos + , rowItems + , rowStatus + , rowActns + , rowCmds + , rowCycles + , rowActivity + , rowLog + ] + +drawCell :: Set DebugOption -> ListFocused -> MixedCtxt -> RobotWidgetRow -> Widget Name +drawCell uiDebug _ (MxdCtxt _ (MColC (Ix ci))) r = + maybe emptyWidget (renderPlainCell . wWidget . ($ view row r)) (indexedAccessors V.!? ci) + where + indexedAccessors = V.fromList $ NE.toList accessors + accessors = getAccessorList uiDebug + renderPlainCell = padRight Max + +-- | For a single-constructor datatype like 'RobotWidgetRow', +-- this implementation is trivial. +assignRowWidthForConstructor :: WidthsPerRow RobotWidgetRow Widths +assignRowWidthForConstructor = WsPerR $ \(Widths x) _ -> x + +-- | +-- First, computes the minimum width for each column, using +-- both the header string width and the widest visible cell content, +-- then adding 1 to the result of each column for padding. +-- +-- Second, to utilize the full available width for the table, distributes +-- the extra space equally among columns marked as 'Grow'. +computeColumnWidths :: Set DebugOption -> WidthsPerRowKind RobotWidgetRow Widths +computeColumnWidths uiDebug = WsPerRK $ \availableWidth allRows -> + let output = maybe [] (NE.toList . distributeWidths availableWidth) $ NE.nonEmpty allRows + in Widths {robotRowWidths = output} + where + distributeWidths (AvlW availableWidth) allRows = + NE.zipWith (\(ColW w) extra -> ColW $ w + extra) minWidthsPerColum distributedRemainderSpace + where + minWidthsPerColum = mkWidths allRows + totalRequiredWidth = sum $ NE.map extractColWidth minWidthsPerColum + spareWidth = availableWidth - totalRequiredWidth + + growPolicies = NE.map expansionPolicy colAttrList + growableColumnCount = length $ filter (== Grow) $ NE.toList growPolicies + (spacePerGrowable, remainingSpace) = spareWidth `divMod` growableColumnCount + + distributedRemainderSpace = snd $ mapAccumL addedWidth remainingSpace growPolicies + addedWidth remainder policy = case policy of + Grow -> (remainder - 1, spacePerGrowable + extra) + where + extra = fromEnum $ remainder > 0 + Minimal -> (remainder, 0) + + colAttrList = getColumnAttrList uiDebug + colHeaderRowLengths = NE.map (length . headingString) colAttrList + + -- We take the maximum of all cell widths, including the headers, and + -- add 1 for "padding". + -- NOTE: We don't necessarily need to pad the last column, but it's + -- simpler this way and it looks fine. + mkWidths :: NonEmpty RobotWidgetRow -> NonEmpty ColWidth + mkWidths = + NE.map (ColW . (+ 1) . maximumNE) + . NE.transpose + . (colHeaderRowLengths `NE.cons`) + . NE.map getColWidthsForRow + where + getColWidthsForRow :: RobotWidgetRow -> NonEmpty Int + getColWidthsForRow r = NE.map (wWidth . ($ view row r)) $ getAccessorList uiDebug + +getList :: MixedTabularList n e w -> BL.GenericList n Seq e +getList (MixedTabularList oldList _ _) = oldList + +updateList :: + (BL.GenericList n1 Seq e -> BL.GenericList n2 Seq e) -> + MixedTabularList n1 e w -> + MixedTabularList n2 e w +updateList f (MixedTabularList ls a b) = MixedTabularList (f ls) a b + +strWidget :: String -> WidthWidget +strWidget tx = WithWidth (length tx) (str tx) + -- | Render the percentage of ticks that this robot was active. -- This indicator can take some time to "warm up" and stabilize -- due to the sliding window. @@ -50,12 +284,14 @@ import System.Clock (TimeSpec (..)) -- hence 'WC.getOccupancy' will never be @1@ if we use the current tick directly as -- obtained from the 'ticks' function. -- So we "rewind" it to the previous tick for the purpose of this display. -renderDutyCycle :: GameState -> Robot -> Widget Name -renderDutyCycle gs robot = - withAttr dutyCycleAttr . str . flip (showFFloat (Just 1)) "%" $ dutyCyclePercentage +renderDutyCycle :: TemporalState -> Robot -> WidthWidget +renderDutyCycle temporalState r = + withAttr dutyCycleAttr <$> strWidget tx where - curTicks = gs ^. temporal . ticks - window = robot ^. activityCounts . activityWindow + tx = showFFloat (Just 1) dutyCyclePercentage "%" + + curTicks = temporalState ^. ticks + window = r ^. activityCounts . activityWindow -- Rewind to previous tick latestRobotTick = addTicks (-1) curTicks @@ -66,57 +302,36 @@ renderDutyCycle gs robot = dutyCyclePercentage :: Double dutyCyclePercentage = 100 * getValue dutyCycleRatio -robotsListWidget :: AppState -> Widget Name -robotsListWidget s = hCenter table +mkLibraryEntries :: RobotRenderingContext -> Seq RobotWidgetRow +mkLibraryEntries c = + mkRobotRow <$> S.fromList robots where - table = - BT.renderTable - . BT.columnBorders False - . BT.setDefaultColAlignment BT.AlignCenter - -- Inventory count is right aligned - . BT.alignRight 4 - . BT.table - $ map (padLeftRight 1) <$> (headers : robotsTable) - headings = - [ "Name" - , "Age" - , "Pos" - , "Items" - , "Status" - , "Actns" - , "Cmds" - , "Cycles" - , "Activity" - , "Log" - ] - headers = withAttr robotAttr . txt <$> applyWhen debugRID ("ID" :) headings - robotsTable = mkRobotRow <$> robots - mkRobotRow robot = - applyWhen debugRID (idWidget :) cells + mkRobotRow r = + RobotRowPayload r $ + RobotRow + { rowID = strWidget $ show $ r ^. robotID + , rowName = nameWidget + , rowAge = strWidget ageStr + , rowPos = locWidget + , rowItems = increaseWidth 1 $ strWidget $ show rInvCount + , rowStatus = statusWidget + , rowActns = strWidget $ show $ r ^. activityCounts . tangibleCommandCount + , rowCmds = strWidget $ show . sum . M.elems $ r ^. activityCounts . commandsHistogram + , rowCycles = strWidget $ show $ r ^. activityCounts . lifetimeStepCount + , rowActivity = renderDutyCycle (c ^. mygs . temporal) r + , rowLog = strWidget $ pure rLog + } where - cells = - [ nameWidget - , str ageStr - , locWidget - , padRight (Pad 1) (str $ show rInvCount) - , statusWidget - , str $ show $ robot ^. activityCounts . tangibleCommandCount - , -- TODO(#1341): May want to expose the details of this histogram in - -- a per-robot pop-up - str . show . sum . M.elems $ robot ^. activityCounts . commandsHistogram - , str $ show $ robot ^. activityCounts . lifetimeStepCount - , renderDutyCycle (s ^. gameState) robot - , txt rLog - ] - - idWidget = str $ show $ robot ^. robotID - nameWidget = - hBox - [ renderDisplay (robot ^. robotDisplay) - , highlightSystem . txt $ " " <> robot ^. robotName - ] - - highlightSystem = if robot ^. systemRobot then withAttr highlightAttr else id + nameWidget = WithWidth (2 + T.length nameTxt) w + where + w = + hBox + [ renderDisplay (r ^. robotDisplay) + , highlightSystem . txt $ " " <> nameTxt + ] + nameTxt = r ^. robotName + + highlightSystem = if r ^. systemRobot then withAttr highlightAttr else id ageStr | age < 60 = show age <> "sec" @@ -124,44 +339,65 @@ robotsListWidget s = hCenter table | age < 3600 * 24 = show (age `div` 3600) <> "hour" | otherwise = show (age `div` 3600 * 24) <> "day" where - TimeSpec createdAtSec _ = robot ^. robotCreatedAt - TimeSpec nowSec _ = s ^. uiState . uiGameplay . uiTiming . lastFrameTime + TimeSpec createdAtSec _ = r ^. robotCreatedAt + TimeSpec nowSec _ = c ^. timing . lastFrameTime age = nowSec - createdAtSec - rInvCount = sum $ map fst . E.elems $ robot ^. robotEntity . entityInventory + rInvCount = sum $ map fst . E.elems $ r ^. robotEntity . entityInventory rLog - | robot ^. robotLogUpdated = "x" - | otherwise = " " + | r ^. robotLogUpdated = 'x' + | otherwise = ' ' - locWidget = hBox [worldCell, str $ " " <> locStr] + locWidget = + WithWidth (2 + length locStr) w where + w = hBox [worldCell, str $ " " <> locStr] rCoords = fmap locToCoords rLoc - rLoc = robot ^. robotLocation + rLoc = r ^. robotLocation worldCell = drawLoc - (s ^. uiState . uiGameplay) + (c ^. gameplay) g rCoords locStr = renderCoordsString rLoc - statusWidget = case robot ^. machine of - Waiting {} -> txt "waiting" + statusWidget = case r ^. machine of + Waiting {} -> strWidget "waiting" _ - | isActive robot -> withAttr notifAttr $ txt "busy" - | otherwise -> withAttr greenAttr $ txt "idle" + | isActive r -> withAttr notifAttr <$> strWidget "busy" + | otherwise -> withAttr greenAttr <$> strWidget "idle" basePos :: Point V2 Double basePos = realToFrac <$> fromMaybe origin (g ^? baseRobot . robotLocation . planar) -- Keep the base and non system robot (e.g. no seed) - isRelevant robot = robot ^. robotID == 0 || not (robot ^. systemRobot) + isRelevant r = r ^. robotID == 0 || not (r ^. systemRobot) -- Keep the robot that are less than 32 unit away from the base - isNear robot = creative || distance (realToFrac <$> robot ^. robotLocation . planar) basePos < 32 + isNear r = creative || distance (realToFrac <$> r ^. robotLocation . planar) basePos < 32 robots :: [Robot] robots = - filter (\robot -> debugAllRobots || (isRelevant robot && isNear robot)) + filter (\r -> debugAllRobots || (isRelevant r && isNear r)) . IM.elems $ g ^. robotInfo . robotMap creative = g ^. creativeMode - debugRID = s ^. uiState . uiDebugOptions . Lens.contains ListRobotIDs - debugAllRobots = s ^. uiState . uiDebugOptions . Lens.contains ListAllRobots - g = s ^. gameState + debugAllRobots = c ^. uiDbg . Lens.contains ListAllRobots + g = c ^. mygs + +drawRobotsModal :: RobotDisplay -> Widget Name +drawRobotsModal robotDialog = + mainContent + where + rFocusRing = robotDialog ^. robotDetailsFocus + + mainContent = + if robotDialog ^. isDetailsOpened + then + let oldList = getList $ robotDialog ^. robotListContent . robotsListWidget + maybeSelectedRobot = view robot . snd <$> BL.listSelectedElement oldList + detailsContent = case maybeSelectedRobot of + Nothing -> str "No selection" + Just r -> renderRobotDetails rFocusRing r $ robotDialog ^. robotListContent . robotDetailsPaneState + in vBox + [ detailsContent + , tabControlFooter + ] + else renderRobotsList $ robotDialog ^. robotListContent diff --git a/src/swarm-tui/Swarm/TUI/View/Robot/Details.hs b/src/swarm-tui/Swarm/TUI/View/Robot/Details.hs new file mode 100644 index 000000000..77c03028a --- /dev/null +++ b/src/swarm-tui/Swarm/TUI/View/Robot/Details.hs @@ -0,0 +1,76 @@ +-- | +-- SPDX-License-Identifier: BSD-3-Clause +-- +-- Rendering of the "details" pane of the F2 robots dialog +module Swarm.TUI.View.Robot.Details (renderRobotDetails) where + +import Brick +import Brick.Widgets.Border +import Brick.Widgets.Center (hCenter) +import Brick.Widgets.List qualified as BL +import Brick.Widgets.Table qualified as BT + +import Brick.Focus +import Control.Lens hiding (from, (<.>)) +import Data.Map qualified as M +import Prettyprinter (pretty) +import Swarm.Game.Robot +import Swarm.Game.Robot.Activity (commandsHistogram) +import Swarm.Game.Robot.Concrete +import Swarm.Language.Pretty (prettyText) +import Swarm.Log +import Swarm.TUI.Model.Name +import Swarm.TUI.View.Attribute.Attr (boldAttr, cyanAttr) +import Swarm.TUI.View.Robot.Type + +renderRobotDetails :: FocusRing Name -> Robot -> RobotDetailsPaneState -> Widget Name +renderRobotDetails ring r paneState = + vBox + [ str $ + unwords + [ "Selected robot" + , show $ view robotName r + ] + , hBorder + , str " " + , hBox $ + map + hCenter + [ hLimitPercent 70 $ highlightBorderFor RobotLogPane $ borderWithLabel (str "Logs") logsTable + , hLimitPercent 30 $ highlightBorderFor RobotCommandHistogramPane $ borderWithLabel (str "Commands") commandsTable + ] + ] + where + highlightBorderFor n = + if isFocused then overrideAttr borderAttr cyanAttr else id + where + isFocused = focusGetCurrent ring == Just (RobotsListDialog $ SingleRobotDetails n) + + logsTable = withFocusRing ring (BL.renderList mkLogTableEntry) $ paneState ^. logsList + + mkLogTableEntry _isSelected x = + hBox + [ withAttr cyanAttr . str . show . pretty . view leTime $ x + , str ": " + , txt . view leText $ x + ] + + commandsTable = + BT.renderTable + . BT.columnBorders True + . BT.rowBorders False + . BT.surroundingBorder False + . BT.setDefaultColAlignment BT.AlignLeft + . BT.setColAlignment BT.AlignRight 0 + . BT.table + $ map (withAttr boldAttr . str) ["Command", "Count"] : commandHistogramEntries + + mkHistogramEntry (k, v) = + [ txt $ prettyText k + , str $ show v + ] + + commandHistogramEntries = + map mkHistogramEntry $ + M.toList $ + r ^. activityCounts . commandsHistogram diff --git a/src/swarm-tui/Swarm/TUI/View/Robot/Type.hs b/src/swarm-tui/Swarm/TUI/View/Robot/Type.hs new file mode 100644 index 000000000..9bb2dd8c7 --- /dev/null +++ b/src/swarm-tui/Swarm/TUI/View/Robot/Type.hs @@ -0,0 +1,93 @@ +{-# LANGUAGE TemplateHaskell #-} + +-- | +-- SPDX-License-Identifier: BSD-3-Clause +module Swarm.TUI.View.Robot.Type where + +import Brick (Widget) +import Brick.Focus (FocusRing) +import Brick.Widgets.List qualified as BL +import Brick.Widgets.TabularList.Mixed +import Control.Lens hiding (Const, from, (<.>)) +import Data.Sequence (Seq) +import GHC.Generics (Generic) +import Swarm.Game.Robot +import Swarm.Language.Syntax (Const) +import Swarm.Log +import Swarm.TUI.Model.Name + +-- | It is desirable to store the a priori known width of widgets +-- based on their text length or other fixed properties, and +-- use this as the type of the Brick list. +-- +-- We can't defer this width computation to the "draw" function of a cell, +-- because we need access to all cell widths independently +-- outside of the cell draw function. +data WithWidth a = WithWidth + { wWidth :: Int + , wWidget :: a + } + deriving (Functor) + +type WidthWidget = WithWidth (Widget Name) + +-- | For left-aligned cell content, this has the effect +-- of right-padding +increaseWidth :: Int -> WithWidth a -> WithWidth a +increaseWidth extra (WithWidth w x) = WithWidth (w + extra) x + +newtype Widths = Widths + { robotRowWidths :: [ColWidth] + } + deriving (Generic) + +type RobotWidgetRow = RobotRowPayload WidthWidget + +-- | This type is parameterized such that the same +-- collection of fields can specify both +-- cell widgets and column headings +data RobotRow a = RobotRow + { rowID :: a + , rowName :: a + , rowAge :: a + , rowPos :: a + , rowItems :: a + , rowStatus :: a + , rowActns :: a + , rowCmds :: a + , rowCycles :: a + , rowActivity :: a + , rowLog :: a + } + deriving (Functor) + +data RobotRowPayload a = RobotRowPayload + { _robot :: Robot + , _row :: RobotRow a + } + deriving (Functor) + +makeLenses ''RobotRowPayload + +data RobotDetailsPaneState = RobotDetailsPaneState + { _logsList :: BL.GenericList Name Seq LogEntry + , _cmdHistogramList :: BL.List Name (Const, Int) + } + +makeLenses ''RobotDetailsPaneState + +data RobotListContent = RobotListContent + { _robotsListWidget :: MixedTabularList Name RobotWidgetRow Widths + , _robotsListRenderers :: MixedRenderers Name RobotWidgetRow Widths + , _robotDetailsPaneState :: RobotDetailsPaneState + } + +makeLenses ''RobotListContent + +data RobotDisplay = RobotDisplay + { _robotDetailsFocus :: FocusRing Name + , _isDetailsOpened :: Bool + , _robotListContent :: RobotListContent + } + +makeLenses ''RobotDisplay diff --git a/src/swarm-tui/Swarm/TUI/View/Shared.hs b/src/swarm-tui/Swarm/TUI/View/Shared.hs new file mode 100644 index 000000000..d61d87454 --- /dev/null +++ b/src/swarm-tui/Swarm/TUI/View/Shared.hs @@ -0,0 +1,14 @@ +{-# LANGUAGE OverloadedStrings #-} + +-- | +-- SPDX-License-Identifier: BSD-3-Clause +-- +-- UI view components shared across dialogs +module Swarm.TUI.View.Shared where + +import Brick +import Brick.Widgets.Center (hCenter) +import Swarm.TUI.View.Attribute.Attr (italicAttr) + +tabControlFooter :: Widget n +tabControlFooter = hCenter $ withAttr italicAttr $ txt "NOTE: [Tab] toggles focus between panes" diff --git a/src/swarm-tui/Swarm/TUI/View/Structure.hs b/src/swarm-tui/Swarm/TUI/View/Structure.hs index 11a903ee3..d93d5d680 100644 --- a/src/swarm-tui/Swarm/TUI/View/Structure.hs +++ b/src/swarm-tui/Swarm/TUI/View/Structure.hs @@ -36,6 +36,7 @@ import Swarm.TUI.Model.Dialog.Structure import Swarm.TUI.Model.Name import Swarm.TUI.View.Attribute.Attr import Swarm.TUI.View.CellDisplay +import Swarm.TUI.View.Shared (tabControlFooter) import Swarm.TUI.View.Util import Swarm.Util (commaList) @@ -133,10 +134,9 @@ renderStructuresDisplay gs structureDisplay = [ leftSide , padLeft (Pad 2) structureElaboration ] - , footer + , tabControlFooter ] where - footer = hCenter $ withAttr italicAttr $ txt "NOTE: [Tab] toggles focus between panes" lw = _structurePanelListWidget structureDisplay fr = _structurePanelFocus structureDisplay leftSide = diff --git a/src/swarm-tui/Swarm/TUI/View/Util.hs b/src/swarm-tui/Swarm/TUI/View/Util.hs index 63c3fe39e..14bdfbaf0 100644 --- a/src/swarm-tui/Swarm/TUI/View/Util.hs +++ b/src/swarm-tui/Swarm/TUI/View/Util.hs @@ -18,14 +18,12 @@ import Data.Text qualified as T import Graphics.Vty qualified as V import Swarm.Game.Entity as E import Swarm.Game.Land -import Swarm.Game.Location import Swarm.Game.Scenario (scenarioMetadata, scenarioName) import Swarm.Game.ScenarioInfo (scenarioItemName) import Swarm.Game.State import Swarm.Game.State.Landscape import Swarm.Game.State.Substate import Swarm.Game.Terrain -import Swarm.Game.Universe import Swarm.Language.Pretty (prettyTextLine) import Swarm.Language.Syntax (Syntax) import Swarm.Language.Text.Markdown qualified as Markdown @@ -33,8 +31,10 @@ import Swarm.Language.Types (Polytype) import Swarm.TUI.Model import Swarm.TUI.Model.Event (SwarmEvent) import Swarm.TUI.Model.UI +import Swarm.TUI.Model.UI.Gameplay import Swarm.TUI.View.Attribute.Attr import Swarm.TUI.View.CellDisplay +import Swarm.Util (maximum0) import Witch (from, into) -- | Generate a fresh modal window of the requested type. @@ -114,7 +114,7 @@ generateModal s mt = Modal mt (dialog (Just $ str title) buttons (maxModalWindow TerrainPaletteModal -> ("Terrain", Nothing, w) where tm = s ^. gameState . landscape . terrainAndEntities . terrainMap - wordLength = maximum $ map (T.length . getTerrainWord) (M.keys $ terrainByName tm) + wordLength = maximum0 $ map (T.length . getTerrainWord) (M.keys $ terrainByName tm) w = wordLength + 6 EntityPaletteModal -> ("Entity", Nothing, 30) @@ -188,10 +188,6 @@ quitMsg m = "Are you sure you want to " <> quitAction <> "? All progress on this NoMenu -> "quit" _ -> "return to the menu" -locationToString :: Location -> String -locationToString (Location x y) = - unwords $ map show [x, y] - -- | Display a list of text-wrapped paragraphs with one blank line after each. displayParagraphs :: [Text] -> Widget Name displayParagraphs = layoutParagraphs . map txtWrap @@ -256,11 +252,3 @@ bindingText s e = maybe "" ppBindingShort b Binding V.KLeft m | null m -> "←" Binding V.KRight m | null m -> "→" bi -> ppBinding bi - -renderCoordsString :: Cosmic Location -> String -renderCoordsString (Cosmic sw coords) = - unwords $ locationToString coords : suffix - where - suffix = case sw of - DefaultRootSubworld -> [] - SubworldName swName -> ["in", T.unpack swName] diff --git a/src/swarm-util/Swarm/Util.hs b/src/swarm-util/Swarm/Util.hs index 6f4ae556f..dbd4b53c2 100644 --- a/src/swarm-util/Swarm/Util.hs +++ b/src/swarm-util/Swarm/Util.hs @@ -13,6 +13,7 @@ module Swarm.Util ( sortPair, maxOn, maximum0, + maximumNE, enumeratedMap, cycleEnum, enumerateNonEmpty, @@ -29,6 +30,7 @@ module Swarm.Util ( prependList, deleteKeys, applyWhen, + applyJust, hoistMaybe, unsnocNE, @@ -146,6 +148,11 @@ maximum0 :: (Num a, Ord a) => [a] -> a maximum0 [] = 0 maximum0 xs = maximum xs +-- | NOTE: We should be able to just use 'maximum' from "Data.Foldable1" +-- but it is not available for ghc 9.2 and 9.4. +maximumNE :: (Num a, Ord a) => NonEmpty a -> a +maximumNE = maximum + enumeratedMap :: Int -> [a] -> IntMap a enumeratedMap startIdx = IM.fromList . zip [startIdx ..] @@ -273,6 +280,12 @@ applyWhen :: Bool -> (a -> a) -> a -> a applyWhen True f x = f x applyWhen False _ x = x +-- | +-- Equivalent to `fromMaybe id`. +applyJust :: Maybe (a -> a) -> a -> a +applyJust Nothing x = x +applyJust (Just f) x = f x + -- | Convert a 'Maybe' computation to 'MaybeT'. -- -- TODO (#1151): Use implementation from "transformers" package v0.6.0.0 diff --git a/src/swarm-web/Swarm/Web.hs b/src/swarm-web/Swarm/Web.hs index 9150939f5..c2a6d4d53 100644 --- a/src/swarm-web/Swarm/Web.hs +++ b/src/swarm-web/Swarm/Web.hs @@ -81,6 +81,8 @@ import Swarm.TUI.Model hiding (SwarmKeyDispatchers (..)) import Swarm.TUI.Model.Dialog.Goal import Swarm.TUI.Model.Repl (REPLHistItem, replHistory, replSeq) import Swarm.TUI.Model.UI +import Swarm.TUI.Model.UI.Gameplay +import Swarm.Util (applyJust) import Swarm.Util.RingBuffer import Swarm.Web.Worldview import System.Timeout (timeout) @@ -312,9 +314,7 @@ webMain :: webMain baton port appStateRef chan = catch (Warp.runSettings settings app) handleErr where settings = Warp.setPort port $ onReady Warp.defaultSettings - onReady = case baton of - Just mv -> Warp.setBeforeMainLoop $ putMVar mv WebStarted - Nothing -> id + onReady = applyJust $ Warp.setBeforeMainLoop . flip putMVar WebStarted <$> baton server :: Server ToplevelAPI server = diff --git a/swarm.cabal b/swarm.cabal index ba9d40f38..313440bca 100644 --- a/swarm.cabal +++ b/swarm.cabal @@ -152,6 +152,9 @@ common brick common brick-list-skip build-depends: brick-list-skip >=0.1.1.2 && <0.2 +common brick-tabular-list + build-depends: brick-tabular-list >=2.2.0 && <2.2.1 + common bytestring build-depends: bytestring >=0.10 && <0.13 @@ -959,8 +962,10 @@ library swarm-tui base, brick, brick-list-skip, + brick-tabular-list, bytestring, clock, + prettyprinter, colour, containers, extra, @@ -1031,6 +1036,7 @@ library swarm-tui Swarm.TUI.Model.Repl Swarm.TUI.Model.StateUpdate Swarm.TUI.Model.UI + Swarm.TUI.Model.UI.Gameplay Swarm.TUI.Model.WebCommand Swarm.TUI.Panel Swarm.TUI.View @@ -1043,6 +1049,9 @@ library swarm-tui Swarm.TUI.View.Objective Swarm.TUI.View.Popup Swarm.TUI.View.Robot + Swarm.TUI.View.Robot.Details + Swarm.TUI.View.Robot.Type + Swarm.TUI.View.Shared Swarm.TUI.View.Structure Swarm.TUI.View.Util