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/app/game/Swarm/App.hs b/app/game/Swarm/App.hs index ad4b4974c..c7edf9a6f 100644 --- a/app/game/Swarm/App.hs +++ b/app/game/Swarm/App.hs @@ -38,6 +38,7 @@ import Swarm.Language.Pretty (prettyText) import Swarm.Log (LogSource (SystemLog), Severity (..)) import Swarm.TUI.Controller import Swarm.TUI.Model +import Swarm.TUI.Model.Name import Swarm.TUI.Model.StateUpdate import Swarm.TUI.Model.UI (uiAttrMap) import Swarm.TUI.View 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-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..cb6a1c5f6 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,10 @@ import Swarm.TUI.Model.Name import Swarm.TUI.Model.Repl import Swarm.TUI.Model.StateUpdate import Swarm.TUI.Model.UI +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] -- @@ -418,6 +421,20 @@ 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 + foc <- use robotDetailsFocus + case focusGetCurrent foc of + (Just (RobotsListDialog (SingleRobotDetails RobotLogPane))) -> + Brick.zoom (robotListContent . robotDetailsPaneState . logsList) $ handleListEvent ev + _ -> 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.hs b/src/swarm-tui/Swarm/TUI/Controller/EventHandlers.hs index f35e99aeb..8d875c02d 100644 --- a/src/swarm-tui/Swarm/TUI/Controller/EventHandlers.hs +++ b/src/swarm-tui/Swarm/TUI/Controller/EventHandlers.hs @@ -46,6 +46,7 @@ import Swarm.TUI.Controller.EventHandlers.Robot (handleRobotPanelEvent, robotEve import Swarm.TUI.Controller.EventHandlers.World (worldEventHandlers) import Swarm.TUI.Model import Swarm.TUI.Model.Event (SwarmEvent, swarmEvents) +import Swarm.TUI.Model.Name import Swarm.Util (parens, squote) -- ~~~~ Note [how Swarm event handlers work] diff --git a/src/swarm-tui/Swarm/TUI/Controller/EventHandlers/Frame.hs b/src/swarm-tui/Swarm/TUI/Controller/EventHandlers/Frame.hs index 73cc9b86e..e52d2a6ac 100644 --- a/src/swarm-tui/Swarm/TUI/Controller/EventHandlers/Frame.hs +++ b/src/swarm-tui/Swarm/TUI/Controller/EventHandlers/Frame.hs @@ -26,6 +26,7 @@ import Swarm.TUI.Controller.UpdateUI import Swarm.TUI.Controller.Util import Swarm.TUI.Model import Swarm.TUI.Model.Achievements (popupAchievement) +import Swarm.TUI.Model.Name import Swarm.TUI.Model.UI import System.Clock diff --git a/src/swarm-tui/Swarm/TUI/Controller/EventHandlers/Main.hs b/src/swarm-tui/Swarm/TUI/Controller/EventHandlers/Main.hs index 7bf59d637..ada85975b 100644 --- a/src/swarm-tui/Swarm/TUI/Controller/EventHandlers/Main.hs +++ b/src/swarm-tui/Swarm/TUI/Controller/EventHandlers/Main.hs @@ -26,6 +26,7 @@ import Swarm.TUI.Model import Swarm.TUI.Model.DebugOption (DebugOption (ToggleCreative, ToggleWorldEditor)) import Swarm.TUI.Model.Dialog.Goal import Swarm.TUI.Model.Event (MainEvent (..), SwarmEvent (..)) +import Swarm.TUI.Model.Name import Swarm.TUI.Model.UI import System.Clock (Clock (..), TimeSpec (..), getTime) diff --git a/src/swarm-tui/Swarm/TUI/Controller/EventHandlers/REPL.hs b/src/swarm-tui/Swarm/TUI/Controller/EventHandlers/REPL.hs index b6b28c5b9..275d920c0 100644 --- a/src/swarm-tui/Swarm/TUI/Controller/EventHandlers/REPL.hs +++ b/src/swarm-tui/Swarm/TUI/Controller/EventHandlers/REPL.hs @@ -21,6 +21,7 @@ import Swarm.Game.State.Substate import Swarm.TUI.Controller.Util import Swarm.TUI.Model import Swarm.TUI.Model.Event +import Swarm.TUI.Model.Name import Swarm.TUI.Model.Repl import Swarm.TUI.Model.UI diff --git a/src/swarm-tui/Swarm/TUI/Controller/EventHandlers/Robot.hs b/src/swarm-tui/Swarm/TUI/Controller/EventHandlers/Robot.hs index b2a0bcefb..c6c456518 100644 --- a/src/swarm-tui/Swarm/TUI/Controller/EventHandlers/Robot.hs +++ b/src/swarm-tui/Swarm/TUI/Controller/EventHandlers/Robot.hs @@ -31,6 +31,7 @@ import Swarm.TUI.Inventory.Sorting (cycleSortDirection, cycleSortOrder) import Swarm.TUI.List import Swarm.TUI.Model import Swarm.TUI.Model.Event +import Swarm.TUI.Model.Name import Swarm.TUI.Model.UI import Swarm.TUI.View.Util (generateModal) diff --git a/src/swarm-tui/Swarm/TUI/Controller/EventHandlers/World.hs b/src/swarm-tui/Swarm/TUI/Controller/EventHandlers/World.hs index b4dbe921a..aa4968aaf 100644 --- a/src/swarm-tui/Swarm/TUI/Controller/EventHandlers/World.hs +++ b/src/swarm-tui/Swarm/TUI/Controller/EventHandlers/World.hs @@ -22,6 +22,7 @@ import Swarm.Language.Syntax.Direction (Direction (..), directionSyntax) import Swarm.TUI.Controller.Util import Swarm.TUI.Model import Swarm.TUI.Model.Event +import Swarm.TUI.Model.Name import Swarm.TUI.Model.UI -- | Handle a user input event in the world view panel. diff --git a/src/swarm-tui/Swarm/TUI/Controller/UpdateUI.hs b/src/swarm-tui/Swarm/TUI/Controller/UpdateUI.hs index a2e30d42b..452079a01 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 @@ -43,6 +47,9 @@ import Swarm.TUI.Model.Name import Swarm.TUI.Model.Repl import Swarm.TUI.Model.UI 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 +172,8 @@ updateUI = do newPopups <- generateNotificationPopups + doRobotListUpdate g + let redraw = g ^. needsRedraw || inventoryUpdated @@ -174,6 +183,36 @@ 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 + + maybeModificationFunc = + updateList . BL.listFindBy . ((==) `on` view (rob . 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 ^. rob . activityCounts . commandsHistogram)) + robotDetailsPaneState . logsList . BL.listElementsL .= robotPayload ^. rob . 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..2f257d705 100644 --- a/src/swarm-tui/Swarm/TUI/Controller/Util.hs +++ b/src/swarm-tui/Swarm/TUI/Controller/Util.hs @@ -35,13 +35,12 @@ 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.View.Util (generateModal) 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..0a9e5b986 100644 --- a/src/swarm-tui/Swarm/TUI/Editor/View.hs +++ b/src/swarm-tui/Swarm/TUI/Editor/View.hs @@ -118,7 +118,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..dddceaaf4 100644 --- a/src/swarm-tui/Swarm/TUI/Model.hs +++ b/src/swarm-tui/Swarm/TUI/Model.hs @@ -13,7 +13,6 @@ module Swarm.TUI.Model ( -- $uilabel AppEvent (..), FocusablePanel (..), - Name (..), -- ** Web command WebCommand (..), diff --git a/src/swarm-tui/Swarm/TUI/Model/KeyBindings.hs b/src/swarm-tui/Swarm/TUI/Model/KeyBindings.hs index dc47c69c5..8c48523a4 100644 --- a/src/swarm-tui/Swarm/TUI/Model/KeyBindings.hs +++ b/src/swarm-tui/Swarm/TUI/Model/KeyBindings.hs @@ -28,6 +28,7 @@ import Swarm.Language.Pretty (prettyText) import Swarm.TUI.Controller.EventHandlers import Swarm.TUI.Model import Swarm.TUI.Model.Event (SwarmEvent, defaultSwarmBindings, swarmEvents) +import Swarm.TUI.Model.Name -- See Note [how Swarm event handlers work] 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/UI.hs b/src/swarm-tui/Swarm/TUI/Model/UI.hs index 72ef5d12f..47ff02492 100644 --- a/src/swarm-tui/Swarm/TUI/Model/UI.hs +++ b/src/swarm-tui/Swarm/TUI/Model/UI.hs @@ -31,6 +31,7 @@ module Swarm.TUI.Model.UI ( uiModal, uiGoal, uiStructure, + uiRobot, uiDialogs, uiIsAutoPlay, uiAutoShowObjectives, @@ -63,29 +64,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 +88,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 +209,11 @@ initUIState UIInitOptions {..} = do { _uiModal = Nothing , _uiGoal = emptyGoalDisplay , _uiStructure = emptyStructureDisplay + , _uiRobot = + RobotDisplay + { _robotDetailsFocus = focusRing $ map RobotsListDialog $ RobotList : map SingleRobotDetails enumerate + , _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..820199d00 --- /dev/null +++ b/src/swarm-tui/Swarm/TUI/Model/UI/Gameplay.hs @@ -0,0 +1,226 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} +{-# LANGUAGE RecordWildCards #-} +{-# LANGUAGE TemplateHaskell #-} +{-# LANGUAGE ViewPatterns #-} + +-- | +-- SPDX-License-Identifier: BSD-3-Clause +module Swarm.TUI.Model.UI.Gameplay 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 3201fc56c..ebe87c853 100644 --- a/src/swarm-tui/Swarm/TUI/View.hs +++ b/src/swarm-tui/Swarm/TUI/View.hs @@ -129,6 +129,7 @@ import Swarm.TUI.Model.DebugOption (DebugOption (..)) import Swarm.TUI.Model.Dialog.Goal (goalsContent, hasAnythingToShow) import Swarm.TUI.Model.Event qualified as SE import Swarm.TUI.Model.KeyBindings (handlerNameKeysDescription) +import Swarm.TUI.Model.Name import Swarm.TUI.Model.Repl import Swarm.TUI.Model.UI import Swarm.TUI.Panel @@ -139,6 +140,8 @@ import Swarm.TUI.View.Logo import Swarm.TUI.View.Objective qualified as GR import Swarm.TUI.View.Popup import Swarm.TUI.View.Robot +import Swarm.TUI.View.Robot.Details +import Swarm.TUI.View.Robot.Type import Swarm.TUI.View.Structure qualified as SR import Swarm.TUI.View.Util as VU import Swarm.Util @@ -377,7 +380,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 @@ -606,6 +609,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 @@ -613,7 +617,17 @@ 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 -> case focusGetCurrent rFocusRing of + Just (RobotsListDialog (SingleRobotDetails _)) -> case maybeSelectedRobot of + Nothing -> str "No selection" + Just r -> renderRobotDetails rFocusRing r $ robotDialog ^. robotListContent . robotDetailsPaneState + where + oldList = getList $ robotDialog ^. robotListContent . robotsListWidget + maybeSelectedRobot = view rob . snd <$> BL.listSelectedElement oldList + _ -> renderRobotsList $ robotDialog ^. robotListContent + where + robotDialog = s ^. uiState . uiGameplay . uiDialogs . uiRobot + rFocusRing = robotDialog ^. robotDetailsFocus RecipesModal -> availableListWidget (s ^. gameState) RecipeList CommandsModal -> commandsListWidget (s ^. gameState) MessagesModal -> availableListWidget (s ^. gameState) MessageList @@ -689,7 +703,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 diff --git a/src/swarm-tui/Swarm/TUI/View/Achievement.hs b/src/swarm-tui/Swarm/TUI/View/Achievement.hs index 47f4a9b6a..cd1765516 100644 --- a/src/swarm-tui/Swarm/TUI/View/Achievement.hs +++ b/src/swarm-tui/Swarm/TUI/View/Achievement.hs @@ -16,6 +16,7 @@ import Swarm.Game.Achievement.Attainment import Swarm.Game.Achievement.Definitions import Swarm.Game.Achievement.Description import Swarm.TUI.Model +import Swarm.TUI.Model.Name import Swarm.TUI.Model.UI import Swarm.TUI.View.Attribute.Attr import Swarm.TUI.View.Util (drawMarkdown) 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..ebbb806e3 100644 --- a/src/swarm-tui/Swarm/TUI/View/Robot.hs +++ b/src/swarm-tui/Swarm/TUI/View/Robot.hs @@ -1,5 +1,8 @@ +{-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE NoGeneralizedNewtypeDeriving #-} +{-# OPTIONS_GHC -fno-warn-orphans #-} -- | -- SPDX-License-Identifier: BSD-3-Clause @@ -7,14 +10,26 @@ -- A UI-centric model for presentation of Robot details. module Swarm.TUI.View.Robot 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.Foldable1 qualified as F1 (maximum) 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 +43,223 @@ 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.Type +import Swarm.Util (applyWhen, maximum0) import Swarm.Util.UnitInterval import Swarm.Util.WindowedCounter qualified as WC import System.Clock (TimeSpec (..)) +extractColWidth :: ColWidth -> Int +extractColWidth (ColW x) = x + +instance Semigroup ColWidth where + 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 (<>) 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 + } + +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 = + LibRobotRow + { _fID = Col "ID" Minimal + , _fName = Col "Name" Grow + , _fAge = Col "Age" Minimal + , _fPos = Col "Pos" Minimal + , _fItems = Col "Items" Minimal + , _fStatus = Col "Status" Minimal + , _fActns = Col "Actns" Minimal + , _fCmds = Col "Cmds" Minimal + , _fCycles = Col "Cycles" Minimal + , _fActivity = Col "Activity" Grow + , _fLog = Col "Log" Minimal + } + +colHdrs :: Set DebugOption -> Vector String +colHdrs = + V.fromList + . NE.toList + . NE.map headingString + . getColumnAttrList + +getAccessorList :: Set DebugOption -> NonEmpty (LibRobotRow a -> a) +getAccessorList dbgOptions = + applyWhen debugRID (NE.cons _fID) mainListSuffix + where + debugRID = dbgOptions ^. Lens.contains ListRobotIDs + + mainListSuffix = + _fName + :| [ _fAge + , _fPos + , _fItems + , _fStatus + , _fActns + , _fCmds + , _fCycles + , _fActivity + , _fLog + ] + +drawCell :: Set DebugOption -> ListFocused -> MixedCtxt -> RobotWidgetRow -> Widget Name +drawCell uiDebug _ (MxdCtxt _ (MColC (Ix ci))) r = + maybe emptyWidget (renderPlainCell . wWidget . ($ view rPayload 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, the widest visible cell content, +-- and 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) . F1.maximum) + . NE.transpose + . (colHeaderRowLengths `NE.cons`) + . NE.map getColWidthsForRow + where + getColWidthsForRow :: RobotWidgetRow -> NonEmpty Int + getColWidthsForRow r = NE.map (wWidth . ($ view rPayload 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 = WidthPrecompute (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,11 +271,13 @@ 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 robot = + withAttr dutyCycleAttr <$> strWidget tx where - curTicks = gs ^. temporal . ticks + tx = showFFloat (Just 1) dutyCyclePercentage "%" + + curTicks = temporalState ^. ticks window = robot ^. activityCounts . activityWindow -- Rewind to previous tick @@ -66,55 +289,34 @@ 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 + RobotRowPayload robot $ + LibRobotRow + { _fID = strWidget $ show $ robot ^. robotID + , _fName = nameWidget + , _fAge = strWidget ageStr + , _fPos = locWidget + , _fItems = padWidth 1 $ strWidget $ show rInvCount + , _fStatus = statusWidget + , _fActns = strWidget $ show $ robot ^. activityCounts . tangibleCommandCount + , _fCmds = strWidget $ show . sum . M.elems $ robot ^. activityCounts . commandsHistogram + , _fCycles = strWidget $ show $ robot ^. activityCounts . lifetimeStepCount + , _fActivity = renderDutyCycle (c ^. mygs . temporal) robot + , _fLog = 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 - ] + nameWidget = WidthPrecompute (2 + T.length nameTxt) w + where + w = + hBox + [ renderDisplay (robot ^. robotDisplay) + , highlightSystem . txt $ " " <> nameTxt + ] + nameTxt = robot ^. robotName highlightSystem = if robot ^. systemRobot then withAttr highlightAttr else id @@ -125,30 +327,32 @@ robotsListWidget s = hCenter table | otherwise = show (age `div` 3600 * 24) <> "day" where TimeSpec createdAtSec _ = robot ^. robotCreatedAt - TimeSpec nowSec _ = s ^. uiState . uiGameplay . uiTiming . lastFrameTime + TimeSpec nowSec _ = c ^. timing . lastFrameTime age = nowSec - createdAtSec rInvCount = sum $ map fst . E.elems $ robot ^. robotEntity . entityInventory rLog - | robot ^. robotLogUpdated = "x" - | otherwise = " " + | robot ^. robotLogUpdated = 'x' + | otherwise = ' ' - locWidget = hBox [worldCell, str $ " " <> locStr] + locWidget = + WidthPrecompute (2 + length locStr) w where + w = hBox [worldCell, str $ " " <> locStr] rCoords = fmap locToCoords rLoc rLoc = robot ^. robotLocation worldCell = drawLoc - (s ^. uiState . uiGameplay) + (c ^. gameplay) g rCoords locStr = renderCoordsString rLoc statusWidget = case robot ^. machine of - Waiting {} -> txt "waiting" + Waiting {} -> strWidget "waiting" _ - | isActive robot -> withAttr notifAttr $ txt "busy" - | otherwise -> withAttr greenAttr $ txt "idle" + | isActive robot -> withAttr notifAttr <$> strWidget "busy" + | otherwise -> withAttr greenAttr <$> strWidget "idle" basePos :: Point V2 Double basePos = realToFrac <$> fromMaybe origin (g ^? baseRobot . robotLocation . planar) @@ -162,6 +366,5 @@ robotsListWidget s = hCenter table . 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 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..88f75e697 --- /dev/null +++ b/src/swarm-tui/Swarm/TUI/View/Robot/Details.hs @@ -0,0 +1,74 @@ +-- | +-- SPDX-License-Identifier: BSD-3-Clause +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 robot paneState = + vBox + [ str $ + unwords + [ "Selected robot" + , show $ view robotName robot + ] + , 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 $ + robot ^. 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..3aa51480d --- /dev/null +++ b/src/swarm-tui/Swarm/TUI/View/Robot/Type.hs @@ -0,0 +1,90 @@ +{-# 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 WidthPrecompute a = WidthPrecompute + { wWidth :: Int + , wWidget :: a + } + deriving (Functor) + +type WidthWidget = WidthPrecompute (Widget Name) + +padWidth :: Int -> WidthPrecompute a -> WidthPrecompute a +padWidth extra (WidthPrecompute w x) = WidthPrecompute (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 LibRobotRow a = LibRobotRow + { _fID :: a + , _fName :: a + , _fAge :: a + , _fPos :: a + , _fItems :: a + , _fStatus :: a + , _fActns :: a + , _fCmds :: a + , _fCycles :: a + , _fActivity :: a + , _fLog :: a + } + deriving (Functor) + +data RobotRowPayload a = RobotRowPayload + { _rob :: Robot + , _rPayload :: LibRobotRow 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 + , _robotListContent :: RobotListContent + } + +makeLenses ''RobotDisplay diff --git a/src/swarm-tui/Swarm/TUI/View/Util.hs b/src/swarm-tui/Swarm/TUI/View/Util.hs index 63c3fe39e..87bdc4a9e 100644 --- a/src/swarm-tui/Swarm/TUI/View/Util.hs +++ b/src/swarm-tui/Swarm/TUI/View/Util.hs @@ -18,23 +18,23 @@ 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 import Swarm.Language.Types (Polytype) import Swarm.TUI.Model import Swarm.TUI.Model.Event (SwarmEvent) +import Swarm.TUI.Model.Name import Swarm.TUI.Model.UI 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..492ee3787 100644 --- a/src/swarm-util/Swarm/Util.hs +++ b/src/swarm-util/Swarm/Util.hs @@ -29,6 +29,7 @@ module Swarm.Util ( prependList, deleteKeys, applyWhen, + applyJust, hoistMaybe, unsnocNE, @@ -273,6 +274,10 @@ applyWhen :: Bool -> (a -> a) -> a -> a applyWhen True f x = f x applyWhen False _ x = x +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/swarm.cabal b/swarm.cabal index ba9d40f38..fab381f95 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,8 @@ 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.Structure Swarm.TUI.View.Util