Skip to content

Fix cursor metrics in SkikoParagraph - #3405

Merged
Vladimir Mazunin (mazunin-v-jb) merged 9 commits into
JetBrains:jb-mainfrom
gavr123456789:gavr/CMP-8371
Nov 11, 2026
Merged

Vladimir Mazunin (mazunin-v-jb) merged 9 commits into
JetBrains:jb-mainfrom
gavr123456789:gavr/CMP-8371

Conversation

@gavr123456789

@gavr123456789 gavr (gavr123456789) commented Sep 10, 2026 •

Copy link
Copy Markdown

Fixes CMP-8371 Text cursor shrinks after typing begins when a TextField
There are 2 problems fixed.

  1. cursor height is different between empty string and non-empty string
Screen.Recording.2026-09-09.at.22.36.40.mov
  1. new symbol on the second line changes the baseline when font size is larger than line height
Screen.Recording.2026-09-09.at.22.38.07.mov

(fix by SkikoParagraphBuilder.nonAndroid.kt file)

To reproduce:

 Row(
            modifier = Modifier.fillMaxWidth(),
            horizontalArrangement = Arrangement.SpaceEvenly
        ) {

            var text by remember { mutableStateOf("") }
            val textFieldStyle = LocalTextStyle.current.copy(fontSize = 64.sp)
            TextField(
                value = text,
                onValueChange = {
                    println(
                        "[TextFieldDebug] text=${it} " +
                            "length=${it.length} style=$textFieldStyle"
                    )
                    text = it
                },
                modifier = Modifier.onFocusChanged {
                    println(
                        "[TextFieldDebug] focus=" +
                            "isFocused=${it.isFocused} isCaptured=${it.isCaptured}"
                    )
                },
                textStyle = textFieldStyle,
            )

            Column(modifier = Modifier.width(340.dp)) {
                var multilineText by remember { mutableStateOf("") }
                TextField(
                    value = multilineText,
                    onValueChange = { multilineText = it },
                    label = { Text("Multiline: 48sp font / 28sp lineH") },
                    textStyle = LocalTextStyle.current.copy(fontSize = 48.sp, lineHeight = 28.sp),
                    maxLines = 4,
                    modifier = Modifier.fillMaxWidth().height(150.dp),
                )

                var monospaceText by remember { mutableStateOf("") }
                TextField(
                    value = monospaceText,
                    onValueChange = { monospaceText = it },
                    label = { Text("Monospace: 32sp font / 48sp lineH") },
                    textStyle = LocalTextStyle.current.copy(
                        fontSize = 32.sp,
                        fontFamily = FontFamily.Monospace,
                        lineHeight = 48.sp,
                    ),
                    modifier = Modifier.fillMaxWidth(),
                )

                var smallText by remember { mutableStateOf("") }
                TextField(
                    value = smallText,
                    onValueChange = { smallText = it },
                    label = { Text("Small: 14sp font / 20sp lineH") },
                    textStyle = LocalTextStyle.current.copy(fontSize = 14.sp, lineHeight = 20.sp),
                    modifier = Modifier.fillMaxWidth(),
                )
            }
        }

Testing

Screen.Recording.2026-09-09.at.22.48.18.mov
image

Release Notes

Fixes - Multiple Platforms

  • Fixed the cursor metrics difference between empty and non-empty string

@gavr123456789

gavr (gavr123456789) commented Sep 10, 2026 •

Copy link
Copy Markdown
Author

test getBoundingBox_lineHeight is broken after my second fix.
https://youtrack.jetbrains.com/issue/CMP-7963

Expected Behavior
Hello and There should overlap each other

image

Now I see that this isn’t a bug: when the user chooses a font size > lineH, overlap is expected. Will unroll second fix.

@MatkovIvan Ivan Matkov (MatkovIvan) left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please resolve merge conflicts (rebase)

Comment on lines +345 to +351
private val emptyLineCursorMetrics: LineMetrics
get() = _emptyLineCursorMetrics ?: layouter
.emptyLineMetrics(paragraph)
.first()
.trimFirstAscent(defaultFont.metrics, layouter.textStyle)
.trimLastDescent(defaultFont.metrics, layouter.textStyle)
.also { _emptyLineCursorMetrics = it }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why cannot we reuse receiveLineMetrics() here and need a separate resolve? Shouldn't we fix that variant instead (if it's wrong for some reason) and keep a single source of truth?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

receiveLineMetrics() chooses its source based on whether the whole text is empty,
for an empty paragraph it returns the same empty-line metrics, but for non-empty text it returns paragraph.lineMetrics, that's where the difference in cursor height comes from.
The cursor intentionally uses empty-line metrics so its height does not change after a new char typed on the empty/newline line

I can extract the empty-line metrics construction and trimming into a helper used by both receiveLineMetrics() and the cursor.
receiveLineMetrics() shouldn't return empty-line metrics for non-empty text, since paragraph.lineMetrics are also used for line geometry, while the empty-line metrics are only needed for the cursor.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1. receiveLineMetrics() is fine; the issue is emptyLineMetrics using fontSize instead of the font's intrinsic height. Fixing that removes the need for a second source (see my other comment).

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Cursor sizing discards styled-font metrics, and the claimed baseline-layout fix is absent.

2 open issues
What changed in this PR

Adjusts Skiko cursor sizing to reduce height changes when typing into empty text fields.

Changes:

  • Reuses cached empty-line metrics for cursors with explicit line heights.
  • Adds an empty-versus-nonempty cursor-height regression test.
  • Adds a StrutStyle import without changing paragraph layout.
File Description
compose/​ui/​ui-skiko/​src/​nonAndroidTest/​kotlin/​androidx/​compose/​ui/​text/​SkikoParagraphTest.kt Tests consistent cursor height.
compose/​ui/​ui-skiko/​src/​nonAndroidMain/​kotlin/​androidx/​compose/​ui/​text/​SkikoParagraph.nonAndroid.kt Changes cursor metric selection and caching.
compose/​ui/​ui-skiko/​src/​nonAndroidMain/​kotlin/​androidx/​compose/​ui/​text/​platform/​SkikoParagraphBuilder.nonAndroid.kt Adds an unused import.

Review effort: Balanced

Comment on lines +197 to +199
(text.isNotEmpty() && !layouter.textStyle.lineHeight.isUnspecified)
val cursorMetrics = if (usesEmptyLineCursorMetrics && text.isNotEmpty()) {
emptyLineCursorMetrics

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for digging into this!
However, I think the fix lands in the wrong place, though: the caret is fine, but the empty-line metrics it is compared against are wrong.

emptyLineMetrics uses fontSize where Skia uses the font's intrinsic height (see the inline comments).

Could you please try aligning emptyLineMetrics with Skia's computeEmptyMetrics and reverting the getCursorRect changes?
That should fix the empty-field caret and keep the non-empty ones matching the line height.

Comment on lines +345 to +351
private val emptyLineCursorMetrics: LineMetrics
get() = _emptyLineCursorMetrics ?: layouter
.emptyLineMetrics(paragraph)
.first()
.trimFirstAscent(defaultFont.metrics, layouter.textStyle)
.trimLastDescent(defaultFont.metrics, layouter.textStyle)
.also { _emptyLineCursorMetrics = it }

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1. receiveLineMetrics() is fine; the issue is emptyLineMetrics using fontSize instead of the font's intrinsic height. Fixing that removes the need for a second source (see my other comment).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm now, thank you!

@mazunin-v-jb
Vladimir Mazunin (mazunin-v-jb) merged commit 384387b into JetBrains:jb-main Nov 11, 2026
26 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants