packages feed

ghcitui-0.2.0.0: lib/ghcitui-brick/Ghcitui/Brick/AppState.hs

{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedRecordDot #-}

module Ghcitui.Brick.AppState
    ( ActiveWindow (..)
    , AppConfig (..)
    , AppState (..)
    , WidgetSizes
    , changeInfoWidgetSize
    , getInfoWidth
    , getReplHeight
    , changeReplWidgetSize
    , getSelectedModuleInInfoPanel
    , changeSelectedModuleInInfoPanel
    , appInterpState
    , getSourceLineCount
    , selectedFile
    , setSelectedFile
    , selectedLine
    , filePathOfInfoSelectedModule
    , listAvailableSources
    , liveEditor
    , makeInitialState
    , selectPausedLine
    , sourceWindow
    , toggleActiveLineInterpreter
    , toggleBreakpointLine
    , updateSourceMap
    , writeDebugLog
    ) where

import qualified Brick as B
import qualified Brick.Widgets.Edit as BE
import Control.Error (atMay, fromMaybe)
import Control.Exception (IOException, try)
import Control.Monad.IO.Class (MonadIO (..))
import qualified Data.Map.Strict as Map
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.IO as T
import qualified Data.Vector as Vec
import Lens.Micro ((^.))
import qualified Lens.Micro as Lens

import Ghcitui.Brick.AppConfig (AppConfig (..))
import qualified Ghcitui.Brick.AppConfig as AppConfig
import qualified Ghcitui.Brick.AppInterpState as AIS
import Ghcitui.Brick.AppTopLevel (AppName (..))

import qualified Ghcitui.Brick.SourceWindow as SourceWindow
import Ghcitui.Ghcid.Daemon (toggleBreakpointLine)
import qualified Ghcitui.Ghcid.Daemon as Daemon
import qualified Ghcitui.Ghcid.LogConfig as LogConfig
import qualified Ghcitui.Loc as Loc
import qualified Ghcitui.Util as Util

data ActiveWindow
    = ActiveCodeViewport
    | ActiveLiveInterpreter
    | ActiveInfoWindow
    | ActiveDialogQuit
    | ActiveDialogHelp
    deriving (Show, Eq, Ord)

-- | Size information of the current GHCiTUI main boxes.
data WidgetSizes = WidgetSizes
    { _wsInfoWidth :: !Int
    , _wsReplHeight :: !Int
    }

{- | Application state wrapper.

Contains information about the UI and configuration. It also holds a
handle to the actual interpreter under the hood, but on the high level
it should not hold anything internal to GHCi or GHCiD.

Prefer to create this with 'makeInitialState'.
-}
data AppState n = AppState
    { interpState :: Daemon.InterpState ()
    -- ^ The interpreter handle.
    , getCurrentWorkingDir :: !FilePath
    -- ^ The current working directory.
    , _appInterpState :: AIS.AppInterpState T.Text n
    -- ^ The live interpreter state (separate from the interpreter
    -- and the app state itself.
    , interpLogs :: ![Text]
    , appConfig :: !AppConfig
    -- ^ Program launch configuration.
    , activeWindow :: !ActiveWindow
    -- ^ Currently active window.
    , _selectedFile :: !(Maybe FilePath)
    -- ^ Filepath to the current code viewport contents, if set.
    , _sourceWindow :: !(SourceWindow.SourceWindow n T.Text)
    , _infoPanelSelectedModule :: !Int
    -- ^ Currently selected module in the info sidebar, zero indexed.
    , sourceMap :: Map.Map FilePath T.Text
    -- ^ Mapping between source filepaths and their contents.
    , _currentWidgetSizes :: WidgetSizes
    -- ^ Current window/box/panel sizes (since it can change). Do not edit
    -- directly.
    , displayDebugConsoleLogs :: !Bool
    -- ^ Whether to display debug Console logs.
    , debugConsoleLogs :: [Text]
    -- ^ Place for debug output to go.
    , splashContents :: !(Maybe T.Text)
    -- ^ Splash to show on start up.
    }

newtype AppStateM m a = AppStateM {runAppStateM :: m a}

instance (Functor m) => Functor (AppStateM m) where
    fmap f appStateA = AppStateM (f <$> runAppStateM appStateA)

instance (Applicative m) => Applicative (AppStateM m) where
    pure appState = AppStateM (pure appState)
    AppStateM appl <*> AppStateM tgt = AppStateM (appl <*> tgt)

instance (Monad m) => Monad (AppStateM m) where
    return = pure
    AppStateM valM >>= f2 = AppStateM (valM >>= runAppStateM . f2)

instance (MonadIO m) => MonadIO (AppStateM m) where
    liftIO = AppStateM . liftIO

-- | Lens for the App's interpreter box.
appInterpState :: Lens.Lens' (AppState n) (AIS.AppInterpState T.Text n)
appInterpState = Lens.lens _appInterpState (\x ais -> x{_appInterpState = ais})

-- | Lens wrapper for zooming with handleEditorEvent.
liveEditor :: Lens.Lens' (AppState n) (BE.Editor T.Text n)
liveEditor = appInterpState . AIS.liveEditor

currentWidgetSizes :: Lens.Lens' (AppState n) WidgetSizes
currentWidgetSizes = Lens.lens _currentWidgetSizes (\x cws -> x{_currentWidgetSizes = cws})

wsInfoWidth :: Lens.Lens' WidgetSizes Int
wsInfoWidth = Lens.lens _wsInfoWidth (\x ipw -> x{_wsInfoWidth = ipw})

wsReplHeight :: Lens.Lens' WidgetSizes Int
wsReplHeight = Lens.lens _wsReplHeight (\x rh -> x{_wsReplHeight = rh})

sourceWindow :: Lens.Lens' (AppState n) (SourceWindow.SourceWindow n T.Text)
sourceWindow = Lens.lens _sourceWindow (\x srcW -> x{_sourceWindow = srcW})

selectedFile :: AppState n -> Maybe FilePath
selectedFile = _selectedFile

setSelectedFile :: (MonadIO m) => Maybe FilePath -> AppState n -> m (AppState n)
setSelectedFile mayFP appState =
    if mayFP == _selectedFile appState
        then -- If we're selecting the same file again, do nothing.
            pure appState
        else do
            -- Update the source map with the new file, and replace the window contents.
            updatedAppState <- liftIO $ updateSourceMap appState{_selectedFile = mayFP}
            let contents = mayFP >>= (sourceMap updatedAppState Map.!?)
            let elements = maybe Vec.empty (Vec.fromList . T.lines) contents
            let newSrcW = SourceWindow.srcWindowReplace elements (appState ^. sourceWindow)
            pure updatedAppState{_sourceWindow = newSrcW}

-- -------------------------------------------------------------------------------------------------
-- State Line Details
-- -------------------------------------------------------------------------------------------------

-- | Currently selected line number. One-indexed. If no line is selected, returns 1.
selectedLine :: AppState n -> Int
selectedLine s = fromMaybe 1 (s ^. sourceWindow . SourceWindow.srcSelectedLineL)

-- | Reset the code viewport selected line to the pause location.
selectPausedLine :: (Ord n) => AppState n -> B.EventM n m (AppState n)
selectPausedLine s@AppState{interpState} = do
    s' <- setSelectedFile ourSelectedFile s
    newSrcW <- SourceWindow.setSelectionTo ourSelectedLine (s' ^. sourceWindow)
    pure $ Lens.set sourceWindow newSrcW s'
  where
    ourSelectedLine :: Int
    ourSelectedLine =
        fromMaybe
            (selectedLine s)
            (Loc.startLine . Loc.fSourceRange =<< interpState.pauseLoc)
    ourSelectedFile = maybe (selectedFile s) (Just . Loc.filepath) interpState.pauseLoc

-- | Write a debug log entry.
writeDebugLog :: T.Text -> AppState n -> AppState n
writeDebugLog lg s = s{debugConsoleLogs = take 100 (lg : debugConsoleLogs s)}

toggleActiveLineInterpreter :: AppState n -> AppState n
toggleActiveLineInterpreter s@AppState{activeWindow} =
    s{activeWindow = toggleLogic activeWindow}
  where
    toggleLogic ActiveLiveInterpreter = ActiveCodeViewport
    toggleLogic _ = ActiveLiveInterpreter

-- | Update the source map given any app state changes.
updateSourceMap :: AppState n -> IO (AppState n)
updateSourceMap s = do
    s' <- case selectedFile s of
        Just sf -> updateSourceMapWithFilepath s sf
        Nothing -> pure s
    case s'.interpState.pauseLoc of
        Nothing -> pure s'
        (Just (Loc.FileLoc{filepath})) -> updateSourceMapWithFilepath s' filepath

-- | Update the source map with a given filepath.
updateSourceMapWithFilepath :: AppState n -> FilePath -> IO (AppState n)
updateSourceMapWithFilepath s filepath
    | Map.member filepath s.sourceMap = pure s
    | otherwise = do
        let adjustedFilepath = getCurrentWorkingDir s <> "/" <> filepath
        eContents <- try $ T.readFile adjustedFilepath :: IO (Either IOException T.Text)
        case eContents of
            Left err -> do
                pure $
                    writeDebugLog
                        ( "failed to update source map with "
                            <> T.pack filepath
                            <> ": "
                            <> T.pack (show err)
                        )
                        s
            Right contents -> do
                let newSourceMap = Map.insert filepath (stripCREndings contents) s.sourceMap
                let logMsg = "updated source map with " <> T.pack filepath
                pure (writeDebugLog logMsg s{sourceMap = newSourceMap})

-- | Remove CR line endings.
stripCREndings :: T.Text -> T.Text
stripCREndings = T.replace "\r" ""

listAvailableSources :: AppState n -> [(T.Text, FilePath)]
listAvailableSources = Loc.moduleFileMapAssocs . Daemon.moduleFileMap . interpState

-- | Return the potential contents of the current paused file location.
getSourceContents :: AppState n -> Maybe T.Text
getSourceContents s = selectedFile s >>= (sourceMap s Map.!?)

{- | Return the number of lines in the current source viewer.
     Returns Nothing if there's no currently viewed source.
-}
getSourceLineCount :: AppState n -> Maybe Int
getSourceLineCount s = length . T.lines <$> getSourceContents s

changeInfoWidgetSize :: Int -> AppState n -> AppState n
changeInfoWidgetSize amnt s =
    Lens.set
        (currentWidgetSizes . wsInfoWidth)
        -- Do not let the min go too low (<=2), because this causes a memory leak in Brick?
        (Util.clamp (10, 120) (getInfoWidth s + amnt))
        s

changeReplWidgetSize :: Int -> AppState n -> AppState n
changeReplWidgetSize amnt s =
    Lens.set
        (currentWidgetSizes . wsReplHeight)
        -- Do not let the min go too low, because the box disappears then.
        (Util.clamp (1, 80) (getReplHeight s + amnt))
        s

changeSelectedModuleInInfoPanel :: Int -> AppState n -> AppState n
changeSelectedModuleInInfoPanel amnt s =
    s{_infoPanelSelectedModule = newSelection}
  where
    newSelection = (_infoPanelSelectedModule s + amnt) `mod` numModules
    numModules = length (Loc.moduleFileMapAssocs (Daemon.moduleFileMap (interpState s)))

getSelectedModuleInInfoPanel :: AppState n -> Int
getSelectedModuleInInfoPanel = _infoPanelSelectedModule

-- | Return the info box's desired width in character columns.
getInfoWidth :: AppState n -> Int
getInfoWidth = _wsInfoWidth . _currentWidgetSizes

-- | Return the REPL (interactive interpreter)'s box in lines.
getReplHeight :: AppState n -> Int
getReplHeight = _wsReplHeight . _currentWidgetSizes

filePathOfInfoSelectedModule :: AppState n -> Maybe FilePath
filePathOfInfoSelectedModule AppState{interpState, _infoPanelSelectedModule} =
    fmap snd
        . flip atMay _infoPanelSelectedModule
        . Loc.moduleFileMapAssocs
        . Daemon.moduleFileMap
        $ interpState

-- | Initialise the state from the config.
makeInitialState
    :: AppConfig
    -- ^ Start up config.
    -> T.Text
    -- ^ Daemon command prefix.
    -> FilePath
    -- ^ Workding directory.
    -> IO (AppState AppName)
makeInitialState appConfig target cwd = do
    let cwd' = if null cwd then "." else cwd
    let fullCmd = getCmd appConfig <> " " <> target
    let logOutput = case getDebugLogPath appConfig of
            "stderr" -> Daemon.LogOutputStdErr
            "stdout" -> Daemon.LogOutputStdOut
            filepath -> Daemon.LogOutputFile filepath
    let logLevel = LogConfig.LogLevel (AppConfig.getVerbosity appConfig)
    let startupConfig =
            Daemon.StartupConfig
                { Daemon.logLevel = logLevel
                , Daemon.logOutput = logOutput
                }
    interpState <-
        Daemon.run (Daemon.startup (T.unpack fullCmd) cwd' startupConfig) >>= \case
            Right iState -> pure iState
            Left er -> error (show er)
    splashContents <- AppConfig.loadStartupSplash appConfig
    let selectedFile' =
            case Loc.moduleFileMapAssocs (Daemon.moduleFileMap interpState) of
                -- If we just have one file, select that.
                [(_, filepath)] -> Just filepath
                -- If we have no module/file mappings, nothing must be selected.
                [] -> Nothing
                -- If we don't have a selected file, but we have a module loaded,
                -- select the last one.
                _ -> Nothing
    updateSourceMap
        AppState
            { interpState
            , getCurrentWorkingDir = cwd'
            , _appInterpState = AIS.emptyAppInterpState LiveInterpreter
            , activeWindow = ActiveCodeViewport
            , appConfig
            , debugConsoleLogs = mempty
            , displayDebugConsoleLogs = getDebugConsoleOnStart appConfig
            , interpLogs = mempty
            , _selectedFile = selectedFile'
            , _infoPanelSelectedModule = 0
            , sourceMap = mempty
            , _currentWidgetSizes =
                WidgetSizes
                    { _wsInfoWidth = 35
                    , _wsReplHeight = 11 -- 10 plus 1 for the entry line.
                    }
            , splashContents
            , _sourceWindow = SourceWindow.mkSourcWindow SourceList ""
            }