diff --git a/changelog.md b/changelog.md
--- a/changelog.md
+++ b/changelog.md
@@ -1,3 +1,8 @@
+# 0.2
+
+* Share `TextLines` between `Char` and UTF-16 modules.
+* Add `Data.Text.Utf16.Rope.Mixed`.
+
 # 0.1
 
 * Initial release.
diff --git a/src/Data/Text/Lines/Internal.hs b/src/Data/Text/Lines/Internal.hs
--- a/src/Data/Text/Lines/Internal.hs
+++ b/src/Data/Text/Lines/Internal.hs
@@ -30,14 +30,17 @@
   , intToWord
   ) where
 
-import Prelude ((+), (-), subtract, quot, fromIntegral, seq)
+import Prelude ((+), (-), (*), subtract, quot, fromIntegral, seq, error)
 import Control.DeepSeq (NFData, rnf)
 import Data.Bits (toIntegralSized)
-import Data.Bool (Bool, otherwise)
+import Data.Bool (Bool, otherwise, not)
 import Data.Char (Char)
 import Data.Eq (Eq, (==))
+import Data.Foldable (foldMap)
 import Data.Function (on, (.), ($))
 import Data.Int (Int)
+import Data.List (map, mapAccumL, filter)
+import Data.List.NonEmpty (NonEmpty(..))
 import Data.Maybe (Maybe(..))
 import Data.Monoid (Monoid(..))
 import Data.Ord (Ord, compare, (<=), (<), (>))
@@ -46,6 +49,7 @@
 import qualified Data.Text.Array as TA
 import Data.Text.Internal (Text(..))
 import qualified Data.Text as T
+import Data.Tuple (snd)
 import qualified Data.Vector.Unboxed as U
 import Data.Word (Word)
 import Foreign.C.Types (CSize(..))
@@ -57,13 +61,11 @@
 
 #if MIN_VERSION_text(2,0,0)
 #else
-import Prelude ((*))
 import Data.Bits (shiftR)
 #endif
 
 #ifdef DEBUG
-import Prelude (error)
-import Data.Bool ((&&), not)
+import Data.Bool ((&&))
 import Data.Char (generalCategory, GeneralCategory(..))
 import Data.Eq ((/=))
 import Data.List ((++))
@@ -86,32 +88,27 @@
   }
 
 instance NFData TextLines where
-  rnf (TextLines a b) = rnf a `seq` rnf b
+  rnf = (`seq` ())
 
 instance Eq TextLines where
   (==) = (==) `on` toText
-  {-# INLINE (==) #-}
 
 instance Ord TextLines where
   compare = compare `on` toText
-  {-# INLINE compare #-}
 
 instance Show TextLines where
 #ifdef DEBUG
   show (TextLines x y) = "TextLines { " ++ showText x ++ ", " ++ show y ++ " }"
 #else
   show = show . toText
-  {-# INLINE show #-}
 #endif
 
 instance IsString TextLines where
   fromString = fromText . fromString
-  {-# INLINE fromString #-}
 
 -- | Create from 'Text', linear time.
 fromText :: HasCallStack => Text -> TextLines
 fromText t = textLines t (U.fromList $ nlIndices t)
-{-# INLINABLE fromText #-}
 
 nlIndices :: Text -> [Int]
 #if MIN_VERSION_text(2,0,0)
@@ -134,7 +131,6 @@
         delta = fromIntegral (unsafeDupablePerformIO $
           memchr (TA.aBA arr) (2 * fromIntegral n) (2 * fromIntegral (len + off - n))) `shiftR` 1
 #endif
-{-# INLINABLE nlIndices #-}
 
 foreign import ccall unsafe "_hs_text_lines_memchr0A" memchr
   :: ByteArray# -> CSize -> CSize -> IO CSsize
@@ -142,8 +138,18 @@
 -- | Check whether a text is empty, O(1).
 null :: TextLines -> Bool
 null = T.null . toText
-{-# INLINE null #-}
 
+concat :: [TextLines] -> TextLines
+concat ts = case ts' of
+  [] -> mempty
+  [x] -> x
+  _ -> textLines
+    (T.concat (map toText ts'))
+    (U.concat (snd (mapAccumL f 0 ts')))
+  where
+    ts' = filter (not . null) ts
+    f l (TextLines (Text _ off len) nls) = (l + len, U.map (+ (l - off)) nls)
+
 instance Semigroup TextLines where
   TextLines t1@(Text _ off1 len1) s1 <> TextLines t2@(Text _ off2 _) s2
     | T.null t1 = textLines t2 s2
@@ -152,15 +158,22 @@
       (t1 <> t2)
       (U.map (subtract off1) s1 <> U.map (+ (len1 - off2)) s2)
       -- This relies on specific implementation of instance Semigroup Text!
-  {-# INLINABLE (<>) #-}
-  -- TODO implement sconcat via Builder
 
+  sconcat (x :| xs) = concat (x : xs)
+
+  stimes 1 tl = tl
+  stimes n (TextLines t@(Text _ off len) nls)
+    | n == fromIntegral n' = textLines t' nls'
+    | otherwise = error "Data.Text.Lines: stimes argument is too large"
+    where
+      n' = fromIntegral n
+      t' = T.replicate n' t
+      nls' = foldMap (\i -> U.map (\j -> j - off + len * i) nls) [0..n'-1]
+
 instance Monoid TextLines where
   mempty = textLines mempty mempty
-  {-# INLINE mempty #-}
   mappend = (<>)
-  {-# INLINE mappend #-}
-  -- TODO implement mconcat via Builder
+  mconcat = concat
 
 -- | Equivalent to 'Data.List.length' . 'lines', but in O(1).
 --
@@ -202,7 +215,6 @@
     arrLen = off + len
     go i [] = [Text arr i (arrLen - i) | i < arrLen]
     go i (x : xs) = Text arr i (x - i) : go (x + 1) xs
-{-# INLINABLE lines #-}
 
 -- | Split at given line, O(1).
 --
@@ -212,7 +224,6 @@
 --
 splitAtLine :: HasCallStack => Word -> TextLines -> (TextLines, TextLines)
 splitAtLine k = splitAtPosition (Position k 0)
-{-# INLINE splitAtLine #-}
 
 -------------------------------------------------------------------------------
 -- Unicode code points
@@ -228,7 +239,6 @@
 --
 length :: TextLines -> Word
 length = intToWord . T.length . toText
-{-# INLINE length #-}
 
 -- | Represent a position in a text.
 data Position = Position
@@ -237,7 +247,7 @@
   } deriving (Eq, Ord, Show)
 
 instance NFData Position where
-  rnf (Position a b) = rnf a `seq` rnf b
+  rnf = (`seq` ())
 
 -- | Associativity does not hold when 'posLine' overflows.
 instance Semigroup Position where
@@ -268,7 +278,6 @@
   }
   where
     nl = if U.null nls then off else U.last nls + 1
-{-# INLINABLE lengthAsPosition #-}
 
 -- | Span by a predicate, similar to @Data.Text.@'Data.Text.span'.
 -- Takes linear (by length of the prefix satisfying the predicate) time.
@@ -286,7 +295,6 @@
     n = binarySearch nls (off' + len')
     y = textLines (Text arr off (off' + len' - off)) (U.take n nls)
     z = textLines tz (U.drop n nls)
-{-# INLINABLE span #-}
 
 -- | Combination of 'splitAtLine' and subsequent 'splitAt'.
 -- Time is linear in 'posColumn', but does not depend on 'posLine'.
@@ -327,7 +335,6 @@
     n = binarySearch nls (off' + len')
     y = textLines (Text arr off (off' + len' - off)) (U.take n nls)
     z = textLines tz (U.drop n nls)
-{-# INLINABLE splitAtPosition #-}
 
 -- | Split at given code point, similar to @Data.Text.@'Data.Text.splitAt'.
 -- Takes linear time.
@@ -338,7 +345,6 @@
 --
 splitAt :: HasCallStack => Word -> TextLines -> (TextLines, TextLines)
 splitAt = splitAtPosition . Position 0
-{-# INLINE splitAt #-}
 
 -------------------------------------------------------------------------------
 -- Utils
@@ -361,15 +367,12 @@
       where
         k = (i + j) `quot` 2
 {-# SPECIALIZE binarySearch :: U.Vector Int -> Int -> Int #-}
-{-# INLINABLE binarySearch #-}
 
 intToWord :: Int -> Word
 intToWord = fromIntegral
-{-# INLINE intToWord #-}
 
 wordToInt :: Word -> Int
 wordToInt = fromIntegral
-{-# INLINE wordToInt #-}
 
 -------------------------------------------------------------------------------
 -- Debug
@@ -399,6 +402,5 @@
 
 textLines :: HasCallStack => Text -> U.Vector Int -> TextLines
 textLines = TextLines
-{-# INLINE textLines #-}
 
 #endif
diff --git a/src/Data/Text/Rope.hs b/src/Data/Text/Rope.hs
--- a/src/Data/Text/Rope.hs
+++ b/src/Data/Text/Rope.hs
@@ -41,7 +41,7 @@
 import Data.Bool (Bool(..), otherwise)
 import Data.Char (Char)
 import Data.Eq (Eq, (==))
-import Data.Function ((.), ($), on, id)
+import Data.Function ((.), ($), on)
 import Data.Maybe (Maybe(..))
 import Data.Monoid (Monoid(..))
 import Data.Ord (Ord, compare, (<), (<=), Ordering(..))
@@ -65,13 +65,23 @@
 #endif
 
 -- | Rope of 'Text' chunks with logarithmic concatenation.
+-- This rope offers an interface, based on code points.
+-- Use "Data.Text.Utf16.Rope", if you need UTF-16 code units,
+-- or "Data.Text.Utf16.Rope.Mixed", if you need both interfaces.
 data Rope
   = Empty
-  | Node !Rope !TL.TextLines !Rope !Word !Position
+  | Node
+    { _ropeLeft         :: !Rope
+    , _ropeMiddle       :: !TL.TextLines
+    , _ropeRight        :: !Rope
+    , _ropeCharLen      :: !Word
+    , _ropeCharLenAsPos :: !Position
+    }
 
 instance NFData Rope where
   rnf Empty = ()
-  rnf (Node a b c d e) = rnf a `seq` rnf b `seq` rnf c `seq` rnf d `seq` rnf e
+  -- No need to deepseq strict fields, for which WHNF = NF
+  rnf (Node l _ r _ _) = rnf l `seq` rnf r
 
 instance Eq Rope where
   (==) = (==) `on` toLazyText
@@ -238,6 +248,9 @@
 -- >>> lengthInLines "foo\nbar"
 -- 2
 --
+-- If you do not care about ignoring the last newline character,
+-- you can use 'posLine' . 'lengthAsPosition' instead, which works in O(1).
+--
 lengthInLines :: Rope -> Word
 lengthInLines rp = case lastChar rp of
   Nothing -> 0
@@ -245,8 +258,7 @@
 
 -- | Glue chunks into 'TL.TextLines', linear time.
 toTextLines :: Rope -> TL.TextLines
-toTextLines = foldMapRope id
--- TODO make it fast! implement via mconcat for TextLines
+toTextLines = mconcat . foldMapRope (:[])
 
 toLazyText :: Rope -> TextLazy.Text
 toLazyText = foldMapRope (TextLazy.fromStrict . TL.toText)
diff --git a/src/Data/Text/Utf16/Lines.hs b/src/Data/Text/Utf16/Lines.hs
--- a/src/Data/Text/Utf16/Lines.hs
+++ b/src/Data/Text/Utf16/Lines.hs
@@ -10,14 +10,14 @@
 {-# LANGUAGE UnliftedFFITypes #-}
 
 module Data.Text.Utf16.Lines
-  ( TextLines
-  , fromText
-  , toText
-  , null
+  ( I.TextLines
+  , I.fromText
+  , I.toText
+  , I.null
   -- * Lines
-  , lines
-  , lengthInLines
-  , splitAtLine
+  , I.lines
+  , I.lengthInLines
+  , I.splitAtLine
   -- * UTF-16 code units
   , length
   , splitAt
@@ -28,21 +28,19 @@
 
 import Prelude ((+), (-), seq)
 import Control.DeepSeq (NFData, rnf)
-import Data.Bool (Bool, otherwise)
-import Data.Coerce (coerce)
+import Data.Bool (otherwise)
 import Data.Eq (Eq, (==))
 import Data.Function ((.), ($))
 import Data.Maybe (Maybe(..))
 import Data.Monoid (Monoid(..))
 import Data.Ord (Ord, (<=), (>), (>=))
 import Data.Semigroup (Semigroup(..))
-import Data.String (IsString)
 import qualified Data.Text.Array as TA
 import Data.Text.Internal (Text(..))
 import qualified Data.Text.Lines.Internal as I
 import qualified Data.Vector.Unboxed as U
 import Data.Word (Word)
-import Text.Show (Show, show)
+import Text.Show (Show)
 
 #if MIN_VERSION_text(2,0,0)
 import Prelude (fromIntegral)
@@ -62,79 +60,6 @@
 #define HasCallStack ()
 #endif
 
--- | A wrapper around 'Text' for fast line/column navigation.
--- Concatenation takes linear time.
---
--- This is a building block for 'Data.Text.Utf16.Rope.Rope',
--- which provides logarithmic concatenation.
-newtype TextLines = TextLines I.TextLines
-  deriving (Eq, Ord, IsString, Semigroup, Monoid, NFData)
-
-instance Show TextLines where
-  show (TextLines t) = show t
-
--- | Create from 'Text', linear time.
-fromText :: HasCallStack => Text -> TextLines
-fromText = coerce I.fromText
-{-# INLINE fromText #-}
-
--- | Extract 'Text', O(1).
-toText :: TextLines -> Text
-toText = coerce I.toText
-{-# INLINE toText #-}
-
--- | Check whether a text is empty, O(1).
-null :: TextLines -> Bool
-null = coerce I.null
-{-# INLINE null #-}
-
--- | Split into lines by @\\n@, similar to @Data.Text.@'Data.Text.lines'.
--- Each line is produced in O(1).
---
--- >>> :set -XOverloadedStrings
--- >>> lines ""
--- []
--- >>> lines "foo"
--- ["foo"]
--- >>> lines "foo\n"
--- ["foo"]
--- >>> lines "foo\n\n"
--- ["foo",""]
--- >>> lines "foo\nbar"
--- ["foo","bar"]
---
-lines :: TextLines -> [Text]
-lines = coerce I.lines
-{-# INLINE lines #-}
-
--- | Equivalent to 'Data.List.length' . 'lines', but in O(1).
---
--- >>> :set -XOverloadedStrings
--- >>> lengthInLines ""
--- 0
--- >>> lengthInLines "foo"
--- 1
--- >>> lengthInLines "foo\n"
--- 1
--- >>> lengthInLines "foo\n\n"
--- 2
--- >>> lengthInLines "foo\nbar"
--- 2
---
-lengthInLines :: TextLines -> Word
-lengthInLines = coerce I.lengthInLines
-{-# INLINE lengthInLines #-}
-
--- | Split at given line, O(1).
---
--- >>> :set -XOverloadedStrings
--- >>> map (\l -> splitAtLine l "foo\nbar") [0..3]
--- [("","foo\nbar"),("foo\n","bar"),("foo\nbar",""),("foo\nbar","")]
---
-splitAtLine :: HasCallStack => Word -> TextLines -> (TextLines, TextLines)
-splitAtLine = coerce I.splitAtLine
-{-# INLINE splitAtLine #-}
-
 lengthTextUtf16 :: Text -> Word
 #if MIN_VERSION_text(2,0,0)
 lengthTextUtf16 (Text (TA.ByteArray arr) off len) = fromIntegral $ unsafeDupablePerformIO $
@@ -145,7 +70,6 @@
 #else
 lengthTextUtf16 (Text _ _ len) = I.intToWord len
 #endif
-{-# INLINABLE lengthTextUtf16 #-}
 
 -- | Length in UTF-16 code units.
 -- Takes linear time.
@@ -156,9 +80,8 @@
 -- >>> Data.Text.Lines.length "fя𐀀"
 -- 3
 --
-length :: TextLines -> Word
-length = lengthTextUtf16 . toText
-{-# INLINE length #-}
+length :: I.TextLines -> Word
+length = lengthTextUtf16 . I.toText
 
 -- | Represent a position in a text.
 data Position = Position
@@ -167,7 +90,7 @@
   } deriving (Eq, Ord, Show)
 
 instance NFData Position where
-  rnf (Position a b) = rnf a `seq` rnf b
+  rnf = (`seq` ())
 
 -- | Associativity does not hold when 'posLine' overflows.
 instance Semigroup Position where
@@ -190,15 +113,14 @@
 -- Position {posLine = 2, posColumn = 0}
 --
 lengthAsPosition
-  :: TextLines
+  :: I.TextLines
   -> Position
-lengthAsPosition (TextLines (I.TextLines (Text arr off len) nls)) = Position
+lengthAsPosition (I.TextLines (Text arr off len) nls) = Position
   { posLine = I.intToWord $ U.length nls
   , posColumn = lengthTextUtf16 $ Text arr nl (off + len - nl)
   }
   where
     nl = if U.null nls then off else U.last nls + 1
-{-# INLINABLE lengthAsPosition #-}
 
 splitTextAtUtf16Index :: Word -> Text -> Maybe (Text, Text)
 splitTextAtUtf16Index k t@(Text arr off len)
@@ -222,9 +144,8 @@
       k' = I.wordToInt k
       c = TA.unsafeIndex arr (off + k')
 #endif
-{-# INLINABLE splitTextAtUtf16Index #-}
 
--- | Combination of 'splitAtLine' and subsequent 'splitAt'.
+-- | Combination of 'I.splitAtLine' and subsequent 'splitAt'.
 -- If requested number of code units splits a code point in half, return 'Nothing'.
 -- Time is linear in 'posColumn', but does not depend on 'posLine'.
 --
@@ -245,14 +166,14 @@
 splitAtPosition
   :: HasCallStack
   => Position
-  -> TextLines
-  -> Maybe (TextLines, TextLines)
-splitAtPosition (Position line column) (TextLines (I.TextLines (Text arr off len) nls)) =
+  -> I.TextLines
+  -> Maybe (I.TextLines, I.TextLines)
+splitAtPosition (Position line column) (I.TextLines (Text arr off len) nls) =
   case splitTextAtUtf16Index column tx of
     Nothing -> Nothing
     Just (Text _ off' len', tz) -> let n = I.binarySearch nls (off' + len') in Just
-      ( TextLines $ I.textLines (Text arr off (off' + len' - off)) (U.take n nls)
-      , TextLines $ I.textLines tz (U.drop n nls))
+      ( I.textLines (Text arr off (off' + len' - off)) (U.take n nls)
+      , I.textLines tz (U.drop n nls))
   where
     arrLen = off + len
     nl
@@ -260,7 +181,6 @@
       | line > I.intToWord (U.length nls) = arrLen
       | otherwise = nls U.! (I.wordToInt line - 1) + 1
     tx = Text arr nl (arrLen - nl)
-{-# INLINABLE splitAtPosition #-}
 
 -- | Split at given UTF-16 code unit.
 -- If requested number of code units splits a code point in half, return 'Nothing'.
@@ -270,6 +190,5 @@
 -- >>> map (\c -> splitAt c "fя𐀀") [0..4]
 -- [Just ("","fя𐀀"),Just ("f","я𐀀"),Just ("fя","𐀀"),Nothing,Just ("fя𐀀","")]
 --
-splitAt :: HasCallStack => Word -> TextLines -> Maybe (TextLines, TextLines)
+splitAt :: HasCallStack => Word -> I.TextLines -> Maybe (I.TextLines, I.TextLines)
 splitAt = splitAtPosition . Position 0
-{-# INLINE splitAt #-}
diff --git a/src/Data/Text/Utf16/Rope.hs b/src/Data/Text/Utf16/Rope.hs
--- a/src/Data/Text/Utf16/Rope.hs
+++ b/src/Data/Text/Utf16/Rope.hs
@@ -41,7 +41,7 @@
 import Data.Bool (Bool(..), otherwise)
 import Data.Char (Char)
 import Data.Eq (Eq, (==))
-import Data.Function ((.), ($), on, id)
+import Data.Function ((.), ($), on)
 import Data.Maybe (Maybe(..))
 import Data.Monoid (Monoid(..))
 import Data.Ord (Ord, compare, (<), (<=), Ordering(..))
@@ -65,13 +65,23 @@
 #endif
 
 -- | Rope of 'Text' chunks with logarithmic concatenation.
+-- This rope offers an interface, based on UTF-16 code units.
+-- Use "Data.Text.Rope", if you need code points,
+-- or "Data.Text.Utf16.Rope.Mixed", if you need both interfaces.
 data Rope
   = Empty
-  | Node !Rope !TL.TextLines !Rope !Word !Position
+  | Node
+    { _ropeLeft          :: !Rope
+    , _ropeMiddle        :: !TL.TextLines
+    , _ropeRight         :: !Rope
+    , _ropeUtf16Len      :: !Word
+    , _ropeUtf16LenAsPos :: !Position
+    }
 
 instance NFData Rope where
   rnf Empty = ()
-  rnf (Node a b c d e) = rnf a `seq` rnf b `seq` rnf c `seq` rnf d `seq` rnf e
+  -- No need to deepseq strict fields, for which WHNF = NF
+  rnf (Node l _ r _ _) = rnf l `seq` rnf r
 
 instance Eq Rope where
   (==) = (==) `on` toLazyText
@@ -237,6 +247,9 @@
 -- >>> lengthInLines "foo\nbar"
 -- 2
 --
+-- If you do not care about ignoring the last newline character,
+-- you can use 'posLine' . 'lengthAsPosition' instead, which works in O(1).
+--
 lengthInLines :: Rope -> Word
 lengthInLines rp = case lastChar rp of
   Nothing -> 0
@@ -244,8 +257,7 @@
 
 -- | Glue chunks into 'TL.TextLines', linear time.
 toTextLines :: Rope -> TL.TextLines
-toTextLines = foldMapRope id
--- TODO make it fast! implement via mconcat for TextLines
+toTextLines = mconcat . foldMapRope (:[])
 
 toLazyText :: Rope -> TextLazy.Text
 toLazyText = foldMapRope (TextLazy.fromStrict . TL.toText)
diff --git a/src/Data/Text/Utf16/Rope/Mixed.hs b/src/Data/Text/Utf16/Rope/Mixed.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/Text/Utf16/Rope/Mixed.hs
@@ -0,0 +1,491 @@
+-- |
+-- Copyright:   (c) 2021-2022 Andrew Lelechenko
+-- Licence:     BSD3
+-- Maintainer:  Andrew Lelechenko <andrew.lelechenko@gmail.com>
+
+{-# LANGUAGE BangPatterns #-}
+{-# LANGUAGE CPP #-}
+{-# LANGUAGE GeneralizedNewtypeDeriving #-}
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE MultiParamTypeClasses #-}
+{-# LANGUAGE StandaloneDeriving #-}
+{-# LANGUAGE TupleSections #-}
+
+#ifdef DEBUG
+#define DEFRAGMENTATION_THRESHOLD 4
+#else
+#define DEFRAGMENTATION_THRESHOLD 4096
+#endif
+
+module Data.Text.Utf16.Rope.Mixed
+  ( Rope
+  , fromText
+  , fromTextLines
+  , toText
+  , toTextLines
+  , null
+  -- * Lines
+  , lines
+  , lengthInLines
+  , splitAtLine
+  -- * Code points
+  , charLength
+  , charSplitAt
+  , charLengthAsPosition
+  , charSplitAtPosition
+  -- * UTF-16 code units
+  , utf16Length
+  , utf16SplitAt
+  , utf16LengthAsPosition
+  , utf16SplitAtPosition
+  ) where
+
+import Prelude ((-), (+), seq)
+import Control.DeepSeq (NFData, rnf)
+import Data.Bool (Bool(..), otherwise)
+import Data.Char (Char)
+import Data.Eq (Eq, (==))
+import Data.Function ((.), ($), on)
+import Data.Maybe (Maybe(..))
+import Data.Monoid (Monoid(..))
+import Data.Ord (Ord, compare, (<), (<=), Ordering(..))
+import Data.Semigroup (Semigroup(..))
+import Data.String (IsString(..))
+import Data.Text (Text)
+import qualified Data.Text as T
+import qualified Data.Text.Lazy as TextLazy
+import qualified Data.Text.Lazy.Builder as Builder
+import Data.Text.Lines.Internal (TextLines)
+import qualified Data.Text.Lines.Internal as TL (null, fromText, toText, lines, splitAtLine)
+import qualified Data.Text.Lines as Char
+import qualified Data.Text.Utf16.Lines as Utf16
+import Data.Word (Word)
+import Text.Show (Show)
+
+#ifdef DEBUG
+import Prelude (error)
+import GHC.Stack (HasCallStack)
+#else
+#define HasCallStack ()
+import Text.Show (show)
+#endif
+
+-- | Rope of 'Text' chunks with logarithmic concatenation.
+-- This rope offers two interfaces: one based on code points
+-- and another one based on UTF-16 code units. This comes with a price
+-- of double bookkeeping and is less performant than "Data.Text.Rope"
+-- or "Data.Text.Utf16.Rope".
+data Rope
+  = Empty
+  | Node
+    { _ropeLeft          :: !Rope
+    , _ropeMiddle        :: !TextLines
+    , _ropeRight         :: !Rope
+    , _ropeCharLen       :: !Word
+    , _ropeCharLenAsPos  :: !Char.Position
+    , _ropeUtf16Len      :: !Word
+    , _ropeUtf16LenAsPos :: !Utf16.Position
+    }
+
+instance NFData Rope where
+  rnf Empty = ()
+  -- No need to deepseq strict fields, for which WHNF = NF
+  rnf (Node l _ r _ _ _ _) = rnf l `seq` rnf r
+
+instance Eq Rope where
+  (==) = (==) `on` toLazyText
+
+instance Ord Rope where
+  compare = compare `on` toLazyText
+
+#ifdef DEBUG
+deriving instance Show Rope
+#else
+instance Show Rope where
+  show = show . toLazyText
+#endif
+
+instance IsString Rope where
+  fromString = fromTextLines . fromString
+
+-- | Check whether a rope is empty, O(1).
+null :: Rope -> Bool
+null = \case
+  Empty -> True
+  Node{} -> False
+
+-- | Length in code points, similar to @Data.Text.@'Data.Text.length', O(1).
+--
+-- >>> :set -XOverloadedStrings
+-- >>> charLength "fя𐀀"
+-- 3
+--
+charLength :: Rope -> Word
+charLength = \case
+  Empty -> 0
+  Node _ _ _ w _ _ _ -> w
+
+-- | Length in UTF-16 code units, O(1).
+--
+-- >>> :set -XOverloadedStrings
+-- >>> utf16Length "fя𐀀"
+-- 4
+--
+utf16Length :: Rope -> Word
+utf16Length = \case
+  Empty -> 0
+  Node _ _ _ _ _ w _ -> w
+
+-- | Measure text length as an amount of lines and columns, O(1).
+--
+-- >>> :set -XOverloadedStrings
+-- >>> charLengthAsPosition "f𐀀"
+-- Position {posLine = 0, posColumn = 2}
+-- >>> charLengthAsPosition "f\n𐀀"
+-- Position {posLine = 1, posColumn = 1}
+-- >>> charLengthAsPosition "f\n𐀀\n"
+-- Position {posLine = 2, posColumn = 0}
+--
+charLengthAsPosition :: Rope -> Char.Position
+charLengthAsPosition = \case
+  Empty -> mempty
+  Node _ _ _ _ p _ _ -> p
+
+-- | Measure text length as an amount of lines and columns, O(1).
+--
+-- >>> :set -XOverloadedStrings
+-- >>> utf16LengthAsPosition "f𐀀"
+-- Position {posLine = 0, posColumn = 3}
+-- >>> utf16LengthAsPosition "f\n𐀀"
+-- Position {posLine = 1, posColumn = 2}
+-- >>> utf16LengthAsPosition "f\n𐀀\n"
+-- Position {posLine = 2, posColumn = 0}
+--
+utf16LengthAsPosition :: Rope -> Utf16.Position
+utf16LengthAsPosition = \case
+  Empty -> mempty
+  Node _ _ _ _ _ _ p -> p
+
+instance Semigroup Rope where
+  Empty <> t = t
+  t <> Empty = t
+  Node l1 c1 r1 u1 p1 u1' p1' <> Node l2 c2 r2 u2 p2 u2' p2' = defragment
+    l1
+    c1
+    (Node (r1 <> l2) c2 r2 (charLength r1 + u2) (charLengthAsPosition r1 <> p2) (utf16Length r1 + u2') (utf16LengthAsPosition r1 <> p2'))
+    (u1 + u2)
+    (p1 <> p2)
+    (u1' + u2')
+    (p1' <> p2')
+
+instance Monoid Rope where
+  mempty = Empty
+  mappend = (<>)
+
+defragment :: HasCallStack => Rope -> TextLines -> Rope -> Word -> Char.Position -> Word -> Utf16.Position -> Rope
+defragment !l !c !r !u !p !u' !p'
+#ifdef DEBUG
+  | TL.null c = error "Data.Text.Lines: violated internal invariant"
+#endif
+  | u < DEFRAGMENTATION_THRESHOLD
+  = Node Empty (toTextLines rp) Empty u p u' p'
+  | otherwise
+  = rp
+  where
+    rp = Node l c r u p u' p'
+
+-- | Create from 'TextLines', linear time.
+fromTextLines :: TextLines -> Rope
+fromTextLines tl
+  | TL.null tl = Empty
+  | otherwise = Node Empty tl Empty (Char.length tl) (Char.lengthAsPosition tl) (Utf16.length tl) (Utf16.lengthAsPosition tl)
+
+node :: HasCallStack => Rope -> TextLines -> Rope -> Rope
+node l c r = defragment l c r totalLength totalLengthAsPosition totalLength' totalLengthAsPosition'
+  where
+    totalLength = charLength l + Char.length c + charLength r
+    totalLengthAsPosition = charLengthAsPosition l <> Char.lengthAsPosition c <> charLengthAsPosition r
+    totalLength' = utf16Length l + Utf16.length c + utf16Length r
+    totalLengthAsPosition' = utf16LengthAsPosition l <> Utf16.lengthAsPosition c <> utf16LengthAsPosition r
+
+(|>) :: Rope -> TextLines -> Rope
+tr |> tl
+  | TL.null tl = tr
+  | otherwise = node tr tl Empty
+
+(<|) :: TextLines -> Rope -> Rope
+tl <| tr
+  | TL.null tl = tr
+  | otherwise = node Empty tl tr
+
+-- | Create from 'Text', linear time.
+fromText :: Text -> Rope
+fromText = fromTextLines . TL.fromText
+
+foldMapRope :: Monoid a => (TextLines -> a) -> Rope -> a
+foldMapRope f = go
+  where
+    go = \case
+      Empty -> mempty
+      Node l c r _ _ _ _ -> go l `mappend` f c `mappend` go r
+
+data Lines = Lines ![Text] !Bool
+
+instance Semigroup Lines where
+  Lines [] _ <> ls = ls
+  ls <> Lines [] _ = ls
+  Lines xs x <> Lines ys y = Lines (if x then xs <> ys else go xs ys) y
+    where
+      go [] vs = vs
+      go [u] (v : vs) = (u <> v) : vs
+      go (u : us) vs = u : go us vs
+
+instance Monoid Lines where
+  mempty = Lines [] False
+  mappend = (<>)
+
+-- | Split into lines by @\\n@, similar to @Data.Text.@'Data.Text.lines'.
+-- Each line is produced in O(1).
+--
+-- >>> :set -XOverloadedStrings
+-- >>> lines ""
+-- []
+-- >>> lines "foo"
+-- ["foo"]
+-- >>> lines "foo\n"
+-- ["foo"]
+-- >>> lines "foo\n\n"
+-- ["foo",""]
+-- >>> lines "foo\nbar"
+-- ["foo","bar"]
+--
+lines :: Rope -> [Text]
+lines = (\(Lines ls _) -> ls) . foldMapRope
+  -- This assumes that there are no empty chunks:
+  (\tl -> Lines (TL.lines tl) (T.last (TL.toText tl) == '\n'))
+
+lastChar :: Rope -> Maybe Char
+lastChar = \case
+  Empty -> Nothing
+  -- This assumes that there are no empty chunks:
+  Node _ c Empty _ _ _ _ -> Just $ T.last $ TL.toText c
+  Node _ _ r _ _ _ _ -> lastChar r
+
+-- | Equivalent to 'Data.List.length' . 'lines', but in logarithmic time.
+--
+-- >>> :set -XOverloadedStrings
+-- >>> lengthInLines ""
+-- 0
+-- >>> lengthInLines "foo"
+-- 1
+-- >>> lengthInLines "foo\n"
+-- 1
+-- >>> lengthInLines "foo\n\n"
+-- 2
+-- >>> lengthInLines "foo\nbar"
+-- 2
+--
+-- If you do not care about ignoring the last newline character,
+-- you can use 'Char.posLine' . 'charLengthAsPosition' instead, which works in O(1).
+--
+lengthInLines :: Rope -> Word
+lengthInLines rp = case lastChar rp of
+  Nothing -> 0
+  Just ch -> Char.posLine (charLengthAsPosition rp) + (if ch == '\n' then 0 else 1)
+
+-- | Glue chunks into 'TextLines', linear time.
+toTextLines :: Rope -> TextLines
+toTextLines = mconcat . foldMapRope (:[])
+
+toLazyText :: Rope -> TextLazy.Text
+toLazyText = foldMapRope (TextLazy.fromStrict . TL.toText)
+
+-- | Glue chunks into 'Text', linear time.
+toText :: Rope -> Text
+toText = TextLazy.toStrict . Builder.toLazyText . foldMapRope (Builder.fromText . TL.toText)
+
+-- | Split at given code point, similar to @Data.Text.@'Data.Text.splitAt'.
+-- Takes linear time.
+--
+-- >>> :set -XOverloadedStrings
+-- >>> map (\c -> charSplitAt c "fя𐀀") [0..4]
+-- [("","fя𐀀"),("f","я𐀀"),("fя","𐀀"),("fя𐀀",""),("fя𐀀","")]
+--
+charSplitAt :: HasCallStack => Word -> Rope -> (Rope, Rope)
+charSplitAt !len = \case
+  Empty -> (Empty, Empty)
+  Node l c r _ _ _ _
+    | len <= ll -> case charSplitAt len l of
+        (before, after) -> (before, node after c r)
+    | len <= llc -> case Char.splitAt (len - ll) c of
+      (before, after) -> (l |> before, after <| r)
+    | otherwise -> case charSplitAt (len - llc) r of
+      (before, after) -> (node l c before, after)
+    where
+      ll = charLength l
+      llc = ll + Char.length c
+
+-- | Split at given UTF-16 code unit.
+-- If requested number of code units splits a code point in half, return 'Nothing'.
+-- Takes linear time.
+--
+-- >>> :set -XOverloadedStrings
+-- >>> map (\c -> utf16SplitAt c "fя𐀀") [0..4]
+-- [Just ("","fя𐀀"),Just ("f","я𐀀"),Just ("fя","𐀀"),Nothing,Just ("fя𐀀","")]
+--
+utf16SplitAt :: HasCallStack => Word -> Rope -> Maybe (Rope, Rope)
+utf16SplitAt !len = \case
+  Empty -> Just (Empty, Empty)
+  Node l c r _ _ _ _
+    | len <= ll -> case utf16SplitAt len l of
+        Nothing -> Nothing
+        Just (before, after) -> Just (before, node after c r)
+    | len <= llc -> case Utf16.splitAt (len - ll) c of
+      Nothing -> Nothing
+      Just (before, after) -> Just (l |> before, after <| r)
+    | otherwise -> case utf16SplitAt (len - llc) r of
+      Nothing -> Nothing
+      Just (before, after) -> Just (node l c before, after)
+    where
+      ll = utf16Length l
+      llc = ll + Utf16.length c
+
+-- | Split at given line, logarithmic time.
+--
+-- >>> :set -XOverloadedStrings
+-- >>> map (\l -> splitAtLine l "foo\nbar") [0..3]
+-- [("","foo\nbar"),("foo\n","bar"),("foo\nbar",""),("foo\nbar","")]
+--
+splitAtLine :: HasCallStack => Word -> Rope -> (Rope, Rope)
+splitAtLine !len = \case
+  Empty -> (Empty, Empty)
+  Node l c r _ _ _ _
+    | len <= ll -> case splitAtLine len l of
+      (before, after) -> (before, node after c r)
+    | len <= llc -> case TL.splitAtLine (len - ll) c of
+      (before, after) -> (l |> before, after <| r)
+    | otherwise -> case splitAtLine (len - llc) r of
+      (before, after) -> (node l c before, after)
+    where
+      -- posLine is the same both in Char.lengthAsPosition and Utf16.lengthAsPosition
+      ll = Char.posLine (charLengthAsPosition l)
+      llc = ll + Char.posLine (Char.lengthAsPosition c)
+
+charSubOnRope :: Rope -> Char.Position -> Char.Position -> Char.Position
+charSubOnRope rp (Char.Position xl xc) (Char.Position yl yc) = case xl `compare` yl of
+  GT -> Char.Position (xl - yl) xc
+  EQ -> Char.Position 0 (xc - yc)
+  LT -> Char.Position 0 (xc - charLength rp')
+  where
+    (_, rp') = splitAtLine xl rp
+
+utf16SubOnRope :: Rope -> Utf16.Position -> Utf16.Position -> Utf16.Position
+utf16SubOnRope rp (Utf16.Position xl xc) (Utf16.Position yl yc) = case xl `compare` yl of
+  GT -> Utf16.Position (xl - yl) xc
+  EQ -> Utf16.Position 0 (xc - yc)
+  LT -> Utf16.Position 0 (xc - utf16Length rp')
+  where
+    (_, rp') = splitAtLine xl rp
+
+charSubOnLines :: Char.TextLines -> Char.Position -> Char.Position -> Char.Position
+charSubOnLines tl (Char.Position xl xc) (Char.Position yl yc) = case xl `compare` yl of
+  GT -> Char.Position (xl - yl) xc
+  EQ -> Char.Position 0 (xc - yc)
+  LT -> Char.Position 0 (xc - Char.length tl')
+  where
+    (_, tl') = Char.splitAtLine xl tl
+
+utf16SubOnLines :: Utf16.TextLines -> Utf16.Position -> Utf16.Position -> Utf16.Position
+utf16SubOnLines tl (Utf16.Position xl xc) (Utf16.Position yl yc) = case xl `compare` yl of
+  GT -> Utf16.Position (xl - yl) xc
+  EQ -> Utf16.Position 0 (xc - yc)
+  LT -> Utf16.Position 0 (xc - Utf16.length tl')
+  where
+    (_, tl') = Utf16.splitAtLine xl tl
+
+-- | Combination of 'splitAtLine' and subsequent 'charSplitAt'.
+-- Time is linear in 'Char.posColumn' and logarithmic in 'Char.posLine'.
+--
+-- >>> :set -XOverloadedStrings
+-- >>> charSplitAtPosition (Position 1 0) "f\n𐀀я"
+-- ("f\n","𐀀я")
+-- >>> charSplitAtPosition (Position 1 1) "f\n𐀀я"
+-- ("f\n𐀀","я")
+-- >>> charSplitAtPosition (Position 1 2) "f\n𐀀я"
+-- ("f\n𐀀я","")
+-- >>> charSplitAtPosition (Position 0 2) "f\n𐀀я"
+-- ("f\n","𐀀я")
+-- >>> charSplitAtPosition (Position 0 3) "f\n𐀀я"
+-- ("f\n𐀀","я")
+-- >>> charSplitAtPosition (Position 0 4) "f\n𐀀я"
+-- ("f\n𐀀я","")
+--
+charSplitAtPosition :: HasCallStack => Char.Position -> Rope -> (Rope, Rope)
+charSplitAtPosition (Char.Position 0 0) = (mempty,)
+charSplitAtPosition !len = \case
+  Empty -> (Empty, Empty)
+  Node l c r _ _ _ _
+    | len <= ll -> case charSplitAtPosition len l of
+      (before, after)
+        | null after -> case charSplitAtPosition len' (c <| r) of
+          (r', r'') -> (l <> r', r'')
+        | otherwise -> (before, node after c r)
+    | len <= llc -> case Char.splitAtPosition len' c of
+      (before, after)
+        | TL.null after -> case charSplitAtPosition len'' r of
+          (r', r'') -> ((l |> c) <> r', r'')
+        | otherwise -> (l |> before, after <| r)
+    | otherwise -> case charSplitAtPosition len'' r of
+      (before, after) -> (node l c before, after)
+    where
+      ll = charLengthAsPosition l
+      lc = Char.lengthAsPosition c
+      llc = ll <> lc
+      len' = charSubOnRope l len ll
+      len'' = charSubOnLines c len' lc
+
+-- | Combination of 'splitAtLine' and subsequent 'utf16SplitAt'.
+-- Time is linear in 'Utf16.posColumn' and logarithmic in 'Utf16.posLine'.
+--
+-- >>> :set -XOverloadedStrings
+-- >>> utf16SplitAtPosition (Position 1 0) "f\n𐀀я"
+-- Just ("f\n","𐀀я")
+-- >>> utf16SplitAtPosition (Position 1 1) "f\n𐀀я"
+-- Nothing
+-- >>> utf16SplitAtPosition (Position 1 2) "f\n𐀀я"
+-- Just ("f\n𐀀","я")
+-- >>> utf16SplitAtPosition (Position 0 2) "f\n𐀀я"
+-- Just ("f\n","𐀀я")
+-- >>> utf16SplitAtPosition (Position 0 3) "f\n𐀀я"
+-- Nothing
+-- >>> utf16SplitAtPosition (Position 0 4) "f\n𐀀я"
+-- Just ("f\n𐀀","я")
+--
+utf16SplitAtPosition :: HasCallStack => Utf16.Position -> Rope -> Maybe (Rope, Rope)
+utf16SplitAtPosition (Utf16.Position 0 0) = Just . (mempty,)
+utf16SplitAtPosition !len = \case
+  Empty -> Just (Empty, Empty)
+  Node l c r _ _ _ _
+    | len <= ll -> case utf16SplitAtPosition len l of
+      Nothing -> Nothing
+      Just (before, after)
+        | null after -> case utf16SplitAtPosition len' (c <| r) of
+          Nothing -> Nothing
+          Just (r', r'') -> Just (l <> r', r'')
+        | otherwise -> Just (before, node after c r)
+    | len <= llc -> case Utf16.splitAtPosition len' c of
+      Nothing -> Nothing
+      Just (before, after)
+        | Utf16.null after -> case utf16SplitAtPosition len'' r of
+          Nothing -> Nothing
+          Just (r', r'') -> Just ((l |> c) <> r', r'')
+        | otherwise -> Just (l |> before, after <| r)
+    | otherwise -> case utf16SplitAtPosition len'' r of
+      Nothing -> Nothing
+      Just (before, after) -> Just (node l c before, after)
+    where
+      ll = utf16LengthAsPosition l
+      lc = Utf16.lengthAsPosition c
+      llc = ll <> lc
+      len' = utf16SubOnRope l len ll
+      len'' = utf16SubOnLines c len' lc
diff --git a/test/CharLines.hs b/test/CharLines.hs
--- a/test/CharLines.hs
+++ b/test/CharLines.hs
@@ -11,14 +11,15 @@
 import Data.Bool ((||), not, (&&))
 import Data.Function (($))
 import qualified Data.List as L
-import Data.Monoid (mempty)
+import Data.Monoid (mempty, mconcat)
 import Data.Ord (min, (>=), (<))
-import Data.Semigroup ((<>))
+import Data.Semigroup ((<>), stimes, stimesMonoid)
 import qualified Data.Text as T
 import Data.Text.Lines
 import Data.Tuple (snd)
+import Data.Word (Word)
 import Test.Tasty (testGroup, TestTree)
-import Test.Tasty.QuickCheck (testProperty, (===), applyFun, (.||.), (.&&.), (==>))
+import Test.Tasty.QuickCheck (Small(..), testProperty, (===), applyFun, (.||.), (.&&.), (==>))
 
 import Utils ()
 
@@ -36,6 +37,12 @@
     \x -> mempty <> x === (x :: TextLines)
   , testProperty "TextLines <> mempty" $
     \x -> x <> mempty === (x :: TextLines)
+
+  , testProperty "mconcat" $
+    \xs -> L.foldr (<>) mempty xs === mconcat (xs :: [TextLines])
+
+  , testProperty "stimes" $
+    \(Small n) xs -> stimesMonoid n xs === stimes (n :: Word) (xs :: TextLines)
 
   , testProperty "Position associativity" $
     \x y z -> posLine x < maxBound - posLine y && posLine y < maxBound - posLine z ==>
diff --git a/test/Main.hs b/test/Main.hs
--- a/test/Main.hs
+++ b/test/Main.hs
@@ -9,6 +9,7 @@
 
 import qualified CharLines
 import qualified CharRope
+import qualified MixedRope
 import qualified Utf16Lines
 import qualified Utf16Rope
 
@@ -23,4 +24,5 @@
   , CharRope.testSuite
   , Utf16Lines.testSuite
   , Utf16Rope.testSuite
+  , MixedRope.testSuite
   ]
diff --git a/test/MixedRope.hs b/test/MixedRope.hs
new file mode 100644
--- /dev/null
+++ b/test/MixedRope.hs
@@ -0,0 +1,86 @@
+-- |
+-- Copyright:   (c) 2021-2022 Andrew Lelechenko
+-- Licence:     BSD3
+-- Maintainer:  Andrew Lelechenko <andrew.lelechenko@gmail.com>
+
+module MixedRope
+  ( testSuite
+  ) where
+
+import Prelude ((+), (-))
+import Data.Bool (Bool(..), (&&))
+import Data.Function (($))
+import Data.Maybe (Maybe(..), isJust)
+import Data.Semigroup ((<>))
+import qualified Data.Text.Lines as Char
+import qualified Data.Text.Utf16.Lines as Utf16
+import qualified Data.Text.Utf16.Rope.Mixed as Mixed
+import Test.Tasty (testGroup, TestTree)
+import Test.Tasty.QuickCheck (testProperty, (===), property, (.&&.), counterexample)
+
+import Utils ()
+
+testSuite :: TestTree
+testSuite = testGroup "Utf16 Mixed"
+  [ testProperty "char null" $
+    \x -> Mixed.null x === Char.null (Mixed.toTextLines x)
+
+  , testProperty "charLength" $
+    \x -> Mixed.charLength x === Char.length (Mixed.toTextLines x)
+  , testProperty "utf16Length" $
+    \x -> Mixed.utf16Length x === Utf16.length (Mixed.toTextLines x)
+
+  , testProperty "lengthInLines" $
+    \x -> Mixed.lengthInLines x === Char.lengthInLines (Mixed.toTextLines x)
+
+  , testProperty "lines" $
+    \x -> Mixed.lines x === Char.lines (Mixed.toTextLines x)
+
+  , testProperty "splitAtLine" $
+    \i x -> let (y, z) = Mixed.splitAtLine i x in
+      (Mixed.toTextLines y, Mixed.toTextLines z) === Char.splitAtLine i (Mixed.toTextLines x)
+
+  , testProperty "charSplitAt 1" $
+    \i x -> case Mixed.charSplitAt i x of
+      (y, z) -> x === y <> z
+  , testProperty "charSplitAt 2" $
+    \i x -> case (Mixed.charSplitAt i x, Char.splitAt i (Char.fromText $ Mixed.toText x)) of
+      ((y, z), (y', z')) -> Char.fromText (Mixed.toText y) === y' .&&. Char.fromText (Mixed.toText z) === z'
+
+  , testProperty "utf16SplitAt 1" $
+    \i x -> case Mixed.utf16SplitAt i x of
+      Nothing -> property True
+      Just (y, z) -> x === y <> z
+  , testProperty "utf16SplitAt 2" $
+    \i x -> case (Mixed.utf16SplitAt i x, Utf16.splitAt i (Utf16.fromText $ Mixed.toText x)) of
+      (Nothing, Nothing) -> property True
+      (Nothing, Just{}) -> counterexample "can split TextLines, but not Mixed" False
+      (Just{}, Nothing) -> counterexample "can split Mixed, but not TextLines" False
+      (Just (y, z), Just (y', z')) -> Utf16.fromText (Mixed.toText y) === y' .&&. Utf16.fromText (Mixed.toText z) === z'
+  , testProperty "splitAt 3" $
+    \i x -> case Mixed.utf16SplitAt i x of
+      Just{} -> True
+      Nothing -> isJust (Mixed.utf16SplitAt (i - 1) x) && isJust (Mixed.utf16SplitAt (i + 1) x)
+
+  , testProperty "charSplitAtPosition 1" $
+    \i x -> case Mixed.charSplitAtPosition i x of
+      (y, z) -> x === y <> z
+  , testProperty "charSplitAtPosition 2" $
+    \i x -> case (Mixed.charSplitAtPosition i x, Char.splitAtPosition i (Char.fromText $ Mixed.toText x)) of
+      ((y, z), (y', z')) -> Char.fromText (Mixed.toText y) === y' .&&. Char.fromText (Mixed.toText z) === z'
+
+  , testProperty "utf16SplitAtPosition 1" $
+    \i x -> case Mixed.utf16SplitAtPosition i x of
+      Nothing -> property True
+      Just (y, z) -> x === y <> z
+  , testProperty "utf16SplitAtPosition 2" $
+    \i x -> case (Mixed.utf16SplitAtPosition i x, Utf16.splitAtPosition i (Utf16.fromText $ Mixed.toText x)) of
+      (Nothing, Nothing) -> property True
+      (Nothing, Just{}) -> counterexample "can split TextLines, but not Mixed" False
+      (Just{}, Nothing) -> counterexample "can split Mixed, but not TextLines" False
+      (Just (y, z), Just (y', z')) -> Utf16.fromText (Mixed.toText y) === y' .&&. Utf16.fromText (Mixed.toText z) === z'
+  , testProperty "utf16SplitAtPosition 3" $
+    \i x -> case Mixed.utf16SplitAtPosition i x of
+      Just{} -> True
+      Nothing -> isJust (Mixed.utf16SplitAtPosition (i <> Utf16.Position 0 1) x)
+  ]
diff --git a/test/Utf16Lines.hs b/test/Utf16Lines.hs
--- a/test/Utf16Lines.hs
+++ b/test/Utf16Lines.hs
@@ -12,14 +12,15 @@
 import Data.Function (($))
 import qualified Data.List as L
 import Data.Maybe (Maybe(..), isJust)
-import Data.Monoid (mempty)
+import Data.Monoid (mempty, mconcat)
 import Data.Ord (min, (>=), (<))
-import Data.Semigroup ((<>))
+import Data.Semigroup ((<>), stimes, stimesMonoid)
 import qualified Data.Text as T
 import Data.Text.Utf16.Lines
 import Data.Tuple (snd)
+import Data.Word (Word)
 import Test.Tasty (testGroup, TestTree)
-import Test.Tasty.QuickCheck (testProperty, (===), property, (.||.), (.&&.), (==>))
+import Test.Tasty.QuickCheck (Small(..), testProperty, (===), property, (.||.), (.&&.), (==>))
 
 import Utils
 
@@ -37,6 +38,12 @@
     \x -> mempty <> x === (x :: TextLines)
   , testProperty "TextLines <> mempty" $
     \x -> x <> mempty === (x :: TextLines)
+
+  , testProperty "mconcat" $
+    \xs -> L.foldr (<>) mempty xs === mconcat (xs :: [TextLines])
+
+  , testProperty "stimes" $
+    \(Small n) xs -> stimesMonoid n xs === stimes (n :: Word) (xs :: TextLines)
 
   , testProperty "Position associativity" $
     \x y z -> posLine x < maxBound - posLine y && posLine y < maxBound - posLine z ==>
diff --git a/test/Utils.hs b/test/Utils.hs
--- a/test/Utils.hs
+++ b/test/Utils.hs
@@ -21,6 +21,7 @@
 import qualified Data.Text.Rope as CharRope
 import qualified Data.Text.Utf16.Lines as Utf16
 import qualified Data.Text.Utf16.Rope as Utf16Rope
+import qualified Data.Text.Utf16.Rope.Mixed as MixedRope
 import Data.Word (Word)
 import Test.Tasty.QuickCheck (Gen, Arbitrary (arbitrary), arbitrary, shrink, frequency, arbitraryASCIIChar, arbitraryUnicodeChar, listOf, oneof)
 import Data.Monoid (mconcat, mappend)
@@ -56,10 +57,6 @@
   arbitrary = Char.fromText <$> arbitrary
   shrink = L.map Char.fromText . shrink . Char.toText
 
-instance Arbitrary Utf16.TextLines where
-  arbitrary = Utf16.fromText <$> arbitrary
-  shrink = L.map Utf16.fromText . shrink . Utf16.toText
-
 instance Arbitrary Char.Position where
   arbitrary = oneof
     [ Char.Position <$> arbitrary <*> arbitrary
@@ -99,3 +96,13 @@
     | Utf16Rope.null rp = []
     | otherwise = L.concatMap (\i -> maybe [] (\(x, y) -> [x, y]) (Utf16Rope.splitAt i rp))
                   [1..Utf16Rope.length rp - 1]
+
+instance Arbitrary MixedRope.Rope where
+  arbitrary = frequency
+    [ (9, mconcat . L.map MixedRope.fromText <$> arbitrary)
+    , (1, mappend <$> arbitrary <*> arbitrary)
+    ]
+  shrink rp
+    | MixedRope.null rp = []
+    | otherwise = L.concatMap (\i -> (\(x, y) -> [x, y]) (MixedRope.charSplitAt i rp))
+                  [1..MixedRope.charLength rp - 1]
diff --git a/text-rope.cabal b/text-rope.cabal
--- a/text-rope.cabal
+++ b/text-rope.cabal
@@ -1,5 +1,5 @@
 name: text-rope
-version: 0.1
+version: 0.2
 cabal-version: >=1.10
 build-type: Simple
 license: BSD3
@@ -18,7 +18,7 @@
 data-files:
   bench/bench.txt
 
-tested-with: GHC == 9.2.1, GHC==9.0.2, GHC==8.10.7, GHC==8.8.4, GHC==8.6.5, GHC==8.4.4, GHC==8.2.2, GHC==8.0.2
+tested-with: GHC == 9.2.2, GHC==9.0.2, GHC==8.10.7, GHC==8.8.4, GHC==8.6.5, GHC==8.4.4, GHC==8.2.2, GHC==8.0.2
 
 source-repository head
   type: git
@@ -35,6 +35,7 @@
     Data.Text.Rope
     Data.Text.Utf16.Lines
     Data.Text.Utf16.Rope
+    Data.Text.Utf16.Rope.Mixed
   other-modules:
     Data.Text.Lines.Internal
   build-depends:
@@ -44,9 +45,11 @@
     vector >=0.11
   default-language: Haskell2010
   hs-source-dirs: src
-  ghc-options: -O2 -Wall -Wcompat
+  ghc-options: -O2 -Wall -Wcompat -fexpose-all-unfoldings
   if flag(debug)
     cpp-options: -DDEBUG
+    if impl(ghc >= 9.2.2)
+      ghc-options: -fcheck-prim-bounds
   c-sources:
     cbits/utils.c
 
@@ -56,6 +59,7 @@
   other-modules:
     CharLines
     CharRope
+    MixedRope
     Utf16Lines
     Utf16Rope
     Utils
