packages feed

ghcitui-0.3.0.0: lib/ghcitui-brick/Ghcitui/Brick/SourceWindow.hs

{-# LANGUAGE BlockArguments #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TemplateHaskell #-}

module Ghcitui.Brick.SourceWindow
    ( SourceWindow (srcElements)

      -- * Creation
    , mkSourcWindow

      -- * Rendering
    , renderSourceWindow

      -- * Event Handling
    , ScrollDir (..)
    , scrollTo
    , srcWindowScrollPage
    , srcWindowMoveSelectionBy
    , srcWindowReplace
    , setSelectionTo
    , updateVerticalSpace

      -- * Lenses
    , srcElementsL
    , srcNameL
    , srcSelectedLineL
    , srcWindowStartL
    , srcWindowVerticalSpaceL

      -- * Misc
    , srcWindowLength
    ) where

import qualified Brick as B
import Control.Error (fromMaybe)
import qualified Data.Text as T
import qualified Data.Vector as Vec
import Lens.Micro ((^.))
import qualified Lens.Micro as Lens
import Lens.Micro.TH (makeLensesFor)

import qualified Ghcitui.Util as Util

-- | Hold data regarding a code source viewing window.
data SourceWindow name elem = SourceWindow
    { srcElements :: !(Vec.Vector elem)
    -- ^ The actual entries for each source window.
    , srcWindowStart :: !Int
    -- ^ The starting position of the window, as a line number (1-indexed).
    -- No lines before this line number is rendered.
    , srcWindowVerticalSpace :: !(Maybe Int)
    -- ^ The maximum amount of visible lines at any point in time.
    , srcName :: !name
    -- ^ The name of the window.
    , srcSelectedLine :: !(Maybe Int)
    -- ^ The currently selected line in the window.
    }
    deriving (Show)

makeLensesFor
    [ ("srcElements", "srcElementsL")
    , ("srcWindowStart", "srcWindowStartL")
    , ("srcWindowVerticalSpace", "srcWindowVerticalSpaceL")
    , ("srcName", "srcNameL")
    , ("srcSelectedLine", "srcSelectedLineL")
    ]
    ''SourceWindow

-- | The difference between the last rendered line and the first rendered line.
srcWindowLineDiffCount :: SourceWindow name elem -> Maybe Int
srcWindowLineDiffCount SourceWindow{srcWindowVerticalSpace = Just sWVS} = pure $ sWVS - 1
srcWindowLineDiffCount _ = Nothing

-- | The line number of the last viewable line in the window.
getLastRenderedLine :: SourceWindow name elem -> Maybe Int
getLastRenderedLine srcW@SourceWindow{srcWindowStart} = do
    diffCount <- srcWindowLineDiffCount srcW
    pure $ diffCount + srcWindowStart

-- | Render a 'SourceWindow' into a Brick 'B.Widget'.
renderSourceWindow
    :: (Ord n)
    => (Int -> Bool -> e -> B.Widget n)
    -- ^ Render function.
    -> SourceWindow n e
    -- ^ 'SourceWindow' to render.
    -> B.Widget n
    -- ^ The newly created widget.
renderSourceWindow func srcW = B.reportExtent (srcName srcW) (B.Widget B.Greedy B.Greedy renderM)
  where
    renderM = do
        c <- B.getContext
        let availableHeight = c ^. B.availHeightL + 1
        let renderHeight = Util.clamp (1, remainingElements) availableHeight
        let slicedElems = Vec.slice startZeroIdx renderHeight elems
        let drawnElems =
                [ func idx (Just idx == srcSelectedLine srcW) e
                | (idx, e) <- zip [srcWindowStart srcW ..] . Vec.toList $ slicedElems
                ]
        let trailingSpaces = availableHeight - length drawnElems
        -- This is a fairly weird list comprehension, since it either has only one element
        -- or none. But it works, and is for some reason recommended by hlint. Ugh.
        let trailingSpaceWidgets = [B.txt (T.replicate trailingSpaces "\n") | trailingSpaces > 0]
        B.render
            . B.vBox
            $ drawnElems <> trailingSpaceWidgets
    startZeroIdx = Util.clamp (0, srcWindowLength srcW - 1) $ srcWindowStart srcW - 1
    remainingElements = srcWindowLength srcW - startZeroIdx
    elems = srcElements srcW

{- | Return the length of the full contents of the source code stored in the window.

     Note, does NOT return the current length/height/size of the rendered widget.
-}
srcWindowLength :: SourceWindow n e -> Int
srcWindowLength = Vec.length . srcElements

{- | Set the source window end line inside of the given 'EventM' Monad.
     This is primarily for internal consistency, and is cheap. It should be called any time
     the srcWindowStart changes.
-}
updateVerticalSpace :: (Ord n) => SourceWindow n e -> B.EventM n m (SourceWindow n e)
updateVerticalSpace srcW@SourceWindow{srcName {- , srcContainerName -}} = do
    mSrcNameExtent <- B.lookupExtent srcName
    let mSpace = case mSrcNameExtent of
            Just extent ->
                Just . snd . B.extentSize $ extent
            _ -> Nothing
    pure (Lens.set srcWindowVerticalSpaceL mSpace srcW)

-- | Scroll to a given position, and move the source line along the way if needed.
scrollTo :: Int -> SourceWindow n e -> SourceWindow n e
scrollTo pos srcW@SourceWindow{srcWindowVerticalSpace = Just vSpace} =
    srcW{srcWindowStart = clampedPos, srcSelectedLine = newSelection}
  where
    -- Clamp between start line and one window away from the end.
    clampedPos = Util.clamp (1, srcWindowLength srcW - vSpace) pos

    newSelection
        | -- Choose the starting line if we're trying to go past the beginning.
          isScrollingPastStart =
            Just 1
        | -- Choose the last line if we're trying to go past the end.
          isScrollingPastEnd =
            Just $ srcWindowLength srcW
        | otherwise = newClampedSelectedLine
    isScrollingPastStart = pos < 1
    isScrollingPastEnd = pos >= srcWindowLength srcW -- Using >= because of a hack.
    newClampedSelectedLine :: Maybe Int
    newClampedSelectedLine = do
        ssl <- srcSelectedLine srcW
        diffCount <- srcWindowLineDiffCount srcW
        pure $ Util.clamp (clampedPos, clampedPos + diffCount) ssl
scrollTo _ srcW = srcW

-- | Direction to scroll by.
data ScrollDir = Up | Down deriving (Eq, Show)

-- | Scroll by a full page in a direction.
srcWindowScrollPage :: (Ord n) => ScrollDir -> SourceWindow n e -> B.EventM n m (SourceWindow n e)
srcWindowScrollPage dir srcW = srcWindowScrollPage' dir <$> updateVerticalSpace srcW

srcWindowScrollPage' :: ScrollDir -> SourceWindow n e -> SourceWindow n e
srcWindowScrollPage' dir srcW@SourceWindow{srcWindowStart} =
    case dir of
        Up -> scrollTo onePageUpPos srcW
        Down -> scrollTo (fromMaybe srcWindowStart (getLastRenderedLine srcW)) srcW
  where
    onePageUpPos = srcWindowStart - vSpace + 1 -- Plus one to preserve the top line.
    vSpace = fromMaybe 0 (srcWindowVerticalSpace srcW)

-- | Set the selection to a given position, and scroll the window accordingly.
setSelectionTo
    :: (Ord n)
    => Int
    -- ^ Line number to set the selection to (1-indexed)
    -> SourceWindow n e
    -- ^ Source window to update.
    -> B.EventM n m (SourceWindow n e)
setSelectionTo pos srcW = do
    srcW' <- updateVerticalSpace srcW
    case (getLastRenderedLine srcW', srcSelectedLine srcW') of
        (Just end, Just oldSelectedLine) -> do
            let delta = pos - oldSelectedLine
            if pos < srcWindowStart srcW' || pos > end
                then srcWindowMoveSelectionBy delta srcW
                else do
                    pure $ srcW{srcSelectedLine = Just pos}
        _ -> setSelectionToFallback pos srcW'

-- | Fallback function for setting the source window selection line, when we can't set it properly.
setSelectionToFallback :: Int -> SourceWindow name elem -> B.EventM name m (SourceWindow name elem)
setSelectionToFallback pos srcW = pure $ srcW{srcSelectedLine = Just pos, srcWindowStart = pos}

-- | Move the selected line by a given amount.
srcWindowMoveSelectionBy
    :: (Ord n)
    => Int
    -- ^ Delta to move the selected line.
    -> SourceWindow n e
    -- ^ Source window to update.
    -> B.EventM n m (SourceWindow n e)
srcWindowMoveSelectionBy amnt sw = do
    srcW <- updateVerticalSpace sw
    case (getLastRenderedLine srcW, srcWindowLineDiffCount srcW, srcSelectedLine srcW) of
        (Just end, Just renderHeight, Just oldSLine)
            | newSLine < srcWindowStart srcW ->
                pure $ scrollTo newSLine srcW{srcSelectedLine = Just newSLine}
            | newSLine > end ->
                pure $ scrollTo (newSLine - renderHeight) srcW{srcSelectedLine = Just newSLine}
            | otherwise -> pure $ srcW{srcSelectedLine = Just newSLine}
          where
            newSLine = Util.clamp (1, Vec.length (srcElements srcW)) $ oldSLine + amnt
        _ -> pure srcW

{- | Replace the contents of a given source window, and reset the pseudo-viewport's position
     to the top.
-}
srcWindowReplace :: (Foldable f) => f e -> SourceWindow n e -> SourceWindow n e
srcWindowReplace foldable srcW =
    srcW{srcSelectedLine = Just 1, srcWindowStart = 1, srcElements = elems}
  where
    elems = Vec.fromList . foldr (:) [] $ foldable

-- | Create a new source window from some text.
mkSourcWindow
    :: n
    -- ^ Name for the source window.
    -> T.Text
    -- ^ Text contents of the source window (to be split up).
    -> SourceWindow n T.Text
mkSourcWindow sourceWindowName text =
    SourceWindow
        { srcElements = lineVec
        , srcWindowStart = 1
        , srcSelectedLine = Just 1
        , srcName = sourceWindowName
        , srcWindowVerticalSpace = Nothing
        }
  where
    lineVec = Vec.fromList (T.lines text)