arranger
0.3.0-alpha02indexedDeclarative, type-safe rich text editor engine with UI components handling dynamic edits: automatic span shifting, atomic mutations, semantic runs, paragraph styles, lists, and constraint-driven formatting.
Declarative, type-safe rich text editor engine with UI components handling dynamic edits: automatic span shifting, atomic mutations, semantic runs, paragraph styles, lists, and constraint-driven formatting.
Arranger is a declarative, type-safe rich text editor engine and UI components for Compose Multiplatform.
While standard buildAnnotatedString is perfect for static text decoration, it quickly breaks down when building real-time editors where users insert and delete text. Arranger is built specifically for dynamic text operations, automatically managing and shifting attribute spans (like bold, colors, or links) as the underlying text mutates.
[!WARNING] Work In Progress: This library is currently under active development. APIs are unstable and subject to change without notice. We highly welcome your feedback, feature requests, and bug reports via GitHub Issues!
Arranger solves the biggest pain points of traditional rich text handling in Compose Multiplatform.
While standard buildAnnotatedString is excellent for decorating static text, it is not designed for dynamic input. If a user inserts or deletes text in the middle of an AnnotatedString, all subsequent span indices become misaligned, and you are forced to manually recalculate them. This manual index math is tedious and highly error-prone when building a real-time text editor.
The Pain (AnnotatedString)
// The Pain: If a user inserts text, you must manually recalculate all span indices!
val oldText = "Hello Bold Text"
val oldSpans = listOf(AnnotatedString.Range(SpanStyle(fontWeight = FontWeight.Bold), 6, 10))
// Inserting "!" at the beginning
val newText = "!" + oldText
val newSpans = oldSpans.map {
// Manual index shifting - tedious and highly error-prone
AnnotatedString.Range(it.item, it.start + 1, it.end + 1)
}
The Arranger Way Arranger automatically tracks and shifts spans during text mutations.
// Arranger Way 1: Automatically tracks and shifts spans during text mutations.
state.edit {
insert(index = 0, text = "!")
// The "Bold" span is automatically shifted. No manual index math required!
}
Inspired by SwiftUI's AttributedString.Runs, Arranger treats text as semantic chunks. You can easily find and batch-edit specific attributes without complex regex or index tracking.
// Arranger Way 2: Semantic iteration over attributes via "Runs"
state.edit {
// Find all chunks of text that are Bold, and turn them Red at once
val boldRuns = state.richString.runs(BoldKey)
editAll(boldRuns) {
textColor(Color.Red)
}
}
Arranger is published to Maven Central.
Add the dependencies to your commonMain source set in build.gradle.kts:
kotlin {
sourceSets {
commonMain.dependencies {
// For Compose UI integration (RichTextEditor).
// This automatically includes the core 'arranger-richtext' module.
implementation("dev.mkeeda.arranger:arranger-richtext-editor:0.3.0-alpha02")
// Optional: If you only need the core data structures without Compose UI:
// implementation("dev.mkeeda.arranger:arranger-richtext:0.3.0-alpha02")
}
}
}
Add the dependencies to your top-level dependencies block in build.gradle.kts:
dependencies {
implementation("dev.mkeeda.arranger:arranger-richtext-editor:0.3.0-alpha02")
}
Arranger's true power lies in its ability to handle dynamic text input gracefully. When a user types in the RichTextEditor—or when you programmatically insert text into RichTextState—existing spans are automatically maintained and shifted. You don't need to write any custom logic to preserve formatting.
@Composable
fun DynamicEditingSample(modifier: Modifier = Modifier) {
val initialText = "Edit this styled text to see the magic."
// 1. Initialize state with formatting
val state = remember {
RichTextState(
initialText = RichString(text = initialText).edit {
editAttributes(range = initialText.rangeOf()) {
bold()
textColor(Color())
}
}
)
}
RichTextEditor(
state = state,
modifier = Modifier.fillMaxWidth(),
)
}
Arranger natively supports not only inline character formatting (like colors and boldness) but also block-level paragraph formatting such as Headers, Blockquotes, and Alignments.
Arranger provides native support for bulletList and orderedList paragraph formatting. You can apply list attributes over a text range, and the editor will automatically render the appropriate markers and handle indentation.
Bullet lists automatically change their marker symbol based on the indentation level (e.g., Level 1 uses ・, Level 2 uses ○).
Ordered lists automatically calculate and display the sequence numbers based on their position and nesting level.
@Composable
fun {
initialText = +
+
+
state = remember {
RichTextState(
initialText = RichString(text = initialText).edit {
start = initialText.indexOf()
end = initialText.length
editAttributes(start until end) {
orderedList(ListIndentLevel.Level1)
}
}
)
}
RichTextEditor(
state = state,
modifier = modifier.fillMaxWidth(),
)
}
You can customize the list markers by providing a ListMarkerResolver to the RichTextEditor. This allows you to use different symbols, letters, or parentheses for your lists.
Arranger provides intelligent formatting strategies when the user presses the Enter key. By providing an EnterKeyStrategy to the RichTextEditor, you can control how paragraph attributes are inherited or transformed on new lines.
The library includes three built-in strategies:
You can combine these strategies (or create your own custom strategies) to build a seamless editing experience.
You can define custom attribute keys and map them to Compose styles. Below shows an example of implementing a simple highlight feature by creating a custom SpanAttributeKey and styling it with an AttributeStyleResolver.
[!TIP] If you are building a Material 3 application, consider using
rememberMaterial3AttributeStyleResolver()from thearranger-richtext-editor-material3artifact. It automatically resolves standard text formats (like headings and blockquotes) using your app'sMaterialThemetypography and color schemes.To use it, add the following dependency to your module's
build.gradle.kts:
Arranger treats text as semantic "Runs" (chunks of text with identical attributes). This allows you to effortlessly search for patterns or query existing attributes, and modify them all at once.
You can easily search for strings or regular expressions and apply styles to all occurrences at once using rangesOf and editAll. Here's a sample that highlights hashtags in real-time.
Instead of text searching, you can also query existing attributes using runs(key) and apply a batch edit over those specific runs. This is useful for semantic manipulations like changing the color of all bold texts.
[!NOTE] For more complex queries, you can also use
runs { predicate }to extract runs that match any custom condition based on their attributes.
Arranger allows you to programmatically mutate text (insert, delete, replace) and apply formatting atomically within the RichTextState.edit { } block.
The RichTextBuffer automatically shifts existing spans to maintain alignment and allows you to apply new attributes safely to the newly inserted text.
Arranger provides a built-in, robust Undo/Redo engine that automatically records text mutations and attribute changes. The history state is exposed via state.undoState, allowing you to easily build undo/redo toolbars or handle keyboard shortcuts.
The engine correctly manages complex operations like batch attribute application or atomic text replacements as single undoable actions.
Arranger can be used to build rich and complex text input interfaces. Below are some real-world use cases demonstrating how to integrate Arranger into your applications.
To ensure scalability up to PC-class text sizes and pure Kotlin compatibility (KMP), the architecture is layered:
TextFieldState and .InlineContent.Contributions are welcome! Please see our CONTRIBUTING.md for details on how to get started.
This project is licensed under the Apache License 2.0 - see the LICENSE file for details.
| Platform | Support Status | Target |
|---|
| Android | ✅ Supported | API Level 26+ |
| Desktop (JVM) | ✅ Supported | macOS, Windows, Linux |
| iOS | ✅ Supported | - |
| Web (Wasm/JS) | 🚧 Planned | - |
SpanStyle) and domain-specific attributes (e.g., @Mention, #Hashtag) with full compile-time safety.TextFieldState and OutputTransformation.@Composable
fun AdvancedFormattingSample(modifier: Modifier = Modifier) {
val initialText =
"Advanced Formatting Options\n" +
"You can easily apply various text and paragraph styles.\n\n" +
"Paragraph Styling\n" +
"This paragraph is explicitly centered, overriding the default alignment.\n" +
"> Blockquotes are perfect for highlighting external quotes or important notes."
val state =
remember {
RichTextState(
initialText =
RichString(text = initialText).edit {
editAttributes(range = initialText.rangeOf("Advanced Formatting Options")) {
headingLevel(HeadingLevel.H1)
}
editAttributes(range = initialText.rangeOf("Paragraph Styling")) {
headingLevel(HeadingLevel.H3)
}
editAttributes(range = initialText.rangeOf("This paragraph is explicitly centered, overriding the default alignment.")) {
textAlignment(TextAlignment.Center)
}
editAttributes(range = initialText.rangeOf("> Blockquotes are perfect for highlighting external quotes or important notes.")) {
blockquote()
}
editAttributes(range = initialText.rangeOf("various text and paragraph styles")) {
textColor(Color(0xFFE91E63)) // Pink
bold()
underline()
}
},
)
}
RichTextEditor(
state = state,
modifier = Modifier.fillMaxWidth(),
)
}
@Composable
fun BulletListSample(modifier: Modifier = Modifier) {
val initialText = "Bullet Items:\n" +
"First item\n" +
"Second item\n" +
"Third item\n" +
"Nested item 1\n" +
"Nested item 2"
val state = remember {
RichTextState(
initialText = RichString(text = initialText).edit {
val itemsStart = initialText.indexOf("First item")
val itemsEnd = initialText.indexOf("Nested item 1") - 1
editAttributes(itemsStart until itemsEnd) {
bulletList(ListIndentLevel.Level1)
}
val nestedStart = initialText.indexOf("Nested item 1")
val nestedEnd = initialText.length
editAttributes(nestedStart until nestedEnd) {
bulletList(ListIndentLevel.Level2)
}
}
)
}
RichTextEditor(
state = state,
modifier = modifier.fillMaxWidth(),
)
}
private val customMarkerResolver = ListMarkerResolver { item ->
when (item) {
is BulletListItem -> "✔️ "
is OrderedListItem -> "${('a' + item.index - 1)}) "
}
}
@Composable
fun CustomListMarkerSample(modifier: Modifier = Modifier) {
val initialText = "Checklist:\n" +
"Review code\n" +
"Run tests\n" +
"Deploy\n" +
"Priorities:\n" +
"Critical bugs\n" +
"New features\n" +
"Refactoring"
val state = remember {
RichTextState(
initialText = RichString(text = initialText).edit {
val start = initialText.indexOf("Review code")
val end = initialText.indexOf("Priorities:") - 1
editAttributes(start until end) {
bulletList(ListIndentLevel.Level1)
}
val orderedStart = initialText.indexOf("Critical bugs")
val orderedEnd = initialText.length
editAttributes(orderedStart until orderedEnd) {
orderedList(ListIndentLevel.Level1)
}
}
)
}
RichTextEditor(
state = state,
modifier = modifier.fillMaxWidth(),
listMarkerResolver = customMarkerResolver,
)
}
| Strategy | Description | Demo |
|---|
InheritParagraphStrategy(Default) | Inherits all paragraph attributes (like alignment or blockquote) to the new line. | ![]() |
ListEnterStrategy | Inherits list attributes and automatically increments ordered list numbers. Pressing Enter on an empty list item will decrease its indentation level (outdent). If the item is at the first level, the list attribute is removed. | ![]() |
HeadingEnterStrategy | Automatically removes the heading attribute on the new line, allowing users to quickly start typing normal text after a heading. | ![]() |
dependencies {
implementation("dev.mkeeda.arranger:arranger-richtext-editor-material3:0.3.0-alpha02")
}
// 1. Define Custom Attribute Key
object HighlightKey : SpanAttributeKey<Unit> {
override val name: String = "Highlight"
override val defaultValue: Unit = Unit
}
// 2. Create a custom AttributeStyleResolver inheriting from DefaultAttributeStyleResolver
private val customResolver = AttributeStyleResolver(base = DefaultAttributeStyleResolver) {
spanStyle(HighlightKey) {
SpanStyle(
background = Color(0xFFFFF59D), // Light Yellow
color = Color(0xFFE65100), // Orange Text
fontWeight = FontWeight.ExtraBold
)
}
}
@Composable
fun CustomAttributeSample(modifier: Modifier = Modifier) {
val initialText = "Arranger also supports Custom Attributes.\nThis text is highlighted using a custom resolver!"
// 3. Initialize RichTextState with the custom attribute
val state = remember {
RichTextState(
initialText = RichString(text = initialText).edit {
val range = initialText.rangeOf("highlighted")
setSpanAttribute(HighlightKey, Unit, range)
}
)
}
// 4. Pass the custom resolver to RichTextEditor
RichTextEditor(
state = state,
styleResolver = customResolver,
modifier = modifier.fillMaxWidth(),
)
}
fun HashtagHighlightSample(modifier: Modifier = Modifier) {
val initialText = "Type some #hashtags here!\nFor example: #Compose is #awesome"
val state = remember {
RichTextState(
initialText = RichString(text = initialText)
)
}
LaunchedEffect(state) {
snapshotFlow { state.richString.text }.collect { text ->
state.edit {
// Clear existing colors first
editAttributes(range = text.indices) {
clearTextColor()
}
// Find all hashtags and highlight them in blue
val hashtagRanges = text.rangesOf(Regex("#\\w+"))
editAll(hashtagRanges) {
textColor(Color(0xFF1976D2)) // Blue
}
}
}
}
RichTextEditor(
state = state,
modifier = modifier.fillMaxWidth(),
)
}
@Composable
fun AttributeBatchEditSample(modifier: Modifier = Modifier) {
val initialText = "This text has some bold words.\n" +
"We can find all bold parts and change their color at once."
val state = remember {
RichTextState(
initialText = RichString(text = initialText).edit {
editAttributes(range = initialText.rangeOf("bold words")) {
bold()
}
editAttributes(range = initialText.rangeOf("bold parts")) {
bold()
}
}
)
}
Column(modifier = modifier) {
Button(
onClick = {
// Find all runs that have the BoldKey
val boldRuns = state.richString.runs(BoldKey)
// Batch edit those specific runs
state.edit {
editAll(boldRuns) {
textColor(Color(0xFFD32F2F)) // Red
}
}
},
modifier = Modifier.fillMaxWidth()
) {
Text("Highlight Bold Text in Red")
}
Spacer(modifier = Modifier.height(16.dp))
RichTextEditor(
state = state,
modifier = Modifier.fillMaxWidth(),
)
}
}
@Composable
fun AtomicMutationSample(modifier: Modifier = Modifier) {
val state = remember {
RichTextState(initialText = RichString(text = "Hello "))
}
Column(modifier = modifier.padding(16.dp)) {
Text("Atomic Text Mutations", fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = {
state.edit {
// Atomically insert text and apply styles to the newly inserted text
insert(index = textLength, text = "World!") {
bold()
textColor(Color(0xFFE91E63)) // Pink
}
// You can also delete or replace text:
// delete(range = 0..5)
// replace(range = 0..5, text = "Hi, ") { italic() }
}
},
modifier = Modifier.fillMaxWidth()
) {
Text("Insert Styled Text")
}
Spacer(modifier = Modifier.height(16.dp))
RichTextEditor(
state = state,
modifier = Modifier.fillMaxWidth(),
)
}
}
@Composable
fun UndoRedoSample(modifier: Modifier = Modifier) {
val initialText = "Type something here, make changes, and use Undo/Redo buttons."
val state =
remember {
RichTextState(
initialText = RichString(text = initialText).edit {
val range = initialText.rangeOf("Undo/Redo")
editAttributes(range = range) {
bold()
textColor(Color(0xFF1976D2)) // Blue
}
}
)
}
Column(modifier = modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Button(
onClick = { state.undoState.undo() },
enabled = state.undoState.canUndo
) {
Text("Undo")
}
Button(
onClick = { state.undoState.redo() },
enabled = state.undoState.canRedo
) {
Text("Redo")
}
Spacer(modifier = Modifier.weight(1f))
Button(
onClick = {
state.edit {
// textLength is a property of the edit block's receiver — the full character count
editAttributes(range = 0 until textLength) { bold() }
}
}
) {
Text("Make All Bold")
}
}
Spacer(modifier = Modifier.height(16.dp))
RichTextEditor(
state = state,
modifier = Modifier.fillMaxWidth().weight(1f),
)
}
}
| Sample | Screenshot |
|---|
| Document Editor with Full UI This sample demonstrates a full-screen document editor UI equipped with a rich formatting toolbar. It showcases how to handle text selection, manage undo/redo history, and seamlessly integrate state with Compose Multiplatform. This sample app can be run as an Android, iOS, and Desktop (macOS, Windows, Linux) app. Tip: Check this sample to see how you can easily apply formatting using the idiomatic RichTextState extension functions (e.g., toggleFormat(), applyFormat(), removeFormat(), and clearFormats()). | ![]() |
RichString & RichRunAttributeKey<T>: Defines the data type of an attribute.AttributeContainer: A core structure holding a type-safe map of attributes, which is associated with specific text ranges to form RichSpans.RichStringScope: A builder scope used to safely mutate the attributes of a string within an edit block. Designed to accumulate attribute mutations and produce a completely new, immutable RichString.RichTextState: Wraps TextFieldState and manages the Spans. It acts as the single source of truth and exposes the complete RichString.RichTextBuffer: A state-backed buffer provided inside RichTextState.edit { } that allows atomic, programmatic text and attribute mutations while automatically keeping spans synchronized.RichTextOutputTransformation: Converts the plain text and spans into Compose's AnnotatedString purely at render time.RichTextEditor: A simple, declarative Composable wrapping BasicTextField with our state and transformation.OutputTransformationinsert, delete, and replace within edit {} with automatic span tracking.BulletList and OrderedList with auto-indent and prefix management.**bold**TextFieldDecorator for advanced visuals (e.g., custom drawing for blockquotes or code blocks).Surfaced from shared tags and platforms — no rankings paid for.