Building High-Performance Text Editors in Kotlin Multiplatform with Krope

Building High-Performance Text Editors in Kotlin Multiplatform with Krope

2 10
calendar_today agoschedule5 min read
— Originally published at medium.com

If you have ever tried building a text editor, an IDE, or a collaborative document tool, you quickly run into a fundamental wall: how do you store the text?

At first glance, a standard String seems fine. But Strings are immutable; changing a single character in a 1MB file requires allocating a brand new 1MB array. So, you move to StringBuilder. It’s mutable and fast for simple appends, but inserting or deleting text in the middle of a massive document still requires shifting hundreds of thousands of characters in memory — an O(N) operation that causes UI stuttering as your document grows.

This is where the Rope data structure comes in.

Today, I want to introduce Krope, a high-performance, memory-safe, and reactive Rope library built entirely in Kotlin Multiplatform. Whether you are targeting JVM, Android, iOS, or WASM, Krope provides the engine you need to build next-generation text editing tools.

What is a Rope?

Instead of storing text as a single massive, contiguous array of characters, a Rope stores text as a balanced binary tree (in Krope’s case, utilizing a Red-Black tree variant). The leaves of the tree contain short chunks of strings.

When you insert text in the middle of a document, a Rope doesn’t copy the whole string. Instead, it splits a leaf node and inserts a new branch. This gives operations like insertion, deletion, and replacement a time complexity of O(log N).

Furthermore, Krope implements structural sharing. When a modification occurs, Krope doesn’t mutate the existing tree; it returns a new tree that shares the untouched branches with the old one. This provides safe concurrent access, cheap undo/redo history.

Why I Built Krope

The idea for Krope was born while building haskcore — a full-featured IDE for Haskell written in Compose Desktop. It uses Skia for rendering, Tree-sitter for incremental syntax parsing, and the Language Server Protocol for code intelligence. It’s a real editor with 40+ modules, handling everything from syntax highlighting to code completion and project-wide diagnostics.

Very quickly, I hit a wall: Kotlin’s standard StringBuilder couldn't keep up. Editing large Haskell modules meant shifting massive text buffers on every keystroke, causing visible lag during syntax highlighting and LSP synchronization. I needed something better — and the rope data structure, battle-tested in editors like Emacs and Vim since the 90s, was the obvious answer.

But Kotlin Multiplatform lacked a modern, reactive, and fully-featured rope implementation. So I built Krope to solve the specific pain points I encountered:

  • Cross-Platform Consistency: haskcore targets the desktop, but the same text engine should work on Android, iOS, and WASM for future ports or companion apps.
  • Reactive by Design: Compose Multiplatform thrives on reactive state. Krope exposes every edit as a SharedFlow event and every document state as a StateFlow, making UI integration seamless.
  • Byte-Level Encoding Awareness: LSP servers speak in byte offsets, not character positions. Krope natively tracks UTF-8, UTF-16, and UTF-32, giving you efficient access to byte coordinates backed by an LRU cache.
  • Functional & Safe: Invalid operations shouldn’t crash your editor. Krope uses an Either<Throwable, Result> pattern, so out-of-bounds inserts return errors you can handle gracefully — critical when processing remote edits from LSP or collaborative peers.

Inside the Krope API

Krope separates the underlying data structure from the editor API. The primary interface you interact with is the TextBuffer.

Here is how simple it is to get started:

import io.github.numq.krope.core.Encoding
import io.github.numq.krope.text.*
import kotlinx.coroutines.launch

// 1. Initialize the buffer
val factory = RopeTextBufferFactory()
val buffer = factory.create(
    text = "Hello World",
    encoding = Encoding.UTF8,
    lineEnding = TextLineEnding.LF,
    enablePooling = true // Enables internal string interning to save memory
).fold(
    onLeft = { error -> throw error },
    onRight = { it }
)

The Reactive Paradigm

In a standard text editor, the UI needs to know exactly what changed to update the screen or send a diff over the network for collaborative editing. Krope exposes two flows:

  • snapshot: StateFlow<TextSnapshot>: Represents the immutable current state of the document.
  • data: SharedFlow<TextEdit.Data>: Emits granular events whenever the text changes.
// Observe all changes in real-time
launch {
    buffer.data.collect { editData ->
        when (editData) {
            is TextEdit.Data.Single.Insert -> 
                println("Inserted '${editData.insertedText}' at ${editData.startPosition}")
            is TextEdit.Data.Single.Delete -> 
                println("Deleted text ending at ${editData.oldEndPosition}")
            is TextEdit.Data.Single.Replace -> 
                println("Replaced '${editData.oldText}' with '${editData.newText}'")
            is TextEdit.Data.Batch -> 
                println("Batch processed ${editData.singles.size} operations atomically")
        }
    }
}

Line and Column Awareness

Most ropes just deal with raw character offsets. Krope natively understands Lines and Columns through TextPosition.

// "Hello Beautiful World"
buffer.insert(TextPosition(line = 0, column = 5), " Beautiful") 
// Querying the snapshot is instantaneous thanks to cached tree node metadata
val snapshot = buffer.snapshot.value
println("Total Lines: ${snapshot.lines}")
println("Max Line Length: ${snapshot.maxLineLength}")

// Get byte offset for an LSP (Language Server Protocol)
val byteOffset = snapshot.getBytePosition(TextPosition(line = 0, column = 6))

Atomic Batch Operations

When you refactor code (like renaming a variable that appears 10 times in a file), you don’t want to trigger 10 separate UI redraws or clutter the undo stack. Krope supports withBatch:

buffer.withBatch { batch ->
    batch.replace(TextRange(TextPosition(0, 39), TextPosition(0, 42)), "vatAmount")
    batch.replace(TextRange(TextPosition(1, 19), TextPosition(1, 22)), "vatAmount")
}

This applies all edits internally, rebalances the tree once, and emits a single TextEdit.Data.Batch event to your listeners.

Performance: Rope vs. StringBuilder

Does a Rope really matter? I ran JMH benchmarks on the JVM using a 1,000,000-character string to find out.

Test 1: Single Insert in the Middle

  • StringBuilder: ~0.17 ms
  • Krope: ~1.06 ms

Why does StringBuilder win here? Because for a single operation, System.arraycopy() is incredibly fast in Java. The overhead of traversing and splitting a tree makes the Rope slower for a single, isolated edit.

Test 2: 100 Scattered Edits (The “Real Editor” Scenario)

  • StringBuilder: ~1.98 ms
  • Krope: ~1.08 ms

Why does Krope win here? When making multiple edits, StringBuilder has to perform that massive O(N) array copy every single time. Krope, on the other hand, updates a few node pointers and does a quick tree rebalance. In a real-world typing or refactoring scenario, Krope maintains flat, predictable latency — exactly what you need to prevent UI thread stutters.

Furthermore, because Krope uses structural sharing and optional String Pooling (ThreadSafeLruCache), it dramatically reduces heap allocations during continuous editing compared to copying large string arrays.

Get Involved

If you are building text-heavy applications in Compose Multiplatform, writing a code editor, or experimenting with CRDTs for collaborative editing, give Krope a try.

The architecture is highly modular, separating the core data structures from the TextBuffer API, and includes built-in support for kotlinx.serialization.

Check out the source code, play with the examples, and star the repository on GitHub!

🔗 GitHub Repository: Krope

🔥 Join developers growing publicly
Share your knowledge, build in public, and grow your developer presence with a global community.

More Posts

Type-safe Kotlin Multiplatform i18n: auto-convert Android strings to cross-platform translations.

Ismoy - Oct 14, 2025

Kotlin 2.4.0 Released

Mark Kazakov - Jun 13

Kotlin Professional Certificate by JetBrains

Mark Kazakov - May 20

Simplifying Kotlin Multiplatform Setup with the New Android-KMP Plugin

Ismoy - Sep 3, 2025

Kotlin Multiplatform + Compose: Unified Camera & Gallery Picker with Expect/Actual

Ismoy - Aug 20, 2025
chevron_left
245 Points12 Badges
5Posts
2Comments
3Connections
Software Architect & Engineer. Building tools that simplify complexity.

Related Jobs

View all jobs →

Commenters (This Week)

1 comment
1 comment
1 comment

Contribute meaningful comments to climb the leaderboard and earn badges!