packages feed

ghcitui-0.1.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
    , updateSrcWindowEnd
    , srcWindowMoveSelectionBy
    , srcWindowReplace
    , setSelectionTo

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

      -- * 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.
    , srcWindowEnd :: !(Maybe Int)
    , srcName :: !name
    -- ^ The name of the window.
    , srcSelectedLine :: !(Maybe Int)
    -- ^ The currently selected line in the window.
    }
    deriving (Show)

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

-- | 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.
updateSrcWindowEnd :: (Ord n) => SourceWindow n e -> B.EventM n m (SourceWindow n e)
updateSrcWindowEnd srcW@SourceWindow{srcWindowStart, srcName} = do
    mExtent <- B.lookupExtent srcName
    let end = case mExtent of
            Just extent ->
                -- -1 offset since the end is inclusive.
                Just $ (snd . B.extentSize $ extent) + srcWindowStart - 1
            _ -> Nothing
    pure (Lens.set srcWindowEndL end 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{srcWindowEnd = Just windowEnd} =
    srcW{srcWindowStart = clampedPos, srcSelectedLine = newSelection}
  where
    clampedPos = Util.clamp (1, srcWindowLength srcW - renderHeight) 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
    renderHeight = windowEnd - srcWindowStart srcW
    isScrollingPastStart = pos < 1
    isScrollingPastEnd = pos >= srcWindowLength srcW -- Using >= because of a hack.
    newClampedSelectedLine =
        Util.clamp
            (clampedPos, clampedPos + renderHeight)
            <$> srcSelectedLine srcW
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 <$> updateSrcWindowEnd srcW

-- | Internal helper.
srcWindowScrollPage' :: ScrollDir -> SourceWindow n e -> SourceWindow n e
srcWindowScrollPage' dir srcW =
    case dir of
        Up ->
            let renderHeight = windowEnd - srcWindowStart srcW
             in scrollTo (srcWindowStart srcW - renderHeight) srcW
        Down -> scrollTo windowEnd srcW
  where
    windowEnd = fromMaybe 1 $ srcWindowEnd 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@SourceWindow{srcSelectedLine = Just sl, srcWindowEnd = Just end} =
    if pos < srcWindowStart srcW || pos > end
        then srcWindowMoveSelectionBy delta srcW
        else do
            pure $ srcW{srcSelectedLine = Just pos}
  where
    delta = pos - sl
setSelectionTo _ srcW = pure srcW

-- | 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' <- updateSrcWindowEnd sw
    case srcWindowEnd srcW' of
        Just end -> do
            let start = srcWindowStart srcW'
            let mSLine = srcSelectedLine srcW'
            let renderHeight = end - start
            pure $ case mSLine of
                Just sLine
                    | newSLine < start ->
                        scrollTo newSLine srcW'{srcSelectedLine = Just newSLine}
                    | newSLine > end ->
                        scrollTo (newSLine - renderHeight) srcW'{srcSelectedLine = Just newSLine}
                    | otherwise -> srcW'{srcSelectedLine = Just newSLine}
                  where
                    newSLine = Util.clamp (1, Vec.length (srcElements srcW')) $ sLine + amnt
                _ -> srcW'
        Nothing -> 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 name text =
    SourceWindow
        { srcElements = lineVec
        , srcWindowStart = 1
        , srcSelectedLine = Just 1
        , srcName = name
        , srcWindowEnd = Nothing
        }
  where
    lineVec = Vec.fromList (T.lines text)