2Module: Data.Text.Zipper
3Description: A zipper for text documents that allows convenient editing and navigation
5'TextZipper' is designed to be help manipulate the contents of a text input field. It keeps track of the logical lines of text (i.e., lines separated by user-entered newlines) and the current cursor position. Several functions are defined in this module to navigate and edit the TextZipper from the cursor position.
7'TextZipper's can be converted into 'DisplayLines', which describe how the contents of the zipper will be displayed when wrapped to fit within a container of a certain width. It also provides some convenience facilities for converting interactions with the rendered DisplayLines back into manipulations of the underlying TextZipper.
10{-# LANGUAGE FlexibleContexts #-}
11{-# LANGUAGE LambdaCase #-}
12{-# LANGUAGE OverloadedStrings #-}
13module Data.Text.Zipper where
15import Data.Char (isSpace)
17import qualified Data.Map as Map
18import Data.Maybe (fromMaybe)
19import Control.Monad.State (evalState, forM, get, put)
21import Data.Text (Text)
22import qualified Data.Text as T
24-- | A zipper of the logical text input contents (the "document"). The lines
25-- before the line containing the cursor are stored in reverse order.
26-- The cursor is logically between the "before" and "after" text.
27-- A "logical" line of input is a line of input up until a user-entered newline
28-- character (as compared to a "display" line, which is wrapped to fit within
29-- a given viewport width).
30data TextZipper = TextZipper
31 { _textZipper_linesBefore :: [Text] -- reversed
32 , _textZipper_before :: Text
33 , _textZipper_after :: Text -- The cursor is on top of the first character of this text
34 , _textZipper_linesAfter :: [Text]
38-- | Move the cursor left one character, if possible
39left :: TextZipper -> TextZipper
42-- | Move the cursor left by the given number of characters, or, if the document
43-- isn't long enough, to the beginning of the document
44leftN :: Int -> TextZipper -> TextZipper
45leftN n z@(TextZipper lb b a la) =
48 let n' = T.length b - n
49 in TextZipper lb (T.take n' b) (T.drop n' b <> a) la
52 (l:ls) -> leftN (n - T.length b - 1) $ TextZipper ls l "" ((b <> a) : la)
54-- | Move the cursor right one character, if possible
55right :: TextZipper -> TextZipper
58-- | Move the character right by the given number of characters, or, if the document
59-- isn't long enough, to the end of the document
60rightN :: Int -> TextZipper -> TextZipper
61rightN n z@(TextZipper lb b a la) =
63 then TextZipper lb (b <> T.take n a) (T.drop n a) la
66 (l:ls) -> rightN (n - T.length a - 1) $ TextZipper ((b <> a) : lb) "" l ls
68-- | Move the cursor up one logical line, if possible
69up :: TextZipper -> TextZipper
70up z@(TextZipper lb b a la) = case lb of
73 let (b', a') = T.splitAt (T.length b) l
74 in TextZipper ls b' a' ((b <> a) : la)
76-- | Move the cursor down one logical line, if possible
77down :: TextZipper -> TextZipper
78down z@(TextZipper lb b a la) = case la of
81 let (b', a') = T.splitAt (T.length b) l
82 in TextZipper ((b <> a) : lb) b' a' ls
84-- | Move the cursor up by the given number of lines
85pageUp :: Int -> TextZipper -> TextZipper
86pageUp pageSize z = if pageSize <= 0
88 else pageUp (pageSize - 1) $ up z
90-- | Move the cursor down by the given number of lines
91pageDown :: Int -> TextZipper -> TextZipper
92pageDown pageSize z = if pageSize <= 0
94 else pageDown (pageSize - 1) $ down z
96-- | Move the cursor to the beginning of the current logical line
97home :: TextZipper -> TextZipper
98home (TextZipper lb b a la) = TextZipper lb "" (b <> a) la
100-- | Move the cursor to the end of the current logical line
101end :: TextZipper -> TextZipper
102end (TextZipper lb b a la) = TextZipper lb (b <> a) "" la
104-- | Move the cursor to the top of the document
105top :: TextZipper -> TextZipper
106top (TextZipper lb b a la) = case reverse lb of
107 [] -> TextZipper [] "" (b <> a) la
108 (start:rest) -> TextZipper [] "" start (rest <> [b <> a] <> la)
110-- | Insert a character at the current cursor position
111insertChar :: Char -> TextZipper -> TextZipper
112insertChar i = insert (T.singleton i)
114-- | Insert text at the current cursor position
115insert :: Text -> TextZipper -> TextZipper
116insert i z@(TextZipper lb b a la) = case T.split (=='\n') i of
118 (start:rest) -> case reverse rest of
119 [] -> TextZipper lb (b <> start) a la
120 (l:ls) -> TextZipper (ls <> [b <> start] <> lb) l a la
122-- | Delete the character to the left of the cursor
123deleteLeft :: TextZipper-> TextZipper
124deleteLeft z@(TextZipper lb b a la) = case T.unsnoc b of
125 Nothing -> case lb of
127 (l:ls) -> TextZipper ls l a la
128 Just (b', _) -> TextZipper lb b' a la
130-- | Delete the character under/to the right of the cursor
131deleteRight :: TextZipper -> TextZipper
132deleteRight z@(TextZipper lb b a la) = case T.uncons a of
133 Nothing -> case la of
135 (l:ls) -> TextZipper lb b l ls
136 Just (_, a') -> TextZipper lb b a' la
138-- | Delete a word to the left of the cursor. Deletes all whitespace until it
139-- finds a non-whitespace character, and then deletes contiguous non-whitespace
141deleteLeftWord :: TextZipper -> TextZipper
142deleteLeftWord (TextZipper lb b a la) =
143 let b' = T.dropWhileEnd isSpace b
146 [] -> TextZipper [] b' a la
147 (l:ls) -> deleteLeftWord $ TextZipper ls l a la
148 else TextZipper lb (T.dropWhileEnd (not . isSpace) b') a la
150-- | Insert up to n spaces to get to the next logical column that is a multiple of n
151tab :: Int -> TextZipper -> TextZipper
152tab n z@(TextZipper _ b _ _) =
153 insert (T.replicate (fromEnum $ n - (T.length b `mod` n)) " ") z
155-- | The plain text contents of the zipper
156value :: TextZipper -> Text
157value (TextZipper lb b a la) = T.intercalate "\n" $ mconcat [ reverse lb
164empty = TextZipper [] "" "" []
166-- | Constructs a zipper with the given contents. The cursor is placed after
168fromText :: Text -> TextZipper
169fromText = flip insert empty
171-- | A span of text tagged with some metadata that makes up part of a display
173data Span tag = Span tag Text
176-- | Information about the document as it is displayed (i.e., post-wrapping)
177data DisplayLines tag = DisplayLines
178 { _displayLines_spans :: [[Span tag]]
179 , _displayLines_offsetMap :: Map Int Int
180 , _displayLines_cursorY :: Int
184-- | Given a width and a 'TextZipper', produce a list of display lines
185-- (i.e., lines of wrapped text) with special attributes applied to
186-- certain segments (e.g., the cursor). Additionally, produce the current
187-- y-coordinate of the cursor and a mapping from display line number to text
190 :: Int -- ^ Width, used for wrapping
191 -> tag -- ^ Metadata for normal characters
192 -> tag -- ^ Metadata for the cursor
193 -> TextZipper -- ^ The text input contents and cursor state
195displayLines width tag cursorTag (TextZipper lb b a la) =
196 let linesBefore :: [[Text]] -- The wrapped lines before the cursor line
197 linesBefore = map (wrapWithOffset width 0) $ reverse lb
198 linesAfter :: [[Text]] -- The wrapped lines after the cursor line
199 linesAfter = map (wrapWithOffset width 0) la
200 offsets :: Map Int Int
201 offsets = offsetMap $ mconcat
203 , [wrapWithOffset width 0 $ b <> a]
206 spansBefore = map ((:[]) . Span tag) $ concat linesBefore
207 spansAfter = map ((:[]) . Span tag) $ concat linesAfter
208 -- Separate the spans before the cursor into
209 -- * spans that are on earlier display lines (though on the same logical line), and
210 -- * spans that are on the same display line
211 (spansCurrentBefore, spansCurLineBefore) = fromMaybe ([], []) $
212 initLast $ map ((:[]) . Span tag) (wrapWithOffset width 0 b)
213 -- Calculate the number of columns on the cursor's display line before the cursor
214 curLineOffset = spansLength spansCurLineBefore
215 -- Check whether the spans on the current display line are long enough that
216 -- the cursor has to go to the next line
217 cursorAfterEOL = curLineOffset == width
218 -- Separate the span after the cursor into
219 -- * spans that are on the same display line, and
220 -- * spans that are on later display lines (though on the same logical line)
221 (spansCurLineAfter, spansCurrentAfter) = fromMaybe ([], []) $
222 headTail $ case T.uncons a of
223 Nothing -> [[Span cursorTag " "]]
225 let o = if cursorAfterEOL then 1 else curLineOffset + 1
226 cursor = Span cursorTag (T.singleton c)
227 in case map ((:[]) . Span tag) (wrapWithOffset width o rest) of
229 (l:ls) -> (cursor : l) : ls
231 { _displayLines_spans = concat
235 then [ spansCurLineBefore, spansCurLineAfter ]
236 else [ spansCurLineBefore <> spansCurLineAfter ]
240 , _displayLines_offsetMap = offsets
241 , _displayLines_cursorY = sum
243 , length spansCurrentBefore
244 , if cursorAfterEOL then 1 else 0
248 initLast :: [a] -> Maybe ([a], a)
251 (x:xs) -> case initLast xs of
252 Nothing -> Just ([], x)
253 Just (ys, y) -> Just (x:ys, y)
254 headTail :: [a] -> Maybe (a, [a])
259-- | Wraps a logical line of text to fit within the given width. The first
260-- wrapped line is offset by the number of columns provided. Subsequent wrapped
262wrapWithOffset :: Int -> Int -> Text -> [Text]
263wrapWithOffset maxWidth _ _ | maxWidth <= 0 = []
264wrapWithOffset maxWidth n xs =
265 let (firstLine, rest) = T.splitAt (maxWidth - n) xs
266 in firstLine : (fmap (T.take maxWidth) . takeWhile (not . T.null) . iterate (T.drop maxWidth) $ rest)
268-- | For a given set of wrapped logical lines, computes a map
269-- from display line index to text offset in the original text.
270-- This is used to help determine how interactions with the displayed
271-- text map back to the original text.
272-- For example, given the document @\"AA\\nBBB\\nCCCCCCCC\\n\"@ wrapped to 5 columns,
273-- this function will compute the offset in the original document of each character
278-- > CCCCC (2, 7) -- (this line wraps to the next row)
282 :: [[Text]] -- ^ The outer list represents logical lines, and the
283 -- inner list represents the display lines into which
284 -- the logical line has been wrapped
285 -> Map Int Int -- ^ A map from the index (row) of display line to
286 -- the text offset from the beginning of the document
287 -- to the first character of the display line
288offsetMap ts = evalState (offsetMap' ts) (0, 0)
290 offsetMap' xs = fmap Map.unions $ forM xs $ \x -> do
291 maps <- forM x $ \line -> do
292 let l = T.length line
295 return $ Map.singleton dl o
298 return $ Map.insert dl (o + 1) $ Map.unions maps
300-- | Move the cursor of the given 'TextZipper' to the logical position indicated
301-- by the given display line coordinates, using the provided 'DisplayLines'
302-- information. If the x coordinate is beyond the end of a line, the cursor is
303-- moved to the end of the line.
304goToDisplayLinePosition :: Int -> Int -> DisplayLines tag -> TextZipper -> TextZipper
305goToDisplayLinePosition x y dl tz =
306 let offset = Map.lookup y $ _displayLines_offsetMap dl
310 let displayLineLength = case drop y $ _displayLines_spans dl of
312 (s:_) -> spansLength s
313 in rightN (o + (min displayLineLength x)) $ top tz
315-- | Get the length of the text in a set of 'Span's
316spansLength :: [Span tag] -> Int
317spansLength = sum . map (\(Span _ t) -> T.length t)
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list