packages feed

cabal-matrix-1.0.1.0: src/Cabal/Matrix/Tui.hs

module Cabal.Matrix.Tui
  ( tuiLive
  , tuiRecording
  ) where

import Cabal.Matrix.CabalArgs
import Cabal.Matrix.Cli
import Cabal.Matrix.Matrix
import Cabal.Matrix.Record
import Cabal.Matrix.Rectangle (Rectangle)
import Cabal.Matrix.Rectangle qualified as Rectangle
import Cabal.Matrix.Scheduler
import Cabal.Matrix.Tui.Common
import Cabal.Matrix.Tui.Flavor
import Cabal.Matrix.Tui.Headers
import Cabal.Matrix.Tui.Table
import Control.Concurrent
import Control.Concurrent.STM
import Control.Monad
import Data.Foldable
import Data.IntMap.Strict (IntMap)
import Data.IntMap.Strict qualified as IntMap
import Data.List
import Data.List.NonEmpty (NonEmpty)
import Data.Map.Strict qualified as Map
import Data.Maybe
import Data.Primitive
import Data.Set qualified as Set
import Data.Text (Text)
import Graphics.Vty
import Graphics.Vty.CrossPlatform


data AppState = AppState
  { headers :: HeaderState
  , headerEditor :: Maybe HeaderEditorState
  , flavorStates :: IntMap FlavorState
  , openedCell :: Maybe (FlavorIndex, OutputState)
  , layout :: TableLayout
  , table :: TableState
  }

type TuiMatrix = Rectangle (PerCabalStep (NonEmpty Text)) Text (Maybe Text)

initAppState :: TuiMatrix -> AppState
initAppState matrix = AppState
  { headers
  , headerEditor = Nothing
  , flavorStates = IntMap.fromList
    $ zip [0..] $ map initFlavorState $ toList $ Rectangle.rows matrix
  , openedCell = Nothing
  , layout = mkTableLayout headers
  , table = initTableState
  }
  where
    !numColumns = sizeofArray $ Rectangle.columns matrix
    !vertical = arrayFromListN numColumns
      [i * 2 >= numColumns | i <- [0 .. numColumns - 1]]
    !headers = mkHeaderState matrix vertical

mkTableMeta :: HeaderState -> TableMeta
mkTableMeta headers = TableMeta
  { frozenRows = sizeofArray $ Rectangle.columns headers.horizontalHeader
  , normalRows = sizeofArray $ Rectangle.rows headers.verticalHeader
  , frozenColumns = sizeofArray $ Rectangle.columns headers.verticalHeader
  , normalColumns = sizeofArray $ Rectangle.rows headers.horizontalHeader
  }

mkTableHeaders :: HeaderState -> TableHeaders
mkTableHeaders headers = TableHeaders
  { frozenRowHeader = indexArray $ Rectangle.columns headers.horizontalHeader
  , frozenRowCell = \x y -> Rectangle.indexCell headers.horizontalHeader y x
  , frozenColumnHeader = indexArray $ Rectangle.columns headers.verticalHeader
  , frozenColumnCell = \x y -> Rectangle.indexCell headers.verticalHeader x y
  }

mkTableLayout :: HeaderState -> TableLayout
mkTableLayout headers
  = tableLayout (mkTableMeta headers) (mkTableHeaders headers)

appWidget
  :: DisplayRegion
  -> TuiMatrix
  -> PerCabalStep Bool
  -> AppState
  -> (AppState, Image)
appWidget (width, height) matrix enabledSteps ast
  = (ast', contents <-> keybinds)
  where
    (ast', contents)
      | Just (flavorIndex, os) <- ast.openedCell
      , Just fs <- IntMap.lookup flavorIndex ast.flavorStates
      = (ast,) $ outputWidget (width, height - 1) enabledSteps fs os
      | Just hes <- ast.headerEditor
      , (hes', editor) <- headerEditorWidget (width, height - 1)
        matrix ast.headers hes
      , (ts', tbl) <- tableWidget (width - imageWidth editor - 1, height - 1)
        (mkTableMeta ast.headers) table ast.layout ast.table
      = (ast { headerEditor = Just hes', table = ts' },) $ horizCat
        [ editor
        , charFill defAttr borderNS 1 (height - 1)
        , tbl
        ]
      | (ts', tbl) <- tableWidget (width, height - 1)
        (mkTableMeta ast.headers) table ast.layout ast.table
      = (ast { table = ts' },) $ tbl

    table = TableContents
      { normalCell = \x y -> do
        i <- Rectangle.indexCell ast.headers.gridToFlavor x y
        IntMap.lookup i ast.flavorStates
      }

    blueBg = defAttr `withBackColor` blue
    keybinds = resizeWidthFill blueBg ' ' width $ horizCat $
      intersperse (char blueBg ' ' <|> char blueBg borderNS <|> char blueBg ' ')
      [ text' (blueBg `withForeColor` brightYellow) bind
        <|> text' blueBg (": " <> desc)
      | (bind, desc) <- appKeybinds ast
      ]

appHandleSchedulerMessage
  :: SchedulerMessage
  -> AppState
  -> AppState
appHandleSchedulerMessage ev ast = case ev of
  OnStepStarted{ flavorIndex } -> ast
    { flavorStates = IntMap.adjust (flavorHandleSchedulerEvent ev)
      flavorIndex ast.flavorStates
    }
  OnStepFinished{ flavorIndex } -> ast
    { flavorStates = IntMap.adjust (flavorHandleSchedulerEvent ev)
      flavorIndex ast.flavorStates
    }
  OnOutput{ flavorIndex } -> ast
    { flavorStates = IntMap.adjust (flavorHandleSchedulerEvent ev)
      flavorIndex ast.flavorStates
    }
  OnDone -> ast

appHandleEvent
  :: TuiMatrix
  -> PerCabalStep Bool
  -> Maybe SchedulerHandle
  -> AppEvent
  -> AppState
  -> IO (Maybe AppState)
appHandleEvent matrix enabledSteps mScheduler aev ast = case aev of
  AppTimerEvent ev -> pure $ Just ast
    { flavorStates = IntMap.map (flavorHandleTimerEvent ev) ast.flavorStates }
  VtyEvent (EvKey (isExitKey -> True) _)
    | Just _ <- ast.openedCell
    -> pure $ Just ast { openedCell = Nothing }
    | Just _ <- ast.headerEditor
    -> pure $ Just ast { headerEditor = Nothing }
    | Nothing <- ast.headerEditor
    -> pure Nothing
  VtyEvent ev
    | Just (flavorIndex, os) <- ast.openedCell
    -> case ev of
      EvKey (KChar 'c') (elem MCtrl -> True) -> do
        for_ mScheduler $ flip signalScheduler InterruptFlavor { flavorIndex }
        pure $ Just ast
      EvKey (KChar '\\') (elem MCtrl -> True) -> do
        for_ mScheduler $ flip signalScheduler TerminateFlavor { flavorIndex }
        pure $ Just ast
      EvKey (KChar 'p') _ -> do
        for_ mScheduler $ flip signalScheduler PrioritizeFlavor { flavorIndex }
        pure $ Just ast
      EvKey (KChar 'r') _ -> do
        for_ mScheduler $ flip signalScheduler RestartFlavor { flavorIndex }
        pure $ Just ast
      _ -> pure $ Just ast
        { openedCell = Just (flavorIndex, outputHandleEvent enabledSteps ev os)
        }
    | Just headerEditor <- ast.headerEditor
    , (headerEditor', headers')
      <- headerEditorHandleEvent matrix ev (headerEditor, ast.headers)
    -> pure $ Just ast
      { headers = headers'
      , headerEditor = Just headerEditor'
      , layout = mkTableLayout headers'
      }
  VtyEvent (EvKey (KChar 'x') _)
    -> pure $ Just ast { headerEditor = Just initHeaderEditorState }
  VtyEvent (EvKey (isEnterKey -> True) _)
    | Just flavorIndex <- astSelectedCell ast
    , Just fs <- IntMap.lookup flavorIndex ast.flavorStates
    -> pure $ Just ast { openedCell = Just (flavorIndex, initOutputState fs) }
    | otherwise -> pure $ Just ast
  VtyEvent (EvKey (KChar 'c') (elem MCtrl -> True))
    | Just flavorIndex <- astSelectedCell ast
    -> do
      for_ mScheduler $ flip signalScheduler InterruptFlavor { flavorIndex }
      pure $ Just ast
  VtyEvent (EvKey (KChar '\\') (elem MCtrl -> True))
    | Just flavorIndex <- astSelectedCell ast
    -> do
      for_ mScheduler $ flip signalScheduler TerminateFlavor { flavorIndex }
      pure $ Just ast
  VtyEvent (EvKey (KChar 'p') _)
    | Just flavorIndex <- astSelectedCell ast
    -> do
      for_ mScheduler $ flip signalScheduler PrioritizeFlavor { flavorIndex }
      pure $ Just ast
  VtyEvent (EvKey (KChar 'r') _)
    | Just flavorIndex <- astSelectedCell ast
    -> do
      for_ mScheduler $ flip signalScheduler RestartFlavor { flavorIndex }
      pure $ Just ast
  VtyEvent ev
    -> pure $ Just ast
      { table = tableHandleEvent (mkTableMeta ast.headers) ev ast.table }
  where
    isExitKey = \case
      KEsc -> True
      KChar 'q' -> True
      _ -> False
    isEnterKey = \case
      KChar ' ' -> True
      KEnter -> True
      _ -> False

astSelectedCell :: AppState -> Maybe FlavorIndex
astSelectedCell ast = do
  guard $ ast.table.activeSelection == SelectionNormal
  guard $ ast.table.normalSelectionCol < sizeofArray
    (Rectangle.rows ast.headers.horizontalHeader)
  guard $ ast.table.normalSelectionRow < sizeofArray
    (Rectangle.rows ast.headers.verticalHeader)
  Rectangle.indexCell ast.headers.gridToFlavor
    ast.table.normalSelectionCol ast.table.normalSelectionRow

appKeybinds :: AppState -> [(Text, Text)]
appKeybinds ast
  | Just _ <- ast.openedCell
  = [("<Esc>/Q", "back")] <> outputKeybinds <>
    [ ("^C", "interrupt")
    , ("^\\", "terminate")
    , ("P", "prioritize")
    , ("R", "restart")
    ]
  | Just _ <- ast.headerEditor
  = [("<Esc>/Q", "back")] <> headerEditorKeybinds
  | otherwise
  = (if isJust (astSelectedCell ast)
      then
        [ ("<Enter>/<Space>", "output")
        , ("^C", "interrupt")
        , ("^\\", "terminate")
        , ("P", "prioritize")
        , ("R", "restart")
        ]
      else [])
    <> [("<Esc>/Q", "quit")]
    <> [("X", "axes")]
    <> tableKeybinds

data AppEvent
  = VtyEvent Event
  | AppTimerEvent TimerEvent

tuiMainLoop
  :: TuiMatrix
  -> AppState
  -> PerCabalStep Bool
  -> TBQueue (Either SchedulerMessage AppEvent)
  -> Maybe SchedulerHandle
  -> IO ()
tuiMainLoop tuiMatrix ast0 steps queue mScheduler = do
  vty <- mkVty defaultConfig

  _ <- forkIO $ forever do
    atomically . writeTBQueue queue . Right . VtyEvent =<< vty.nextEvent

  let
    goDisplay !ast = do
      bounds <- vty.outputIface.displayBounds
      let (!ast', !image) = appWidget bounds tuiMatrix steps ast
      vty.update $ picForImage image
      go ast'

    go !ast = atomically (readTBQueue queue) >>= \case
      Left msg -> go (appHandleSchedulerMessage msg ast)
      Right ev -> appHandleEvent tuiMatrix steps mScheduler ev ast >>= \case
        Just ast' -> goDisplay ast'
        Nothing -> vty.shutdown

  goDisplay ast0

tuiMatrixLive :: SchedulerConfig -> Matrix -> TuiMatrix
tuiMatrixLive schedulerConfig
  = Rectangle.mapRows \flavor -> tabulateCabalStep' \step
    -> renderCabalArgs $ mkCabalArgs schedulerConfig step flavor

tuiLive :: Matrix -> RunOptions -> IO ()
tuiLive matrix options = do
  schedulerConfig <- getSchedulerConfig options
  let
    !tuiMatrix = tuiMatrixLive schedulerConfig matrix
    !flavors = Rectangle.rows matrix

  queue <- newTBQueueIO 1
  _ <- forkIO $ forever do
    threadDelay 100000
    atomically . writeTBQueue queue . Right . AppTimerEvent $ TimerEvent
  scheduler <- startScheduler schedulerConfig flavors
    (atomically . writeTBQueue queue . Left)

  tuiMainLoop tuiMatrix (initAppState tuiMatrix) options.steps queue
    (Just scheduler)

  -- TODO: should probably kill the processes in _hdl

tuiMatrixRecording :: [FlavorResult] -> TuiMatrix
tuiMatrixRecording results = matrix
  where
    !cols = Set.toList $ Set.unions
      [ Map.keysSet result.flavor
      | result <- results
      ]
    !matrix = Rectangle.fromRowMajor cols
      [ ( tabulateCabalStep' \step
          -> maybe noCmdline (.cmdline) $ indexCabalStep result.steps step
        , [Map.lookup col result.flavor | col <- cols]
        )
      | result <- results
      ]
    noCmdline = pure "" -- TODO: better representation?

addOutputFromRecording :: [FlavorResult] -> AppState -> AppState
addOutputFromRecording results ast = ast
  { flavorStates = foldl'
    (\im (i, result) -> IntMap.adjust (flavorFromRecording result) i im)
    ast.flavorStates
    (zip [0..] results)
  }

stepsRecording :: [FlavorResult] -> PerCabalStep Bool
stepsRecording results = tabulateCabalStep' \step
  -> any (\res -> isJust $ indexCabalStep res.steps step) results

tuiRecording :: [FlavorResult] -> IO ()
tuiRecording results = do
  let
    !tuiMatrix = tuiMatrixRecording results
    !steps = stepsRecording results

  queue <- newTBQueueIO 1

  tuiMainLoop tuiMatrix
    (addOutputFromRecording results $ initAppState tuiMatrix)
    steps queue Nothing