diff --git a/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/cursor/CursorExt.kt b/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/cursor/CursorExt.kt index 701a631..9cf3ade 100644 --- a/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/cursor/CursorExt.kt +++ b/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/cursor/CursorExt.kt @@ -20,7 +20,7 @@ fun TextEditorState.calculateCursorPosition(): CursorMetrics { val safeCharIndex = charIndex.coerceIn(0, textLength) val cursorX = layout.getHorizontalPosition(safeCharIndex, usePrimaryDirection = true) + - emptyLineBlockIndent() + emptyLineIndent() val cursorY = currentWrappedLine.offset.y - scrollState.value val lineHeight = layout.multiParagraph.getLineHeight(virtualLineIndex) @@ -43,16 +43,22 @@ fun TextEditorState.calculateCursorPosition(): CursorMetrics { * Compose's `getHorizontalPosition` returns 0 on an empty line because the * paragraph style's [androidx.compose.ui.text.style.TextIndent] doesn't apply * to a degenerate `[0, 0)` paragraph range. Add the indent manually so the - * cursor sits at the indented position on an empty bullet/quote line — without + * cursor sits at the indented position on an empty indented line — without * this it jumps from `x=0` to `x=indent` the moment the user types the first - * character. Returns 0 if the line isn't an empty line-block. + * character. Covers both line-block indents (bullet/quote/list/code) and the + * editor-wide [androidx.compose.ui.text.TextStyle.textIndent]. Returns 0 if + * the line isn't empty or no first-line indent is in effect. */ -private fun TextEditorState.emptyLineBlockIndent(): Float { +private fun TextEditorState.emptyLineIndent(): Float { if (cursorPosition.char != 0) return 0f val line = textLines.getOrNull(cursorPosition.line) ?: return 0f if (line.text.isNotEmpty()) return 0f - val block = detectLineBlock(cursorPosition.line) ?: return 0f - val indent = block.paragraphStyle.textIndent?.firstLine ?: return 0f + // Block lines carry their own paragraph-style indent; plain lines inherit + // the editor-wide textStyle indent (baked in by updateBookKeeping). + val block = detectLineBlock(cursorPosition.line) + val indent = block?.paragraphStyle?.textIndent?.firstLine + ?: textStyle.textIndent?.firstLine + ?: return 0f val d = density ?: return 0f return with(d) { indent.toPx() } } diff --git a/ComposeTextEditor/src/desktopTest/kotlin/texteditor/CursorIndentTest.kt b/ComposeTextEditor/src/desktopTest/kotlin/texteditor/CursorIndentTest.kt new file mode 100644 index 0000000..6dec087 --- /dev/null +++ b/ComposeTextEditor/src/desktopTest/kotlin/texteditor/CursorIndentTest.kt @@ -0,0 +1,87 @@ +package texteditor + +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.text.TextMeasurer +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.createFontFamilyResolver +import androidx.compose.ui.text.style.TextIndent +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.sp +import com.darkrockstudios.texteditor.cursor.calculateCursorPosition +import com.darkrockstudios.texteditor.richstyle.BulletList +import com.darkrockstudios.texteditor.richstyle.applyLineBlock +import com.darkrockstudios.texteditor.state.TextEditorState +import kotlinx.coroutines.test.TestScope +import org.junit.Test +import kotlin.test.assertEquals + +/** + * Verifies that the cursor on an empty line sits at the effective first-line + * indent position rather than at `x=0`. Without this, the cursor jumps from 0 + * to the indent the moment the user types the first character — visible + * regression as "the cursor snaps right when I start typing". + */ +class CursorIndentTest { + private val testScope = TestScope() + private val density = Density(1f, 1f) + + private fun realTextMeasurer(): TextMeasurer = TextMeasurer( + defaultFontFamilyResolver = createFontFamilyResolver(), + defaultDensity = density, + defaultLayoutDirection = LayoutDirection.Ltr, + ) + + private fun freshState(): TextEditorState = TextEditorState( + scope = testScope, + measurer = realTextMeasurer(), + ).also { + it.density = density + it.onViewportSizeChange(Size(500f, 200f)) + } + + @Test + fun `cursor on empty line with editor-wide firstLine indent sits at indent`() { + val state = freshState() + state.textStyle = TextStyle(textIndent = TextIndent(firstLine = 40.sp)) + + val metrics = state.calculateCursorPosition() + + // With Density(1f, 1f), 40.sp == 40px. + assertEquals(40f, metrics.position.x, 0.5f) + } + + @Test + fun `cursor on empty line without firstLine indent sits at x=0`() { + val state = freshState() + // No textIndent applied. + + val metrics = state.calculateCursorPosition() + + assertEquals(0f, metrics.position.x, 0.5f) + } + + @Test + fun `cursor on empty bullet line sits at bullet indent`() { + val state = freshState() + state.applyLineBlock(0, BulletList) + + val metrics = state.calculateCursorPosition() + + // BULLET_LIST_PARAGRAPH_STYLE.textIndent.firstLine == 16.sp. + assertEquals(16f, metrics.position.x, 0.5f) + } + + @Test + fun `block-line indent takes precedence over editor-wide indent`() { + val state = freshState() + state.textStyle = TextStyle(textIndent = TextIndent(firstLine = 40.sp)) + state.applyLineBlock(0, BulletList) + + val metrics = state.calculateCursorPosition() + + // Block lines carry their own paragraph style; the editor-wide indent + // is not layered on top. + assertEquals(16f, metrics.position.x, 0.5f) + } +}