Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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() }
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading