packages feed

swarm-0.7.0.0: src/swarm-tui/Swarm/TUI/View/Robot.hs

{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE OverloadedRecordDot #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE NoGeneralizedNewtypeDeriving #-}

-- |
-- SPDX-License-Identifier: BSD-3-Clause
--
-- A UI-centric model for presentation of Robot details.
--
-- It stores robot IDs to identify rows, which makes
-- it relatively light-weight.
module Swarm.TUI.View.Robot (
  emptyRobotDisplay,
  updateRobotList,
  robotGridRenderers,
  drawRobotsDisplayModal,
  getSelectedRobot,
) where

import Brick
import Brick.Focus
import Brick.Widgets.Border
import Brick.Widgets.Center
import Brick.Widgets.List qualified as BL
import Brick.Widgets.TabularList.Grid qualified as BL
import Brick.Widgets.TabularList.Mixed
import Control.Lens as Lens hiding (Const, from)
import Data.IntMap qualified as IM
import Data.List.Extra (dropPrefix, enumerate)
import Data.Map qualified as M
import Data.Maybe (fromMaybe)
import Data.Sequence (Seq)
import Data.Sequence qualified as S
import Data.Set (Set)
import Data.Set qualified as Set
import Data.Text (Text)
import Data.Text qualified as T
import Linear (V2, distance)
import Numeric (showFFloat)
import Swarm.Game.CESK (CESK (..))
import Swarm.Game.Entity as E
import Swarm.Game.Location (Point, origin)
import Swarm.Game.Robot
import Swarm.Game.Robot.Activity
import Swarm.Game.Robot.Concrete
import Swarm.Game.State
import Swarm.Game.State.Robot
import Swarm.Game.State.Substate
import Swarm.Game.Tick (addTicks)
import Swarm.Game.Universe
import Swarm.Game.World.Coords
import Swarm.TUI.Model.DebugOption (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.Robot.Details
import Swarm.TUI.View.Robot.Type
import Swarm.TUI.View.Shared (tabControlFooter)
import Swarm.Util (applyWhen, maximum0)
import Swarm.Util.UnitInterval
import Swarm.Util.WindowedCounter qualified as WC
import System.Clock (TimeSpec (..))

{--------------------------------------------------------------------
NEW GRID LIST
--------------------------------------------------------------------}

-- | The columns in the Robot modal grid.
data RobotColumn
  = ColName
  | ColAge
  | ColPos
  | ColItems
  | ColStatus
  | ColActns
  | ColCmds
  | ColCycles
  | ColActivity
  | ColLog
  | -- | The ID is the only optional field.
    --   It is shown last to make indexing code easier.
    ColID
  deriving (Eq, Ord, Enum, Bounded, Show)

colName :: RobotColumn -> Text
colName = T.pack . dropPrefix "Col" . show

colWidth :: RobotColumn -> Int
colWidth = textWidth . colName

colWidths :: Set DebugOption -> Seq ColWidth
colWidths opt = ColW . getWidth <$> S.fromList robotColumns
 where
  showIDs = Set.member ListRobotIDs opt
  robotColumns :: [RobotColumn]
  robotColumns = (if showIDs then id else filter (/= ColID)) enumerate
  getWidth = \case
    ColName -> 26 + (if showIDs then 0 else getWidth ColID)
    ColAge -> 8
    ColPos -> 9
    ColStatus -> 10
    ColID -> 5
    c -> 1 + colWidth c

{--------------------------------------------------------------------
EMPTY
--------------------------------------------------------------------}

-- | Initial empty robot modal.
emptyRobotDisplay :: Set DebugOption -> RobotDisplay
emptyRobotDisplay opt =
  RobotDisplay
    { _isDetailsOpened = False
    , -- we have to select the fixed width columns at start - if they change, the whole list has to be replaced
      _robotsGridList = BL.gridTabularList (RobotsListDialog RobotList) mempty (LstItmH 1) (colWidths opt)
    , _robotDetailsPaneState =
        RobotDetailsPaneState
          { _detailFocus = focusRing $ map (RobotsListDialog . SingleRobotDetails) enumerate
          , _logsList = BL.list (RobotsListDialog $ SingleRobotDetails RobotLogPane) mempty 1
          , _cmdHistogramList = BL.list (RobotsListDialog $ SingleRobotDetails RobotCommandHistogramPane) mempty 1
          }
    }

{--------------------------------------------------------------------
GET SELECTED
--------------------------------------------------------------------}

getSelectedRID :: BL.GridTabularList Name RID -> Maybe RID
getSelectedRID gl = snd <$> BL.listSelectedElement gl.list

-- | Get the robot selected in the robot list.
getSelectedRobot :: GameState -> BL.GridTabularList Name RID -> Maybe Robot
getSelectedRobot g gl = do
  rid <- getSelectedRID gl
  g ^. robotInfo . robotMap . at rid

{--------------------------------------------------------------------
UPDATE
--------------------------------------------------------------------}

-- | Update robot modal grid list contents.
--
-- To prevent memory leaks, this only stores robot IDs.
updateRobotList :: Set DebugOption -> GameState -> BL.GridTabularList Name RID -> BL.GridTabularList Name RID
updateRobotList dOpts g l = l {BL.list = updatedList}
 where
  updatedList :: BL.GenericList Name Seq RID
  updatedList = BL.listReplace rids sel l.list
  rids :: Seq RID
  rids = S.fromList . fmap (view robotID) $ robots
  sel :: Maybe Int
  sel = flip S.elemIndexL rids =<< getSelectedRID l

  robots :: [Robot]
  robots = g ^. robotInfo . robotMap . to IM.elems . to filterRobots
  filterRobots :: [Robot] -> [Robot]
  filterRobots = if Set.member ListAllRobots dOpts then id else filter (\r -> isRelevant r && isNear r)
  basePos :: Point V2 Double
  basePos = realToFrac <$> fromMaybe origin (g ^? baseRobot . robotLocation . planar)
  -- Keep the base and non system robot (e.g. no seed)
  isRelevant r = r ^. robotID == 0 || not (r ^. systemRobot)
  -- Keep the robot that are less than 32 unit away from the base
  isNear r = creative || distance (realToFrac <$> r ^. robotLocation . planar) basePos < 32
  creative = g ^. creativeMode

{--------------------------------------------------------------------
DRAW
--------------------------------------------------------------------}

columnHdrAttr :: AttrName
columnHdrAttr = attrName "columnHeader"

rowHdrAttr :: AttrName
rowHdrAttr = attrName "rowHeader"

-- | Draw robot modal.
--
-- It either shows a list of robots information or details of selected robot.
drawRobotsDisplayModal :: UIGameplay -> GameState -> RobotDisplay -> Widget Name
drawRobotsDisplayModal t g robDisplay =
  if robDisplay ^. isDetailsOpened
    then robDetail
    else robList
 where
  robDetail :: Widget Name
  robDetail =
    let detailsContent = case getSelectedRobot g (robDisplay ^. robotsGridList) of
          Nothing -> str "No selection"
          Just r -> renderRobotDetails r $ robDisplay ^. robotDetailsPaneState
     in vBox
          [ detailsContent
          , tabControlFooter
          ]
  robList :: Widget Name
  robList = drawRobotsList t g $ robDisplay ^. robotsGridList

drawRobotsList :: UIGameplay -> GameState -> BL.GridTabularList Name RID -> Widget Name
drawRobotsList t g = vLimit 30 . BL.renderGridTabularList (robotGridRenderers t g) (LstFcs True)

-- | Render robot modal grid - column and row headers as well as cells based on robot ID.
robotGridRenderers :: UIGameplay -> GameState -> BL.GridRenderers Name RID
robotGridRenderers t g =
  BL.GridRenderers
    { BL.cell = drawRobotGridCell t g
    , BL.rowHdr = Just rowHdr
    , BL.colHdr = Just colHdr
    , BL.colHdrRowHdr = Just colRowHdr
    }

-- | Enumerates the rows by position (just row index, not 'RID').
rowHdr :: RowHdr Name a
rowHdr =
  RowHdr
    { draw = \_ (WdthD wd) (RowHdrCtxt (Sel s)) rh ->
        let attrFn = applyWhen (not s) $ 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
    }

colHdr :: BL.GridColHdr Name
colHdr =
  BL.GridColHdr
    { draw =
        \_ (WdthD widthDef) (BL.GColC (BL.Ix i) (BL.Sel _sel)) ->
          let colGap = padLeft (Pad $ if widthDef > 0 then 0 else 1)
           in withAttr columnHdrAttr (colGap . txt . colName $ toEnum i) <=> hBorder
    , height = ColHdrH 2
    }

colRowHdr :: BL.ColHdrRowHdr Name
colRowHdr = BL.ColHdrRowHdr $ \_ (WdthD _wd) -> fill ' ' <=> hBorder

drawRobotGridCell :: UIGameplay -> GameState -> ListFocused -> WidthDeficit -> BL.GridCtxt -> RID -> Widget Name
drawRobotGridCell t g _foc (WdthD widthDef) ctx rid =
  colGap . withSelectedAttr $
    case g ^. robotInfo . robotMap . at rid of
      -- this would be a synchronisation error, but crashing the game is not worth it
      Nothing -> case col of
        ColID -> padRight Max $ showW rid
        _ -> fill '?'
      Just r -> case col of
        ColName -> padRight Max $ nameWidget r
        ColAge -> hCenter $ ageWidget r
        ColPos -> hCenter $ locWidget r
        ColItems -> padLeft Max $ rInvCount r
        ColStatus -> hCenter $ statusWidget r
        ColActns -> padLeft Max . showW $ r ^. activityCounts . tangibleCommandCount
        ColCmds -> padLeft Max . showW . sum . M.elems $ r ^. activityCounts . commandsHistogram
        ColCycles -> padLeft Max . showW $ r ^. activityCounts . lifetimeStepCount
        ColActivity -> padLeft Max $ renderDutyCycle (g ^. temporal) r
        ColLog -> hCenter $ rLog r
        ColID -> padRight Max $ showW rid
 where
  (BL.GColC (BL.Ix cIx) (BL.Sel cSel)) = ctx.col
  (BL.GRowC (BL.Ix _ix) (BL.Sel rSel)) = ctx.row
  withSelectedAttr = if cSel && rSel then withAttr BL.listSelectedAttr else id
  col :: RobotColumn
  col = if cIx <= fromEnum (maxBound :: RobotColumn) then toEnum cIx else ColLog

  showW :: Show a => a -> Widget Name
  showW = str . show
  highlightSystem :: Robot -> Widget Name -> Widget Name
  highlightSystem r = applyWhen (r ^. systemRobot) $ withAttr highlightAttr
  colGap = padLeft (Pad $ if widthDef > 0 then 0 else 1)

  nameWidget :: Robot -> Widget Name
  nameWidget r =
    hBox
      [ renderDisplay (r ^. robotDisplay)
      , highlightSystem r . txt $ " " <> r ^. robotName
      ]

  ageWidget :: Robot -> Widget Name
  ageWidget r = str ageStr
   where
    TimeSpec createdAtSec _ = r ^. robotCreatedAt
    TimeSpec nowSec _ = t ^. uiTiming . lastFrameTime
    age = nowSec - createdAtSec
    ageStr
      | age < 60 = show age <> "sec"
      | age < 3600 = show (age `div` 60) <> "min"
      | age < 3600 * 24 = show (age `div` 3600) <> "hour"
      | otherwise = show (age `div` 3600 * 24) <> "day"

  rInvCount :: Robot -> Widget Name
  rInvCount r = showW . sum . map fst . E.elems $ r ^. robotEntity . entityInventory

  rLog :: Robot -> Widget Name
  rLog r = str $ if r ^. robotLogUpdated then "x" else " "

  locWidget :: Robot -> Widget Name
  locWidget r = hBox [worldCell, str $ " " <> locStr]
   where
    rCoords = fmap locToCoords rLoc
    rLoc = r ^. robotLocation
    worldCell = drawLoc t g rCoords
    locStr = renderCoordsString rLoc

  statusWidget :: Robot -> Widget Name
  statusWidget r = case r ^. machine of
    Waiting {} -> str "waiting"
    _ | isActive r -> withAttr notifAttr $ str "busy"
    _ | otherwise -> withAttr greenAttr $ str "idle"

-- | 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.
--
-- == Use of previous tick
-- The 'Swarm.Game.Step.gameTick' function runs all robots, then increments the current tick.
-- So at the time we are rendering a frame, the current tick will always be
-- strictly greater than any ticks stored in the 'WC.WindowedCounter' for any robot;
-- 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 :: TemporalState -> Robot -> Widget Name
renderDutyCycle temporalState r = withAttr dutyCycleAttr $ str tx
 where
  tx = showFFloat (Just 1) dutyCyclePercentage "%"

  curTicks = temporalState ^. ticks
  window = r ^. activityCounts . activityWindow

  -- Rewind to previous tick
  latestRobotTick = addTicks (-1) curTicks
  dutyCycleRatio = WC.getOccupancy latestRobotTick window

  dutyCycleAttr = safeIndex dutyCycleRatio meterAttributeNames

  dutyCyclePercentage :: Double
  dutyCyclePercentage = 100 * getValue dutyCycleRatio