text-rope-zipper-0.1.1.0: src/Data/Text/Rope/Zipper.hs
{-# OPTIONS_GHC -Wno-name-shadowing #-}
module Data.Text.Rope.Zipper
( RopeZipper (..)
, Position (..)
, null
, cursor
, moveCursor
, setCursor
, lines
, lengthInLines
, toRope
, toText
, fromParts
, fromRope
, fromText
, splitFirstLine
, splitLastLine
, insertRope
, insertText
, insertChar
, deleteBefore
, deleteAfter
, moveForward
, moveBackward
, moveUp
, moveDown
, moveToLineStart
, moveToLineEnd
, moveToFirstLine
, moveToLastLine
)
where
import Data.Ord (clamp)
import Data.String (IsString (fromString))
import Data.Text (Text)
import Data.Text qualified as Strict
import Data.Text.Lazy qualified as Lazy
import Data.Text.Lazy.Zipper (TextZipper (TextZipper))
import Data.Text.Lazy.Zipper qualified as TextZipper
import Data.Text.Rope (Position (..), Rope)
import Data.Text.Rope qualified as Rope
import GHC.Generics (Generic)
import GHC.Records qualified as GHC
import Prelude hiding (lines, null)
import Util
-- | A RopeZipper is similar in concept to a 'TextZipper', but tracks the
-- lines before the cursor, lines after the cursor, and the current line of the
-- cursor (as a 'TextZipper'). In essence, it is a 2D extension of 'TextZipper'.
data RopeZipper = RopeZipper
{ linesBefore :: !Rope
-- ^ The lines before the cursor
, currentLine :: !TextZipper
-- ^ The line the cursor is on
, linesAfter :: !Rope
-- ^ The lines after the cursor
, stickyCol :: !TextZipper.Position
-- ^ The last requested cursor column. This is used to remember the
-- column when moving between ends of lines of different lengths.
}
deriving stock (Generic, Eq, Show)
instance Monoid RopeZipper where
mempty :: RopeZipper
mempty =
RopeZipper
{ linesBefore = mempty
, currentLine = mempty
, linesAfter = mempty
, stickyCol = 0
}
-- | '(<>)' appends the content of the second zipper after the first; the cursor
-- of the first is preserved while the cursor of the second is ignored.
instance Semigroup RopeZipper where
(<>) :: RopeZipper -> RopeZipper -> RopeZipper
a <> b | not (Rope.null a.linesAfter) = a{linesAfter = a.linesAfter <> toRope b}
RopeZipper{..} <> b =
let
(firstLine, linesAfter) = splitFirstLine $ toRope b
in
RopeZipper
{ currentLine = currentLine <> TextZipper.fromText firstLine
, ..
}
-- | Whether the whole 'RopeZipper' structure is empty.
null :: RopeZipper -> Bool
null RopeZipper{..} = Rope.null linesBefore && TextZipper.null currentLine && Rope.null linesAfter
-- | Get the current cursor position of the 'RopeZipper'.
cursor :: RopeZipper -> Position
cursor RopeZipper{..} =
Position
{ posLine = (Rope.lengthAsPosition linesBefore).posLine
, posColumn = currentLine.cursor
}
instance GHC.HasField "cursor" RopeZipper Position where
getField = cursor
-- | Move the cursor relative to its current position.
moveCursor :: (Position -> Position) -> RopeZipper -> RopeZipper
moveCursor f r
-- for a positive change where the current line has a trailing newline OR
-- there are lines after the current line
| (newY > oldY)
&& (TextZipper.hasTrailingNewline r.currentLine || not (Rope.null r.linesAfter)) =
let (before, currentLine, linesAfter) = splitAtLine (absDy - 1) r.linesAfter
in RopeZipper
{ linesBefore = r.linesBefore <> currentLineAsRope <> before
, stickyCol = withStickyCol $ min currentLine.cursor
, ..
}
-- for a negative change that puts the line of the cursor at the start
| newY < oldY && newY == 0 =
let ( flip TextZipper.fromTextAt (if absDx /= 0 then newX else r.stickyCol) ->
moveBackFromNewline -> currentLine
, linesAfter
) = splitFirstLine $ r.linesBefore <> currentLineAsRope <> r.linesAfter
in RopeZipper
{ linesBefore = mempty
, currentLine
, stickyCol = withStickyCol $ min currentLine.cursor
, ..
}
-- for a negative change that doesn't put the line of the cursor at the start
| newY < oldY && not (Rope.null r.linesBefore) =
let (linesBefore, currentLine, after) =
splitAtLine
((Rope.lengthAsPosition r.linesBefore).posLine - absDy)
r.linesBefore
in RopeZipper
{ linesAfter = after <> currentLineAsRope <> r.linesAfter
, stickyCol = withStickyCol $ min currentLine.cursor
, ..
}
-- in any other circumstance, just move the cursor within the line and reset
-- the current cursor pos
| otherwise =
let currentLine = moveBackFromNewline $ TextZipper.setCursor newX r.currentLine
in r
{ currentLine
, stickyCol =
if absDx > 0 && absDy == 0 && r.currentLine.cursor /= currentLine.cursor
then currentLine.cursor
else r.stickyCol
}
where
Position{posLine = oldY, posColumn = oldX} = r.cursor
Position{posLine = newY, posColumn = newX} = f r.cursor
absDy = absDelta newY oldY
absDx = absDelta newX oldX
withStickyCol f = if absDx == 0 then r.stickyCol else f newX
currentLineAsRope = Rope.fromText . Lazy.toStrict . TextZipper.toText $ r.currentLine
splitAtLine :: Word -> Rope -> (Rope, TextZipper, Rope)
splitAtLine n rope =
let (before, after) = Rope.splitAtLine (clamp (0, (Rope.lengthAsPosition rope).posLine) n) rope
(current, after') = splitFirstLine after
stickyCol = if absDx /= 0 then newX else r.stickyCol
in (before, moveBackFromNewline $ TextZipper.fromTextAt current stickyCol, after')
-- | Move the cursor to the given absolute position.
setCursor :: Position -> RopeZipper -> RopeZipper
setCursor c = moveCursor (const c)
lines :: RopeZipper -> [Text]
lines = Rope.lines . toRope
lengthInLines :: RopeZipper -> Word
lengthInLines r@RopeZipper{..} = before + current + after
where
before = posLine . cursor $ r
current = if TextZipper.null currentLine then 0 else 1
after = Rope.lengthInLines linesAfter
toRope :: RopeZipper -> Rope
toRope RopeZipper{..} =
mconcat
[ linesBefore
, Rope.fromText $ Lazy.toStrict $ TextZipper.toText currentLine
, linesAfter
]
toText :: RopeZipper -> Text
toText = Rope.toText . toRope
fromParts :: Rope -> Rope -> RopeZipper
fromParts r1 r2 = RopeZipper{..}
where
(linesBefore, beforeCursor) = splitLastLine r1
(afterCursor, linesAfter) = splitFirstLine r2
currentLine = TextZipper.fromParts beforeCursor afterCursor
stickyCol = currentLine.cursor
fromRope :: Rope -> RopeZipper
fromRope = flip fromParts mempty
fromText :: Text -> RopeZipper
fromText = fromRope . Rope.fromText
instance IsString RopeZipper where
fromString = fromText . fromString
splitFirstLine :: Rope -> (Lazy.Text, Rope)
splitFirstLine r = (Lazy.fromStrict $ Rope.toText firstLine, linesAfter)
where
(firstLine, linesAfter) = Rope.splitAtLine 1 r
splitLastLine :: Rope -> (Rope, Lazy.Text)
splitLastLine r = (linesBefore, Lazy.fromStrict $ Rope.toText lastLine)
where
(linesBefore, lastLine) = Rope.splitAtLine (Rope.lengthAsPosition r).posLine r
split2ndLastLine :: Rope -> (Rope, Lazy.Text)
split2ndLastLine r
| numLines < 1 = ("", Lazy.fromStrict $ Rope.toText r)
| otherwise = (linesBefore, Lazy.fromStrict $ Rope.toText lastLine)
where
numLines = (Rope.lengthAsPosition r).posLine
(linesBefore, lastLine) = Rope.splitAtLine (numLines - 1) r
insertRope :: Rope -> RopeZipper -> RopeZipper
insertRope rope@(Rope.lengthAsPosition -> Rope.Position{posLine = 0}) r =
r{currentLine, stickyCol = currentLine.cursor}
where
currentLine = TextZipper.insert (Lazy.fromStrict $ Rope.toText rope) r.currentLine
insertRope (fromRope -> t) r =
r{linesBefore, currentLine, stickyCol = currentLine.cursor}
where
linesBefore =
r.linesBefore
<> Rope.fromText (Lazy.toStrict r.currentLine.beforeCursor)
<> t.linesBefore
currentLine = t.currentLine <> TextZipper.fromText r.currentLine.afterCursor
insertText :: Text -> RopeZipper -> RopeZipper
insertText = insertRope . Rope.fromText
insertChar :: Char -> RopeZipper -> RopeZipper
insertChar = insertText . Strict.singleton
deleteBefore :: RopeZipper -> RopeZipper
deleteBefore r@RopeZipper{currentLine = TextZipper{beforeCursor = Lazy.null -> False}} =
r{currentLine, stickyCol = currentLine.cursor}
where
currentLine = TextZipper.deleteBefore r.currentLine
deleteBefore r = RopeZipper{linesAfter = r.linesAfter, ..}
where
(linesBefore, TextZipper.removeTrailingNewline -> beforeCursor) = split2ndLastLine r.linesBefore
currentLine = TextZipper.fromParts beforeCursor r.currentLine.afterCursor
stickyCol = currentLine.cursor
deleteAfter :: RopeZipper -> RopeZipper
deleteAfter r@RopeZipper{currentLine = TextZipper{afterCursor = "\n"}} = RopeZipper{..}
where
linesBefore = r.linesBefore
(afterCursor, linesAfter) = splitFirstLine r.linesAfter
currentLine = TextZipper.fromParts r.currentLine.beforeCursor afterCursor
stickyCol = currentLine.cursor
deleteAfter RopeZipper{..} = RopeZipper{currentLine = TextZipper.deleteAfter currentLine, ..}
-- | Move the cursor to the previous character of the current row, if there is one. Does not change lines.
moveBackward :: RopeZipper -> RopeZipper
moveBackward = moveCursor $ \c -> c{posColumn = boundedPred c.posColumn}
-- | Move the cursor to the next character of the current line, if there is one. Does not change lines.
moveForward :: RopeZipper -> RopeZipper
moveForward = moveCursor $ \c -> c{posColumn = boundedSucc c.posColumn}
-- | Move the cursor to the previous line, trying to preserve the column.
moveUp :: RopeZipper -> RopeZipper
moveUp = moveCursor $ \c -> c{posLine = boundedPred c.posLine}
-- | Move the cursor to the next line, trying to preserve the column.
moveDown :: RopeZipper -> RopeZipper
moveDown = moveCursor $ \c -> c{posLine = boundedSucc c.posLine}
-- | Move the cursor to the start of the current line.
moveToLineStart :: RopeZipper -> RopeZipper
moveToLineStart = moveCursor $ \c -> c{posColumn = minBound}
-- | Move the cursor to the end of the current line.
moveToLineEnd :: RopeZipper -> RopeZipper
moveToLineEnd = moveCursor $ \c -> c{posColumn = maxBound}
-- | Move the cursor to the first line, trying to preserve the column.
moveToFirstLine :: RopeZipper -> RopeZipper
moveToFirstLine = moveCursor $ \c -> c{posLine = minBound}
-- | Move the cursor to the last line, trying to preserve the column.
moveToLastLine :: RopeZipper -> RopeZipper
moveToLastLine = moveCursor $ \c -> c{posLine = maxBound}
-- If the cursor is after the final character and the final character is a newline,
-- move backwards once.
moveBackFromNewline :: TextZipper -> TextZipper
moveBackFromNewline t
| TextZipper.hasTrailingNewline t && Lazy.null t.afterCursor =
TextZipper.moveBackward t
| otherwise = t