TextFieldBuffer


A text buffer that can be edited, similar to StringBuilder.

This class provides methods for changing the text, such as:

This class also stores and tracks the cursor position or selection range. The cursor position is just a selection range with zero length. The cursor and selection can be changed using methods such as:

To get one of these, and for usage samples, see TextFieldState.edit. Every change to the buffer is tracked in a ChangeList which you can access via the changes property.

Summary

Nested types

The ordered list of non-overlapping and discontinuous changes performed on a TextFieldBuffer during the current edit or filter operation.

Public functions

TrackedRange<ParagraphStyle>
addStyle(
    paragraphStyle: ParagraphStyle,
    range: TextRange,
    expandPolicy: ExpandPolicy
)

Adds the given paragraphStyle to the text within the range on this buffer.

Cmn
Unit
addStyle(paragraphStyle: ParagraphStyle, start: Int, end: Int)

Adds the given paragraphStyle to the text between start and end on this buffer.

Cmn
TrackedRange<SpanStyle>
addStyle(
    spanStyle: SpanStyle,
    range: TextRange,
    expandPolicy: ExpandPolicy
)

Adds the given spanStyle to the text within the range on this buffer.

Cmn
Unit
addStyle(spanStyle: SpanStyle, start: Int, end: Int)

Adds the given spanStyle to the text between start and end on this buffer.

Cmn
open Appendable
append(char: Char)
Cmn
open Appendable
Cmn
open Appendable
append(text: CharSequence?, start: Int, end: Int)
Cmn
CharSequence

Returns a CharSequence backed by this buffer.

Cmn
Char
charAt(index: Int)

Returns the Char at index in this buffer.

Cmn
List<TrackedRange<ParagraphStyle>>
getParagraphStyles(start: Int, end: Int)

Returns the ParagraphStyles that intersect with the range defined by start (inclusive) and end (exclusive).

Cmn
List<TrackedRange<SpanStyle>>
getSpanStyles(start: Int, end: Int)

Returns the SpanStyles that intersect with the given range defined by start (inclusive) and end (exclusive).

Cmn
Unit

Places the cursor after the character at the given index.

Cmn
Unit

Places the cursor before the character at the given index.

Cmn
Boolean
removeStyle(trackedRange: TrackedRange<*>)

Removes the exact style represented by the given trackedRange from this buffer.

Cmn
Unit
replace(start: Int, end: Int, text: CharSequence)

Replaces the text between start (inclusive) and end (exclusive) in this value with text, and records the change in changes.

Cmn
Unit

Revert all changes made to this value since it was created.

Cmn
open String
Cmn

Public properties

TextFieldBuffer.ChangeList

The ChangeList represents the changes made to this value and is inherently mutable.

Cmn
ExpandPolicy

The ExpandPolicy defining how the style range expands when text is inserted at its boundaries.

Cmn
Boolean

True if the selection range has non-zero length.

Cmn
Int

The number of characters in the text field.

Cmn
TextRange

Original selection before the changes.

Cmn
CharSequence

Original text content of the buffer before any changes were applied.

Cmn
ParagraphStyle

The ParagraphStyle object associated with this TrackedRange.

Cmn
TextRange

The selected range of characters.

Cmn
SpanStyle

The SpanStyle object associated with this TrackedRange.

Cmn
TextRange

The TextRange of this style.

Cmn
Boolean

Whether this TrackedRange is still valid in the buffer.

Cmn

Extension functions

Unit
TextFieldBuffer.delete(start: Int, end: Int)

Delete the text between start (inclusive) and end (exclusive).

Cmn
Unit
TextFieldBuffer.insert(index: Int, text: String)

Insert text at the given index in this value.

Cmn
Unit

Places the cursor at the end of the text.

Cmn
Unit

Places the selection around all the text.

Cmn

Public functions

addStyle

fun addStyle(
    paragraphStyle: ParagraphStyle,
    range: TextRange,
    expandPolicy: ExpandPolicy
): TrackedRange<ParagraphStyle>

Adds the given paragraphStyle to the text within the range on this buffer.

Styles are applied in the order they are added to the buffer. This order is preserved even as the text is edited and style ranges are adjusted.

Note that the same ParagraphStyle object can be added to multiple distinct ranges. Each call to addStyle creates a new, independent style range, and returns a unique TrackedRange to identify it. This allows you to save object allocations by applying the same style object to different distinct ranges.

Parameters
paragraphStyle: ParagraphStyle

the ParagraphStyle to be applied

range: TextRange

the effective range of the ParagraphStyle

expandPolicy: ExpandPolicy

the ExpandPolicy defining how the style range expands when text is inserted at its boundaries.

Returns
TrackedRange<ParagraphStyle>

a TrackedRange referencing the added style.

Throws
IllegalArgumentException

if range is out of 0, length, or if it's reversed.

addStyle

fun addStyle(paragraphStyle: ParagraphStyle, start: Int, end: Int): Unit

Adds the given paragraphStyle to the text between start and end on this buffer.

Styles are applied in the order they are added to the buffer. This order is preserved even as the text is edited and style ranges are adjusted.

Note that the same ParagraphStyle object can be added to multiple distinct ranges. Each call to addStyle creates a new, independent style range. This allows you to save object allocations by applying the same style object to different distinct ranges.

If ComposeFoundationFlags.isBasicTextFieldStyledTextEnabled is enabled, this function can be called from any TextFieldBuffer scope (such as InputTransformation or TextFieldState.edit). The added style will be tracked and its range will automatically adjust as the text is edited, behaving as if called with ExpandPolicy.AtEnd.

If the flag is disabled, this function is only permitted within an OutputTransformation. Any styling added in this mode will not become part of the underlying text state, nor will its boundaries be tracked or updated during subsequent edits. For predictable results when the flag is disabled, it is recommended to apply styles only after the text content has been fully determined.

Throws
IllegalArgumentException

if start or end is out of range, or if start>end.

addStyle

fun addStyle(
    spanStyle: SpanStyle,
    range: TextRange,
    expandPolicy: ExpandPolicy
): TrackedRange<SpanStyle>

Adds the given spanStyle to the text within the range on this buffer.

Styles are applied in the order they are added to the buffer. This order is preserved even as the text is edited and style ranges are adjusted.

Note that the same SpanStyle object can be added to multiple distinct ranges. Each call to addStyle creates a new, independent style range, and returns a unique TrackedRange to identify it. This allows you to save object allocations by applying the same style object to different distinct ranges.

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.input.ExpandPolicy
import androidx.compose.foundation.text.input.InputTransformation
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.delete
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.Text
import androidx.compose.runtime.remember
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight

// This sample demonstrates how to use the `TrackedRange` API to track and modify text ranges
// dynamically. It implements a basic Markdown-like behavior where text typed inside double
// asterisks (e.g., **bold**) is automatically bolded, and the asterisks are removed.
val state = rememberTextFieldState("")

fun IntRange.toTextRange(): TextRange {
    // Unlike IntRange, TextRange is exclusive at the end.
    return TextRange(first, last + 1)
}

val inputTransformation = remember {
    InputTransformation {
        val text = asCharSequence().toString()
        val matches = "\\*\\*([^*]+)\\*\\*".toRegex().findAll(text).toList()
        matches
            .map { match ->
                val contentRange = match.groups[0]!!.range
                // Apply bold style to the text inside asterisks (including the
                // asterisks for now).
                addStyle(
                    SpanStyle(fontWeight = FontWeight.Bold),
                    contentRange.toTextRange(),
                    ExpandPolicy.InsideOnly,
                )
            }
            .forEach { trackedRange ->
                // Remove the asterisks here.

                // `trackedRange` simplifies this logic: normally, deleting characters at
                // the start would shift the end index. However, because `trackedRange`
                // automatically tracks text updates and adjusts its offsets
                // dynamically, we can safely delete the target range without having to
                // calculate the offset manually.
                delete(trackedRange.textRange.start, trackedRange.textRange.start + 2)
                delete(trackedRange.textRange.end - 2, trackedRange.textRange.end)
            }
    }
}

Column {
    Text("Type **text** below to automatically bold it.")

    BasicTextField(
        state = state,
        textStyle = LocalTextStyle.current,
        inputTransformation = inputTransformation,
    )
}
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.input.ExpandPolicy
import androidx.compose.foundation.text.input.TextFieldBuffer
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material.Button
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.Text
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp

// This sample demonstrates a realistic rich-text editor scenario using the `TrackedRange` and
// `TextFieldTextStyles` APIs. It implements a "Toggle Bold" formatting function on the current
// selection.

// For simplicity, this sample keeps bold styles non-overlapping and contiguous, assuming they
// are
// applied exclusively through this method.
val state = rememberTextFieldState("Hello World")

// This derived state calculates whether the current selection is completely covered by
// bold text styles. This ensures the "Bold" toggle button accurately reflects the
// state of the selected text.
val isSelection100PercentBold by derivedStateOf {
    val selection = state.selection
    if (selection.collapsed) {
        false
    } else {
        val spanStyles = state.textStyles.getSpanStyles(selection.min, selection.max)
        var boldCoverage = 0
        for (style in spanStyles) {
            if (style.item.fontWeight == FontWeight.Bold) {
                val overlapStart = maxOf(style.start, selection.min)
                val overlapEnd = minOf(style.end, selection.max)
                if (overlapEnd > overlapStart) {
                    boldCoverage += (overlapEnd - overlapStart)
                }
            }
        }
        boldCoverage == selection.length
    }
}

fun TextFieldBuffer.unBoldSelection() {
    // Query existing bold styles in the selection
    val intersectingStyles =
        getSpanStyles(selection.min, selection.max).filter {
            it.spanStyle.fontWeight == FontWeight.Bold
        }
    // We modify or remove existing styles to exclude the selected range
    for (style in intersectingStyles) {
        val range = style.textRange
        if (range.start >= selection.min && range.end <= selection.max) {
            // The style is fully inside the selection. Remove it.
            removeStyle(style)
        } else if (range.start < selection.min && range.end > selection.max) {
            // The style completely covers the selection. We need to split it.
            val oldEnd = range.end
            // Truncate the start part
            style.textRange = TextRange(range.start, selection.min)
            // Add a new style for the end part
            addStyle(
                SpanStyle(fontWeight = FontWeight.Bold),
                TextRange(selection.max, oldEnd),
                ExpandPolicy.AtEnd,
            )
        } else if (range.start < selection.min) {
            // The style overlaps with the start of the selection. Truncate it.
            style.textRange = TextRange(range.start, selection.min)
        } else {
            // The style overlaps with the end of the selection. Truncate it.
            style.textRange = TextRange(selection.max, range.end)
        }
    }
}

fun TextFieldBuffer.boldSelection() {
    // Query existing bold styles in the selection
    val intersectingStyles =
        getSpanStyles(selection.min, selection.max).filter {
            it.spanStyle.fontWeight == FontWeight.Bold
        }
    // To keep bold styles non-overlapping, we merge any intersecting bold
    // styles with the new selection range into a single contiguous bold style.
    var mergedStart = selection.min
    var mergedEnd = selection.max

    for (style in intersectingStyles) {
        mergedStart = minOf(mergedStart, style.textRange.start)
        mergedEnd = maxOf(mergedEnd, style.textRange.end)
        // Remove the fragmented style
        removeStyle(style)
    }

    addStyle(
        SpanStyle(fontWeight = FontWeight.Bold),
        TextRange(mergedStart, mergedEnd),
        ExpandPolicy.AtEnd,
    )
}

Column {
    Button(
        onClick = {
            state.edit {
                val selection = this.selection
                if (selection.collapsed) return@edit
                if (isSelection100PercentBold) {
                    unBoldSelection()
                } else {
                    boldSelection()
                }
            }
        }
    ) {
        Text(
            "B",
            fontWeight = if (isSelection100PercentBold) FontWeight.Bold else FontWeight.Normal,
        )
    }

    BasicTextField(state = state, textStyle = LocalTextStyle.current)
}
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.material.Text
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp

// Wipe the bold style on a given range using the TrackedRange.textRange API
val state = TextFieldState("Hello World")

state.edit {
    // Assume we want to "wipe" all bold styles from the first 5 characters.
    val rangeToWipe = TextRange(0, 5)

    // Get all span styles that intersect with the wipe range.
    getSpanStyles(rangeToWipe.start, rangeToWipe.end).forEach { trackedRange ->
        if (trackedRange.spanStyle.fontWeight == FontWeight.Bold) {
            val current = trackedRange.textRange

            if (rangeToWipe.start <= current.start && current.end <= rangeToWipe.end) {
                // Case 1: The bold style is entirely within the wipe range, remove it.
                removeStyle(trackedRange)
            } else if (current.start < rangeToWipe.start && rangeToWipe.end < current.end) {
                // Case 2: The wipe range is in the middle: split the style into two parts.
                val oldEnd = current.end
                // Truncate the original style to end at the start of the wipe range.
                trackedRange.textRange = TextRange(current.start, rangeToWipe.start)
                // Add a new bold style starting after the wipe range.
                addStyle(trackedRange.spanStyle, rangeToWipe.end, oldEnd)
            } else if (current.start < rangeToWipe.start) {
                // Case 3: Overlap at the start of wipe: truncate the style's end.
                trackedRange.textRange = TextRange(current.start, rangeToWipe.start)
            } else {
                // Case 4: Overlap at the end of wipe: truncate the style's start.
                trackedRange.textRange = TextRange(rangeToWipe.end, current.end)
            }
        }
    }
}
Parameters
spanStyle: SpanStyle

the SpanStyle to be applied

range: TextRange

the effective range of the SpanStyle

expandPolicy: ExpandPolicy

the ExpandPolicy defining how the style range expands when text is inserted at its boundaries.

Returns
TrackedRange<SpanStyle>

a TrackedRange referencing the added style.

Throws
IllegalArgumentException

if range is out of 0, length, or if it's reversed.

addStyle

fun addStyle(spanStyle: SpanStyle, start: Int, end: Int): Unit

Adds the given spanStyle to the text between start and end on this buffer.

Styles are applied in the order they are added to the buffer. This order is preserved even as the text is edited and style ranges are adjusted.

Note that the same SpanStyle object can be added to multiple distinct ranges. Each call to addStyle creates a new, independent style range. This allows you to save object allocations by applying the same style object to different distinct ranges.

If ComposeFoundationFlags.isBasicTextFieldStyledTextEnabled is enabled, this function can be called from any TextFieldBuffer scope (such as InputTransformation or TextFieldState.edit). The added style will be tracked and its range will automatically adjust as the text is edited, behaving as if called with ExpandPolicy.AtEnd.

If the flag is disabled, this function is only permitted within an OutputTransformation. Any styling added in this mode will not become part of the underlying text state, nor will its boundaries be tracked or updated during subsequent edits. For predictable results when the flag is disabled, it is recommended to apply styles only after the text content has been fully determined.

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.input.ExpandPolicy
import androidx.compose.foundation.text.input.InputTransformation
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.delete
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.Text
import androidx.compose.runtime.remember
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight

// This sample demonstrates how to use the `TrackedRange` API to track and modify text ranges
// dynamically. It implements a basic Markdown-like behavior where text typed inside double
// asterisks (e.g., **bold**) is automatically bolded, and the asterisks are removed.
val state = rememberTextFieldState("")

fun IntRange.toTextRange(): TextRange {
    // Unlike IntRange, TextRange is exclusive at the end.
    return TextRange(first, last + 1)
}

val inputTransformation = remember {
    InputTransformation {
        val text = asCharSequence().toString()
        val matches = "\\*\\*([^*]+)\\*\\*".toRegex().findAll(text).toList()
        matches
            .map { match ->
                val contentRange = match.groups[0]!!.range
                // Apply bold style to the text inside asterisks (including the
                // asterisks for now).
                addStyle(
                    SpanStyle(fontWeight = FontWeight.Bold),
                    contentRange.toTextRange(),
                    ExpandPolicy.InsideOnly,
                )
            }
            .forEach { trackedRange ->
                // Remove the asterisks here.

                // `trackedRange` simplifies this logic: normally, deleting characters at
                // the start would shift the end index. However, because `trackedRange`
                // automatically tracks text updates and adjusts its offsets
                // dynamically, we can safely delete the target range without having to
                // calculate the offset manually.
                delete(trackedRange.textRange.start, trackedRange.textRange.start + 2)
                delete(trackedRange.textRange.end - 2, trackedRange.textRange.end)
            }
    }
}

Column {
    Text("Type **text** below to automatically bold it.")

    BasicTextField(
        state = state,
        textStyle = LocalTextStyle.current,
        inputTransformation = inputTransformation,
    )
}
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.input.ExpandPolicy
import androidx.compose.foundation.text.input.TextFieldBuffer
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material.Button
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.Text
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp

// This sample demonstrates a realistic rich-text editor scenario using the `TrackedRange` and
// `TextFieldTextStyles` APIs. It implements a "Toggle Bold" formatting function on the current
// selection.

// For simplicity, this sample keeps bold styles non-overlapping and contiguous, assuming they
// are
// applied exclusively through this method.
val state = rememberTextFieldState("Hello World")

// This derived state calculates whether the current selection is completely covered by
// bold text styles. This ensures the "Bold" toggle button accurately reflects the
// state of the selected text.
val isSelection100PercentBold by derivedStateOf {
    val selection = state.selection
    if (selection.collapsed) {
        false
    } else {
        val spanStyles = state.textStyles.getSpanStyles(selection.min, selection.max)
        var boldCoverage = 0
        for (style in spanStyles) {
            if (style.item.fontWeight == FontWeight.Bold) {
                val overlapStart = maxOf(style.start, selection.min)
                val overlapEnd = minOf(style.end, selection.max)
                if (overlapEnd > overlapStart) {
                    boldCoverage += (overlapEnd - overlapStart)
                }
            }
        }
        boldCoverage == selection.length
    }
}

fun TextFieldBuffer.unBoldSelection() {
    // Query existing bold styles in the selection
    val intersectingStyles =
        getSpanStyles(selection.min, selection.max).filter {
            it.spanStyle.fontWeight == FontWeight.Bold
        }
    // We modify or remove existing styles to exclude the selected range
    for (style in intersectingStyles) {
        val range = style.textRange
        if (range.start >= selection.min && range.end <= selection.max) {
            // The style is fully inside the selection. Remove it.
            removeStyle(style)
        } else if (range.start < selection.min && range.end > selection.max) {
            // The style completely covers the selection. We need to split it.
            val oldEnd = range.end
            // Truncate the start part
            style.textRange = TextRange(range.start, selection.min)
            // Add a new style for the end part
            addStyle(
                SpanStyle(fontWeight = FontWeight.Bold),
                TextRange(selection.max, oldEnd),
                ExpandPolicy.AtEnd,
            )
        } else if (range.start < selection.min) {
            // The style overlaps with the start of the selection. Truncate it.
            style.textRange = TextRange(range.start, selection.min)
        } else {
            // The style overlaps with the end of the selection. Truncate it.
            style.textRange = TextRange(selection.max, range.end)
        }
    }
}

fun TextFieldBuffer.boldSelection() {
    // Query existing bold styles in the selection
    val intersectingStyles =
        getSpanStyles(selection.min, selection.max).filter {
            it.spanStyle.fontWeight == FontWeight.Bold
        }
    // To keep bold styles non-overlapping, we merge any intersecting bold
    // styles with the new selection range into a single contiguous bold style.
    var mergedStart = selection.min
    var mergedEnd = selection.max

    for (style in intersectingStyles) {
        mergedStart = minOf(mergedStart, style.textRange.start)
        mergedEnd = maxOf(mergedEnd, style.textRange.end)
        // Remove the fragmented style
        removeStyle(style)
    }

    addStyle(
        SpanStyle(fontWeight = FontWeight.Bold),
        TextRange(mergedStart, mergedEnd),
        ExpandPolicy.AtEnd,
    )
}

Column {
    Button(
        onClick = {
            state.edit {
                val selection = this.selection
                if (selection.collapsed) return@edit
                if (isSelection100PercentBold) {
                    unBoldSelection()
                } else {
                    boldSelection()
                }
            }
        }
    ) {
        Text(
            "B",
            fontWeight = if (isSelection100PercentBold) FontWeight.Bold else FontWeight.Normal,
        )
    }

    BasicTextField(state = state, textStyle = LocalTextStyle.current)
}
Throws
IllegalArgumentException

if start or end is out of range, or if start>end.

append

open fun append(char: Char): Appendable

append

open fun append(text: CharSequence?): Appendable

append

open fun append(text: CharSequence?, start: Int, end: Int): Appendable

asCharSequence

fun asCharSequence(): CharSequence

Returns a CharSequence backed by this buffer. Any subsequent changes to this buffer will be visible in the returned sequence as well.

charAt

fun charAt(index: Int): Char

Returns the Char at index in this buffer.

getParagraphStyles

fun getParagraphStyles(start: Int, end: Int): List<TrackedRange<ParagraphStyle>>

Returns the ParagraphStyles that intersect with the range defined by start (inclusive) and end (exclusive).

Styles are returned in the same order they were originally added to the buffer.

A style intersects with the range if it overlaps with it at any point. For non-empty ranges, this means style.start < end and start < style.end.

Example Query Range: [5, 15)

0    5    10   15   20   25
|----|----|----|----|----|
[---------) Query Range [5, 15)

[-------------------) Style [0, 20) (Contains query) -> Returned
[----) Style [8, 12) (Inside query) -> Returned
[----------) Style [0, 10) (Overlap start) -> Returned
[----) Style [0, 5) (Touching start) -> NOT Returned
[----------) Style [15, 25)(Touching end) -> NOT Returned

Example Collapsed Query: [10, 10)

0    5    10   15   20   25
|----|----|----|----|----|
| Query Range [10, 10)

[-------------------) Style [0, 20) (Contains query) -> Returned
[----------) Style [0, 10) (Touching end) -> NOT Returned
[----------) Style [10, 20)(Touching start) -> Returned
Parameters
start: Int

the inclusive start offset of the range

end: Int

the exclusive end offset of the range

Returns
List<TrackedRange<ParagraphStyle>>

a list of TrackedRanges referencing the styles intersecting with the given range, returned in the order they were added to the buffer.

Throws
IllegalArgumentException

if start or end is out of 0, length, or start>end.

getSpanStyles

fun getSpanStyles(start: Int, end: Int): List<TrackedRange<SpanStyle>>

Returns the SpanStyles that intersect with the given range defined by start (inclusive) and end (exclusive).

Styles are returned in the same order they were originally added to the buffer.

A style intersects with the range if it overlaps with it at any point. For non-empty ranges, this means style.start < end and start < style.end.

Example Query Range: [5, 15)

0    5    10   15   20   25
|----|----|----|----|----|
[---------) Query Range [5, 15)

[-------------------) Style [0, 20) (Contains query) -> Returned
[----) Style [8, 12) (Inside query) -> Returned
[----------) Style [0, 10) (Overlap start) -> Returned
[----) Style [0, 5) (Touching start) -> NOT Returned
[----------) Style [15, 25)(Touching end) -> NOT Returned

Example Collapsed Query: [10, 10)

0    5    10   15   20   25
|----|----|----|----|----|
| Query Range [10, 10)

[-------------------) Style [0, 20) (Contains query) -> Returned
[----------) Style [0, 10) (Touching end) -> NOT Returned
[----------) Style [10, 20)(Touching start) -> Returned
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.input.ExpandPolicy
import androidx.compose.foundation.text.input.InputTransformation
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.delete
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.Text
import androidx.compose.runtime.remember
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight

// This sample demonstrates how to use the `TrackedRange` API to track and modify text ranges
// dynamically. It implements a basic Markdown-like behavior where text typed inside double
// asterisks (e.g., **bold**) is automatically bolded, and the asterisks are removed.
val state = rememberTextFieldState("")

fun IntRange.toTextRange(): TextRange {
    // Unlike IntRange, TextRange is exclusive at the end.
    return TextRange(first, last + 1)
}

val inputTransformation = remember {
    InputTransformation {
        val text = asCharSequence().toString()
        val matches = "\\*\\*([^*]+)\\*\\*".toRegex().findAll(text).toList()
        matches
            .map { match ->
                val contentRange = match.groups[0]!!.range
                // Apply bold style to the text inside asterisks (including the
                // asterisks for now).
                addStyle(
                    SpanStyle(fontWeight = FontWeight.Bold),
                    contentRange.toTextRange(),
                    ExpandPolicy.InsideOnly,
                )
            }
            .forEach { trackedRange ->
                // Remove the asterisks here.

                // `trackedRange` simplifies this logic: normally, deleting characters at
                // the start would shift the end index. However, because `trackedRange`
                // automatically tracks text updates and adjusts its offsets
                // dynamically, we can safely delete the target range without having to
                // calculate the offset manually.
                delete(trackedRange.textRange.start, trackedRange.textRange.start + 2)
                delete(trackedRange.textRange.end - 2, trackedRange.textRange.end)
            }
    }
}

Column {
    Text("Type **text** below to automatically bold it.")

    BasicTextField(
        state = state,
        textStyle = LocalTextStyle.current,
        inputTransformation = inputTransformation,
    )
}
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.input.ExpandPolicy
import androidx.compose.foundation.text.input.TextFieldBuffer
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material.Button
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.Text
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp

// This sample demonstrates a realistic rich-text editor scenario using the `TrackedRange` and
// `TextFieldTextStyles` APIs. It implements a "Toggle Bold" formatting function on the current
// selection.

// For simplicity, this sample keeps bold styles non-overlapping and contiguous, assuming they
// are
// applied exclusively through this method.
val state = rememberTextFieldState("Hello World")

// This derived state calculates whether the current selection is completely covered by
// bold text styles. This ensures the "Bold" toggle button accurately reflects the
// state of the selected text.
val isSelection100PercentBold by derivedStateOf {
    val selection = state.selection
    if (selection.collapsed) {
        false
    } else {
        val spanStyles = state.textStyles.getSpanStyles(selection.min, selection.max)
        var boldCoverage = 0
        for (style in spanStyles) {
            if (style.item.fontWeight == FontWeight.Bold) {
                val overlapStart = maxOf(style.start, selection.min)
                val overlapEnd = minOf(style.end, selection.max)
                if (overlapEnd > overlapStart) {
                    boldCoverage += (overlapEnd - overlapStart)
                }
            }
        }
        boldCoverage == selection.length
    }
}

fun TextFieldBuffer.unBoldSelection() {
    // Query existing bold styles in the selection
    val intersectingStyles =
        getSpanStyles(selection.min, selection.max).filter {
            it.spanStyle.fontWeight == FontWeight.Bold
        }
    // We modify or remove existing styles to exclude the selected range
    for (style in intersectingStyles) {
        val range = style.textRange
        if (range.start >= selection.min && range.end <= selection.max) {
            // The style is fully inside the selection. Remove it.
            removeStyle(style)
        } else if (range.start < selection.min && range.end > selection.max) {
            // The style completely covers the selection. We need to split it.
            val oldEnd = range.end
            // Truncate the start part
            style.textRange = TextRange(range.start, selection.min)
            // Add a new style for the end part
            addStyle(
                SpanStyle(fontWeight = FontWeight.Bold),
                TextRange(selection.max, oldEnd),
                ExpandPolicy.AtEnd,
            )
        } else if (range.start < selection.min) {
            // The style overlaps with the start of the selection. Truncate it.
            style.textRange = TextRange(range.start, selection.min)
        } else {
            // The style overlaps with the end of the selection. Truncate it.
            style.textRange = TextRange(selection.max, range.end)
        }
    }
}

fun TextFieldBuffer.boldSelection() {
    // Query existing bold styles in the selection
    val intersectingStyles =
        getSpanStyles(selection.min, selection.max).filter {
            it.spanStyle.fontWeight == FontWeight.Bold
        }
    // To keep bold styles non-overlapping, we merge any intersecting bold
    // styles with the new selection range into a single contiguous bold style.
    var mergedStart = selection.min
    var mergedEnd = selection.max

    for (style in intersectingStyles) {
        mergedStart = minOf(mergedStart, style.textRange.start)
        mergedEnd = maxOf(mergedEnd, style.textRange.end)
        // Remove the fragmented style
        removeStyle(style)
    }

    addStyle(
        SpanStyle(fontWeight = FontWeight.Bold),
        TextRange(mergedStart, mergedEnd),
        ExpandPolicy.AtEnd,
    )
}

Column {
    Button(
        onClick = {
            state.edit {
                val selection = this.selection
                if (selection.collapsed) return@edit
                if (isSelection100PercentBold) {
                    unBoldSelection()
                } else {
                    boldSelection()
                }
            }
        }
    ) {
        Text(
            "B",
            fontWeight = if (isSelection100PercentBold) FontWeight.Bold else FontWeight.Normal,
        )
    }

    BasicTextField(state = state, textStyle = LocalTextStyle.current)
}
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.material.Text
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp

// Wipe the bold style on a given range using the TrackedRange.textRange API
val state = TextFieldState("Hello World")

state.edit {
    // Assume we want to "wipe" all bold styles from the first 5 characters.
    val rangeToWipe = TextRange(0, 5)

    // Get all span styles that intersect with the wipe range.
    getSpanStyles(rangeToWipe.start, rangeToWipe.end).forEach { trackedRange ->
        if (trackedRange.spanStyle.fontWeight == FontWeight.Bold) {
            val current = trackedRange.textRange

            if (rangeToWipe.start <= current.start && current.end <= rangeToWipe.end) {
                // Case 1: The bold style is entirely within the wipe range, remove it.
                removeStyle(trackedRange)
            } else if (current.start < rangeToWipe.start && rangeToWipe.end < current.end) {
                // Case 2: The wipe range is in the middle: split the style into two parts.
                val oldEnd = current.end
                // Truncate the original style to end at the start of the wipe range.
                trackedRange.textRange = TextRange(current.start, rangeToWipe.start)
                // Add a new bold style starting after the wipe range.
                addStyle(trackedRange.spanStyle, rangeToWipe.end, oldEnd)
            } else if (current.start < rangeToWipe.start) {
                // Case 3: Overlap at the start of wipe: truncate the style's end.
                trackedRange.textRange = TextRange(current.start, rangeToWipe.start)
            } else {
                // Case 4: Overlap at the end of wipe: truncate the style's start.
                trackedRange.textRange = TextRange(rangeToWipe.end, current.end)
            }
        }
    }
}
Parameters
start: Int

the inclusive start offset of the range

end: Int

the exclusive end offset of the range

Returns
List<TrackedRange<SpanStyle>>

a list of TrackedRanges referencing the styles intersecting with the given range, returned in the order they were added to the buffer.

Throws
IllegalArgumentException

if start or end is out of 0, length, or start>end.

placeCursorAfterCharAt

fun placeCursorAfterCharAt(index: Int): Unit

Places the cursor after the character at the given index.

If index is inside a surrogate pair or other invalid run, the cursor will be placed at the nearest later index.

To place the cursor at the end of the field, after the last character, pass index TextFieldBuffer.length or call placeCursorAtEnd.

Parameters
index: Int

Character index to place cursor after, should be in range 0 (inclusive) to TextFieldBuffer.length (exclusive).

placeCursorBeforeCharAt

fun placeCursorBeforeCharAt(index: Int): Unit

Places the cursor before the character at the given index.

If index is inside a surrogate pair or other invalid run, the cursor will be placed at the nearest earlier index.

To place the cursor at the beginning of the field, pass index 0. To place the cursor at the end of the field, after the last character, pass index TextFieldBuffer.length or call placeCursorAtEnd.

Parameters
index: Int

Character index to place cursor before, should be in range 0 to TextFieldBuffer.length, inclusive.

removeStyle

fun removeStyle(trackedRange: TrackedRange<*>): Boolean

Removes the exact style represented by the given trackedRange from this buffer. This method only removes the specific style range tied to this TrackedRange object. Since trackedRange uniquely identifies a style range, it does not remove other styles that happen to occupy the same text range.

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.input.ExpandPolicy
import androidx.compose.foundation.text.input.InputTransformation
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.delete
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.Text
import androidx.compose.runtime.remember
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight

// This sample demonstrates how to use the `TrackedRange` API to track and modify text ranges
// dynamically. It implements a basic Markdown-like behavior where text typed inside double
// asterisks (e.g., **bold**) is automatically bolded, and the asterisks are removed.
val state = rememberTextFieldState("")

fun IntRange.toTextRange(): TextRange {
    // Unlike IntRange, TextRange is exclusive at the end.
    return TextRange(first, last + 1)
}

val inputTransformation = remember {
    InputTransformation {
        val text = asCharSequence().toString()
        val matches = "\\*\\*([^*]+)\\*\\*".toRegex().findAll(text).toList()
        matches
            .map { match ->
                val contentRange = match.groups[0]!!.range
                // Apply bold style to the text inside asterisks (including the
                // asterisks for now).
                addStyle(
                    SpanStyle(fontWeight = FontWeight.Bold),
                    contentRange.toTextRange(),
                    ExpandPolicy.InsideOnly,
                )
            }
            .forEach { trackedRange ->
                // Remove the asterisks here.

                // `trackedRange` simplifies this logic: normally, deleting characters at
                // the start would shift the end index. However, because `trackedRange`
                // automatically tracks text updates and adjusts its offsets
                // dynamically, we can safely delete the target range without having to
                // calculate the offset manually.
                delete(trackedRange.textRange.start, trackedRange.textRange.start + 2)
                delete(trackedRange.textRange.end - 2, trackedRange.textRange.end)
            }
    }
}

Column {
    Text("Type **text** below to automatically bold it.")

    BasicTextField(
        state = state,
        textStyle = LocalTextStyle.current,
        inputTransformation = inputTransformation,
    )
}
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.input.ExpandPolicy
import androidx.compose.foundation.text.input.TextFieldBuffer
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material.Button
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.Text
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp

// This sample demonstrates a realistic rich-text editor scenario using the `TrackedRange` and
// `TextFieldTextStyles` APIs. It implements a "Toggle Bold" formatting function on the current
// selection.

// For simplicity, this sample keeps bold styles non-overlapping and contiguous, assuming they
// are
// applied exclusively through this method.
val state = rememberTextFieldState("Hello World")

// This derived state calculates whether the current selection is completely covered by
// bold text styles. This ensures the "Bold" toggle button accurately reflects the
// state of the selected text.
val isSelection100PercentBold by derivedStateOf {
    val selection = state.selection
    if (selection.collapsed) {
        false
    } else {
        val spanStyles = state.textStyles.getSpanStyles(selection.min, selection.max)
        var boldCoverage = 0
        for (style in spanStyles) {
            if (style.item.fontWeight == FontWeight.Bold) {
                val overlapStart = maxOf(style.start, selection.min)
                val overlapEnd = minOf(style.end, selection.max)
                if (overlapEnd > overlapStart) {
                    boldCoverage += (overlapEnd - overlapStart)
                }
            }
        }
        boldCoverage == selection.length
    }
}

fun TextFieldBuffer.unBoldSelection() {
    // Query existing bold styles in the selection
    val intersectingStyles =
        getSpanStyles(selection.min, selection.max).filter {
            it.spanStyle.fontWeight == FontWeight.Bold
        }
    // We modify or remove existing styles to exclude the selected range
    for (style in intersectingStyles) {
        val range = style.textRange
        if (range.start >= selection.min && range.end <= selection.max) {
            // The style is fully inside the selection. Remove it.
            removeStyle(style)
        } else if (range.start < selection.min && range.end > selection.max) {
            // The style completely covers the selection. We need to split it.
            val oldEnd = range.end
            // Truncate the start part
            style.textRange = TextRange(range.start, selection.min)
            // Add a new style for the end part
            addStyle(
                SpanStyle(fontWeight = FontWeight.Bold),
                TextRange(selection.max, oldEnd),
                ExpandPolicy.AtEnd,
            )
        } else if (range.start < selection.min) {
            // The style overlaps with the start of the selection. Truncate it.
            style.textRange = TextRange(range.start, selection.min)
        } else {
            // The style overlaps with the end of the selection. Truncate it.
            style.textRange = TextRange(selection.max, range.end)
        }
    }
}

fun TextFieldBuffer.boldSelection() {
    // Query existing bold styles in the selection
    val intersectingStyles =
        getSpanStyles(selection.min, selection.max).filter {
            it.spanStyle.fontWeight == FontWeight.Bold
        }
    // To keep bold styles non-overlapping, we merge any intersecting bold
    // styles with the new selection range into a single contiguous bold style.
    var mergedStart = selection.min
    var mergedEnd = selection.max

    for (style in intersectingStyles) {
        mergedStart = minOf(mergedStart, style.textRange.start)
        mergedEnd = maxOf(mergedEnd, style.textRange.end)
        // Remove the fragmented style
        removeStyle(style)
    }

    addStyle(
        SpanStyle(fontWeight = FontWeight.Bold),
        TextRange(mergedStart, mergedEnd),
        ExpandPolicy.AtEnd,
    )
}

Column {
    Button(
        onClick = {
            state.edit {
                val selection = this.selection
                if (selection.collapsed) return@edit
                if (isSelection100PercentBold) {
                    unBoldSelection()
                } else {
                    boldSelection()
                }
            }
        }
    ) {
        Text(
            "B",
            fontWeight = if (isSelection100PercentBold) FontWeight.Bold else FontWeight.Normal,
        )
    }

    BasicTextField(state = state, textStyle = LocalTextStyle.current)
}
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.material.Text
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp

// Wipe the bold style on a given range using the TrackedRange.textRange API
val state = TextFieldState("Hello World")

state.edit {
    // Assume we want to "wipe" all bold styles from the first 5 characters.
    val rangeToWipe = TextRange(0, 5)

    // Get all span styles that intersect with the wipe range.
    getSpanStyles(rangeToWipe.start, rangeToWipe.end).forEach { trackedRange ->
        if (trackedRange.spanStyle.fontWeight == FontWeight.Bold) {
            val current = trackedRange.textRange

            if (rangeToWipe.start <= current.start && current.end <= rangeToWipe.end) {
                // Case 1: The bold style is entirely within the wipe range, remove it.
                removeStyle(trackedRange)
            } else if (current.start < rangeToWipe.start && rangeToWipe.end < current.end) {
                // Case 2: The wipe range is in the middle: split the style into two parts.
                val oldEnd = current.end
                // Truncate the original style to end at the start of the wipe range.
                trackedRange.textRange = TextRange(current.start, rangeToWipe.start)
                // Add a new bold style starting after the wipe range.
                addStyle(trackedRange.spanStyle, rangeToWipe.end, oldEnd)
            } else if (current.start < rangeToWipe.start) {
                // Case 3: Overlap at the start of wipe: truncate the style's end.
                trackedRange.textRange = TextRange(current.start, rangeToWipe.start)
            } else {
                // Case 4: Overlap at the end of wipe: truncate the style's start.
                trackedRange.textRange = TextRange(rangeToWipe.end, current.end)
            }
        }
    }
}
Parameters
trackedRange: TrackedRange<*>

the TrackedRange referencing the specific style range to be removed. If the trackedRange was not added to this buffer, or has already been removed, this method will do nothing and return false.

Returns
Boolean

true if the given trackedRange is found in the TextFieldBuffer and successfully removed, false otherwise.

replace

fun replace(start: Int, end: Int, text: CharSequence): Unit

Replaces the text between start (inclusive) and end (exclusive) in this value with text, and records the change in changes.

Parameters
start: Int

The character offset of the first character to replace.

end: Int

The character offset of the first character after the text to replace.

text: CharSequence

The text to replace the range [start, end) with.

See also
append
insert
delete

revertAllChanges

fun revertAllChanges(): Unit

Revert all changes made to this value since it was created.

After calling this method, this object will be in the same state it was when it was initially created, and changes will be empty.

toString

open fun toString(): String

Public properties

changes

@ExperimentalFoundationApi
val changesTextFieldBuffer.ChangeList

The ChangeList represents the changes made to this value and is inherently mutable. This means that the returned ChangeList always reflects the complete list of changes made to this value at any given time, even those made after reading this property.

import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.forEachChange
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material.Text
import androidx.compose.runtime.remember
import androidx.compose.ui.text.substring

// Print a log message every time the text is changed.
BasicTextField(
    state = rememberTextFieldState(),
    inputTransformation = {
        changes.forEachChange { sourceRange, replacedLength ->
            val newString = asCharSequence().substring(sourceRange)
            println("""$replacedLength characters were replaced with "$newString"""")
        }
    },
)
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.delete
import androidx.compose.foundation.text.input.forEachChange
import androidx.compose.foundation.text.input.forEachChangeReversed
import androidx.compose.foundation.text.input.insert
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material.Text
import androidx.compose.runtime.remember

// Make a text field behave in "insert mode" – inserted text overwrites the text ahead of it
// instead of being inserted.
BasicTextField(
    state = rememberTextFieldState(),
    inputTransformation = {
        changes.forEachChangeReversed { range, originalRange ->
            if (!range.collapsed && originalRange.collapsed) {
                // New text was inserted, delete the text ahead of it.
                delete(
                    range.end.coerceAtMost(length),
                    (range.end + range.length).coerceAtMost(length),
                )
            }
        }
    },
)

TrackedRange.expandPolicy

var TrackedRange<*>.expandPolicyExpandPolicy

The ExpandPolicy defining how the style range expands when text is inserted at its boundaries.

This property is only accessible within the TextFieldBuffer scope where the TrackedRange was created.

Modifying the text can potentially invalidate a TrackedRange if its length collapses to zero. It is recommended to check valid before accessing this property if any text changes were made, or if the range might have been explicitly removed via removeStyle.

Setting this property will update the expand policy in-place, preserving its original applying order relative to other styles in the buffer.

import androidx.compose.foundation.text.input.ExpandPolicy
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.delete
import androidx.compose.material.Text
import androidx.compose.ui.text.SpanStyle

// This sample demonstrates the use of [TrackedRange.valid] and [TrackedRange.expandPolicy]. It
// shows how mutating the text can cause a TrackedRange to become invalid, and how to
// dynamically
// update the behavior of a range.
val state = TextFieldState("Hello World")

state.edit {
    // Query the existing styles on the text
    val existingStyles = getSpanStyles(0, length)

    existingStyles.forEach { trackedRange ->
        // Read and update the expand policy of a style
        if (trackedRange.expandPolicy == ExpandPolicy.InsideOnly) {
            trackedRange.expandPolicy = ExpandPolicy.AtEnd
        }
    }

    // Do some edits that might delete the styled text
    delete(0, 5)

    // After the edits, we can check if the previously queried ranges are still valid
    existingStyles.forEach { trackedRange ->
        // The style's range might have collapsed to zero length, making it no longer valid.
        // It is recommended to check validity before accessing properties like textRange.
        if (trackedRange.valid) {
            // Style is still valid, it's up-to-date range can be accessed via
            // trackedRange.textRange
        } else {
            // Style was completely deleted.
        }
    }
}
Throws
IllegalStateException

if this TrackedRange no longer exists in the buffer.

hasSelection

val hasSelectionBoolean

True if the selection range has non-zero length. If this is false, then the selection represents the cursor.

See also
selection

length

val lengthInt

The number of characters in the text field.

originalSelection

val originalSelectionTextRange

Original selection before the changes. Calling revertAllChanges will set the selection to this value.

originalText

val originalTextCharSequence

Original text content of the buffer before any changes were applied. Calling revertAllChanges will set the contents of this buffer to this value.

TrackedRange.paragraphStyle

var TrackedRange<ParagraphStyle>.paragraphStyleParagraphStyle

The ParagraphStyle object associated with this TrackedRange.

This property is only accessible within the TextFieldBuffer block where the TrackedRange was created.

Modifying the text can potentially invalidate a TrackedRange if its length collapses to zero. It is recommended to check valid before accessing this property if any text changes were made, or if the range might have been explicitly removed via removeStyle.

Setting this property will update the style applied to the text in-place, preserving its original applying order relative to other styles in the buffer.

Throws
IllegalStateException

if this TrackedRange no longer exists in the buffer.

selection

var selectionTextRange

The selected range of characters.

Places the selection around the given range in characters.

If the start or end of TextRange fall inside surrogate pairs or other invalid runs, the values will be adjusted to the nearest earlier and later characters, respectively.

To place the start of the selection at the beginning of the field, set this value to TextRange.Zero. To place the end of the selection at the end of the field, after the last character, pass TextFieldBuffer.length. Passing a zero-length range is the same as calling placeCursorBeforeCharAt.

TrackedRange.spanStyle

var TrackedRange<SpanStyle>.spanStyleSpanStyle

The SpanStyle object associated with this TrackedRange.

This property is only accessible within the TextFieldBuffer block where the TrackedRange was created.

Modifying the text can potentially invalidate a TrackedRange if its length collapses to zero. It is recommended to check valid before accessing this property if any text changes were made, or if the range might have been explicitly removed via removeStyle.

Setting this property will update the style applied to the text in-place, preserving its original applying order relative to other styles in the buffer.

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.input.ExpandPolicy
import androidx.compose.foundation.text.input.InputTransformation
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.delete
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.Text
import androidx.compose.runtime.remember
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight

// This sample demonstrates how to use the `TrackedRange` API to track and modify text ranges
// dynamically. It implements a basic Markdown-like behavior where text typed inside double
// asterisks (e.g., **bold**) is automatically bolded, and the asterisks are removed.
val state = rememberTextFieldState("")

fun IntRange.toTextRange(): TextRange {
    // Unlike IntRange, TextRange is exclusive at the end.
    return TextRange(first, last + 1)
}

val inputTransformation = remember {
    InputTransformation {
        val text = asCharSequence().toString()
        val matches = "\\*\\*([^*]+)\\*\\*".toRegex().findAll(text).toList()
        matches
            .map { match ->
                val contentRange = match.groups[0]!!.range
                // Apply bold style to the text inside asterisks (including the
                // asterisks for now).
                addStyle(
                    SpanStyle(fontWeight = FontWeight.Bold),
                    contentRange.toTextRange(),
                    ExpandPolicy.InsideOnly,
                )
            }
            .forEach { trackedRange ->
                // Remove the asterisks here.

                // `trackedRange` simplifies this logic: normally, deleting characters at
                // the start would shift the end index. However, because `trackedRange`
                // automatically tracks text updates and adjusts its offsets
                // dynamically, we can safely delete the target range without having to
                // calculate the offset manually.
                delete(trackedRange.textRange.start, trackedRange.textRange.start + 2)
                delete(trackedRange.textRange.end - 2, trackedRange.textRange.end)
            }
    }
}

Column {
    Text("Type **text** below to automatically bold it.")

    BasicTextField(
        state = state,
        textStyle = LocalTextStyle.current,
        inputTransformation = inputTransformation,
    )
}
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.input.ExpandPolicy
import androidx.compose.foundation.text.input.TextFieldBuffer
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material.Button
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.Text
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp

// This sample demonstrates a realistic rich-text editor scenario using the `TrackedRange` and
// `TextFieldTextStyles` APIs. It implements a "Toggle Bold" formatting function on the current
// selection.

// For simplicity, this sample keeps bold styles non-overlapping and contiguous, assuming they
// are
// applied exclusively through this method.
val state = rememberTextFieldState("Hello World")

// This derived state calculates whether the current selection is completely covered by
// bold text styles. This ensures the "Bold" toggle button accurately reflects the
// state of the selected text.
val isSelection100PercentBold by derivedStateOf {
    val selection = state.selection
    if (selection.collapsed) {
        false
    } else {
        val spanStyles = state.textStyles.getSpanStyles(selection.min, selection.max)
        var boldCoverage = 0
        for (style in spanStyles) {
            if (style.item.fontWeight == FontWeight.Bold) {
                val overlapStart = maxOf(style.start, selection.min)
                val overlapEnd = minOf(style.end, selection.max)
                if (overlapEnd > overlapStart) {
                    boldCoverage += (overlapEnd - overlapStart)
                }
            }
        }
        boldCoverage == selection.length
    }
}

fun TextFieldBuffer.unBoldSelection() {
    // Query existing bold styles in the selection
    val intersectingStyles =
        getSpanStyles(selection.min, selection.max).filter {
            it.spanStyle.fontWeight == FontWeight.Bold
        }
    // We modify or remove existing styles to exclude the selected range
    for (style in intersectingStyles) {
        val range = style.textRange
        if (range.start >= selection.min && range.end <= selection.max) {
            // The style is fully inside the selection. Remove it.
            removeStyle(style)
        } else if (range.start < selection.min && range.end > selection.max) {
            // The style completely covers the selection. We need to split it.
            val oldEnd = range.end
            // Truncate the start part
            style.textRange = TextRange(range.start, selection.min)
            // Add a new style for the end part
            addStyle(
                SpanStyle(fontWeight = FontWeight.Bold),
                TextRange(selection.max, oldEnd),
                ExpandPolicy.AtEnd,
            )
        } else if (range.start < selection.min) {
            // The style overlaps with the start of the selection. Truncate it.
            style.textRange = TextRange(range.start, selection.min)
        } else {
            // The style overlaps with the end of the selection. Truncate it.
            style.textRange = TextRange(selection.max, range.end)
        }
    }
}

fun TextFieldBuffer.boldSelection() {
    // Query existing bold styles in the selection
    val intersectingStyles =
        getSpanStyles(selection.min, selection.max).filter {
            it.spanStyle.fontWeight == FontWeight.Bold
        }
    // To keep bold styles non-overlapping, we merge any intersecting bold
    // styles with the new selection range into a single contiguous bold style.
    var mergedStart = selection.min
    var mergedEnd = selection.max

    for (style in intersectingStyles) {
        mergedStart = minOf(mergedStart, style.textRange.start)
        mergedEnd = maxOf(mergedEnd, style.textRange.end)
        // Remove the fragmented style
        removeStyle(style)
    }

    addStyle(
        SpanStyle(fontWeight = FontWeight.Bold),
        TextRange(mergedStart, mergedEnd),
        ExpandPolicy.AtEnd,
    )
}

Column {
    Button(
        onClick = {
            state.edit {
                val selection = this.selection
                if (selection.collapsed) return@edit
                if (isSelection100PercentBold) {
                    unBoldSelection()
                } else {
                    boldSelection()
                }
            }
        }
    ) {
        Text(
            "B",
            fontWeight = if (isSelection100PercentBold) FontWeight.Bold else FontWeight.Normal,
        )
    }

    BasicTextField(state = state, textStyle = LocalTextStyle.current)
}
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.material.Text
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp

// Wipe the bold style on a given range using the TrackedRange.textRange API
val state = TextFieldState("Hello World")

state.edit {
    // Assume we want to "wipe" all bold styles from the first 5 characters.
    val rangeToWipe = TextRange(0, 5)

    // Get all span styles that intersect with the wipe range.
    getSpanStyles(rangeToWipe.start, rangeToWipe.end).forEach { trackedRange ->
        if (trackedRange.spanStyle.fontWeight == FontWeight.Bold) {
            val current = trackedRange.textRange

            if (rangeToWipe.start <= current.start && current.end <= rangeToWipe.end) {
                // Case 1: The bold style is entirely within the wipe range, remove it.
                removeStyle(trackedRange)
            } else if (current.start < rangeToWipe.start && rangeToWipe.end < current.end) {
                // Case 2: The wipe range is in the middle: split the style into two parts.
                val oldEnd = current.end
                // Truncate the original style to end at the start of the wipe range.
                trackedRange.textRange = TextRange(current.start, rangeToWipe.start)
                // Add a new bold style starting after the wipe range.
                addStyle(trackedRange.spanStyle, rangeToWipe.end, oldEnd)
            } else if (current.start < rangeToWipe.start) {
                // Case 3: Overlap at the start of wipe: truncate the style's end.
                trackedRange.textRange = TextRange(current.start, rangeToWipe.start)
            } else {
                // Case 4: Overlap at the end of wipe: truncate the style's start.
                trackedRange.textRange = TextRange(rangeToWipe.end, current.end)
            }
        }
    }
}
Throws
IllegalStateException

if this TrackedRange no longer exists in the buffer.

TrackedRange.textRange

var TrackedRange<*>.textRangeTextRange

The TextRange of this style. This range will reflect the up-to-date style range as the text is edited.

This property is only accessible within the TextFieldBuffer block where the TrackedRange was created. Do not keep a reference to the TrackedRange outside of that block.

Modifying the text can potentially invalidate a TrackedRange if its length collapses to zero. It is recommended to check valid before accessing this property if any text changes were made, or if the range might have been explicitly removed via removeStyle.

Setting this property will update the range of the style in-place, preserving its original applying order relative to other styles in the buffer.

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.input.ExpandPolicy
import androidx.compose.foundation.text.input.InputTransformation
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.delete
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.Text
import androidx.compose.runtime.remember
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight

// This sample demonstrates how to use the `TrackedRange` API to track and modify text ranges
// dynamically. It implements a basic Markdown-like behavior where text typed inside double
// asterisks (e.g., **bold**) is automatically bolded, and the asterisks are removed.
val state = rememberTextFieldState("")

fun IntRange.toTextRange(): TextRange {
    // Unlike IntRange, TextRange is exclusive at the end.
    return TextRange(first, last + 1)
}

val inputTransformation = remember {
    InputTransformation {
        val text = asCharSequence().toString()
        val matches = "\\*\\*([^*]+)\\*\\*".toRegex().findAll(text).toList()
        matches
            .map { match ->
                val contentRange = match.groups[0]!!.range
                // Apply bold style to the text inside asterisks (including the
                // asterisks for now).
                addStyle(
                    SpanStyle(fontWeight = FontWeight.Bold),
                    contentRange.toTextRange(),
                    ExpandPolicy.InsideOnly,
                )
            }
            .forEach { trackedRange ->
                // Remove the asterisks here.

                // `trackedRange` simplifies this logic: normally, deleting characters at
                // the start would shift the end index. However, because `trackedRange`
                // automatically tracks text updates and adjusts its offsets
                // dynamically, we can safely delete the target range without having to
                // calculate the offset manually.
                delete(trackedRange.textRange.start, trackedRange.textRange.start + 2)
                delete(trackedRange.textRange.end - 2, trackedRange.textRange.end)
            }
    }
}

Column {
    Text("Type **text** below to automatically bold it.")

    BasicTextField(
        state = state,
        textStyle = LocalTextStyle.current,
        inputTransformation = inputTransformation,
    )
}
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.input.ExpandPolicy
import androidx.compose.foundation.text.input.TextFieldBuffer
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material.Button
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.Text
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp

// This sample demonstrates a realistic rich-text editor scenario using the `TrackedRange` and
// `TextFieldTextStyles` APIs. It implements a "Toggle Bold" formatting function on the current
// selection.

// For simplicity, this sample keeps bold styles non-overlapping and contiguous, assuming they
// are
// applied exclusively through this method.
val state = rememberTextFieldState("Hello World")

// This derived state calculates whether the current selection is completely covered by
// bold text styles. This ensures the "Bold" toggle button accurately reflects the
// state of the selected text.
val isSelection100PercentBold by derivedStateOf {
    val selection = state.selection
    if (selection.collapsed) {
        false
    } else {
        val spanStyles = state.textStyles.getSpanStyles(selection.min, selection.max)
        var boldCoverage = 0
        for (style in spanStyles) {
            if (style.item.fontWeight == FontWeight.Bold) {
                val overlapStart = maxOf(style.start, selection.min)
                val overlapEnd = minOf(style.end, selection.max)
                if (overlapEnd > overlapStart) {
                    boldCoverage += (overlapEnd - overlapStart)
                }
            }
        }
        boldCoverage == selection.length
    }
}

fun TextFieldBuffer.unBoldSelection() {
    // Query existing bold styles in the selection
    val intersectingStyles =
        getSpanStyles(selection.min, selection.max).filter {
            it.spanStyle.fontWeight == FontWeight.Bold
        }
    // We modify or remove existing styles to exclude the selected range
    for (style in intersectingStyles) {
        val range = style.textRange
        if (range.start >= selection.min && range.end <= selection.max) {
            // The style is fully inside the selection. Remove it.
            removeStyle(style)
        } else if (range.start < selection.min && range.end > selection.max) {
            // The style completely covers the selection. We need to split it.
            val oldEnd = range.end
            // Truncate the start part
            style.textRange = TextRange(range.start, selection.min)
            // Add a new style for the end part
            addStyle(
                SpanStyle(fontWeight = FontWeight.Bold),
                TextRange(selection.max, oldEnd),
                ExpandPolicy.AtEnd,
            )
        } else if (range.start < selection.min) {
            // The style overlaps with the start of the selection. Truncate it.
            style.textRange = TextRange(range.start, selection.min)
        } else {
            // The style overlaps with the end of the selection. Truncate it.
            style.textRange = TextRange(selection.max, range.end)
        }
    }
}

fun TextFieldBuffer.boldSelection() {
    // Query existing bold styles in the selection
    val intersectingStyles =
        getSpanStyles(selection.min, selection.max).filter {
            it.spanStyle.fontWeight == FontWeight.Bold
        }
    // To keep bold styles non-overlapping, we merge any intersecting bold
    // styles with the new selection range into a single contiguous bold style.
    var mergedStart = selection.min
    var mergedEnd = selection.max

    for (style in intersectingStyles) {
        mergedStart = minOf(mergedStart, style.textRange.start)
        mergedEnd = maxOf(mergedEnd, style.textRange.end)
        // Remove the fragmented style
        removeStyle(style)
    }

    addStyle(
        SpanStyle(fontWeight = FontWeight.Bold),
        TextRange(mergedStart, mergedEnd),
        ExpandPolicy.AtEnd,
    )
}

Column {
    Button(
        onClick = {
            state.edit {
                val selection = this.selection
                if (selection.collapsed) return@edit
                if (isSelection100PercentBold) {
                    unBoldSelection()
                } else {
                    boldSelection()
                }
            }
        }
    ) {
        Text(
            "B",
            fontWeight = if (isSelection100PercentBold) FontWeight.Bold else FontWeight.Normal,
        )
    }

    BasicTextField(state = state, textStyle = LocalTextStyle.current)
}
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.material.Text
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp

// Wipe the bold style on a given range using the TrackedRange.textRange API
val state = TextFieldState("Hello World")

state.edit {
    // Assume we want to "wipe" all bold styles from the first 5 characters.
    val rangeToWipe = TextRange(0, 5)

    // Get all span styles that intersect with the wipe range.
    getSpanStyles(rangeToWipe.start, rangeToWipe.end).forEach { trackedRange ->
        if (trackedRange.spanStyle.fontWeight == FontWeight.Bold) {
            val current = trackedRange.textRange

            if (rangeToWipe.start <= current.start && current.end <= rangeToWipe.end) {
                // Case 1: The bold style is entirely within the wipe range, remove it.
                removeStyle(trackedRange)
            } else if (current.start < rangeToWipe.start && rangeToWipe.end < current.end) {
                // Case 2: The wipe range is in the middle: split the style into two parts.
                val oldEnd = current.end
                // Truncate the original style to end at the start of the wipe range.
                trackedRange.textRange = TextRange(current.start, rangeToWipe.start)
                // Add a new bold style starting after the wipe range.
                addStyle(trackedRange.spanStyle, rangeToWipe.end, oldEnd)
            } else if (current.start < rangeToWipe.start) {
                // Case 3: Overlap at the start of wipe: truncate the style's end.
                trackedRange.textRange = TextRange(current.start, rangeToWipe.start)
            } else {
                // Case 4: Overlap at the end of wipe: truncate the style's start.
                trackedRange.textRange = TextRange(rangeToWipe.end, current.end)
            }
        }
    }
}
Throws
IllegalStateException

if this TrackedRange no longer exists in the buffer.

IllegalArgumentException

if the new range is collapsed, reversed or out of range.

TrackedRange.valid

val TrackedRange<*>.validBoolean

Whether this TrackedRange is still valid in the buffer.

A style ceases to exist when removeStyle is called or when its range collapses to a length of zero due to text edits. Once it no longer exists, accessing or modifying its properties will throw an IllegalStateException.

This property is only accessible within the TextFieldBuffer scope where the TrackedRange was created.

import androidx.compose.foundation.text.input.ExpandPolicy
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.delete
import androidx.compose.material.Text
import androidx.compose.ui.text.SpanStyle

// This sample demonstrates the use of [TrackedRange.valid] and [TrackedRange.expandPolicy]. It
// shows how mutating the text can cause a TrackedRange to become invalid, and how to
// dynamically
// update the behavior of a range.
val state = TextFieldState("Hello World")

state.edit {
    // Query the existing styles on the text
    val existingStyles = getSpanStyles(0, length)

    existingStyles.forEach { trackedRange ->
        // Read and update the expand policy of a style
        if (trackedRange.expandPolicy == ExpandPolicy.InsideOnly) {
            trackedRange.expandPolicy = ExpandPolicy.AtEnd
        }
    }

    // Do some edits that might delete the styled text
    delete(0, 5)

    // After the edits, we can check if the previously queried ranges are still valid
    existingStyles.forEach { trackedRange ->
        // The style's range might have collapsed to zero length, making it no longer valid.
        // It is recommended to check validity before accessing properties like textRange.
        if (trackedRange.valid) {
            // Style is still valid, it's up-to-date range can be accessed via
            // trackedRange.textRange
        } else {
            // Style was completely deleted.
        }
    }
}

Extension functions

TextFieldBuffer.delete

fun TextFieldBuffer.delete(start: Int, end: Int): Unit

Delete the text between start (inclusive) and end (exclusive). Pass 0 as start and TextFieldBuffer.length as end to delete everything in this buffer.

Parameters
start: Int

The character offset of the first character to delete.

end: Int

The character offset of the first character after the deleted range.

See also
replace
append
insert

TextFieldBuffer.insert

fun TextFieldBuffer.insert(index: Int, text: String): Unit

Insert text at the given index in this value. Pass 0 to insert text at the beginning of this buffer, and pass TextFieldBuffer.length to insert text at the end of this buffer.

This is equivalent to calling replace(index, index, text).

Parameters
index: Int

The character offset at which to insert text.

text: String

The text to insert.

See also
replace
append
delete

TextFieldBuffer.placeCursorAtEnd

fun TextFieldBuffer.placeCursorAtEnd(): Unit

Places the cursor at the end of the text.

TextFieldBuffer.selectAll

fun TextFieldBuffer.selectAll(): Unit

Places the selection around all the text.