From 57d89c9cc417cd674a1369b4a0d8195793295885 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Fri, 12 Jun 2026 00:35:48 -0700 Subject: [PATCH 1/2] Make line-block toggles undoable as one atomic step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Toggling a numbered/bulleted list, blockquote, or code fence changed both the line's paragraph indent (via updateLine) and a line-anchored rich span (via richSpanManager). Neither went through editManager.recordEdit, so the toggle was invisible to undo/redo and Ctrl+Z reverted the previous edit instead. Introduce a dedicated TextEditOperation.LineBlock variant that snapshots each affected line's content and block-span set both before and after the toggle. Apply/redo restore the after state; undo restores the before state — including any mutually-excluded block demoted as a side effect — in a single history entry. The toggle now routes through editManager.toggleLineBlock; the inner span mutations use the direct richSpanManager path to avoid double-recording, and importMarkdown stays on the non-recording path so document loading does not pollute undo history. Extend every exhaustive when over TextEditOperation: RichSpanManager (passthrough, toggles don't move text) and SpellCheckState (null, no text change). SpellCheckingTextEditor already has an else branch. Fixes #20 Co-Authored-By: Claude Opus 4.8 --- .../texteditor/markdown/MarkdownExtension.kt | 9 +- .../texteditor/richstyle/LineBlockStyle.kt | 107 +++++++++----- .../texteditor/state/RichSpanManager.kt | 1 + .../texteditor/state/TextEditManager.kt | 66 +++++++++ .../texteditor/state/TextEditOperation.kt | 34 ++++- .../redo/LineBlockRedoTests.kt | 132 ++++++++++++++++++ .../undo/LineBlockUndoTests.kt | 128 +++++++++++++++++ .../texteditor/spellcheck/SpellCheckState.kt | 1 + 8 files changed, 435 insertions(+), 43 deletions(-) create mode 100644 ComposeTextEditor/src/desktopTest/kotlin/texteditmanager/redo/LineBlockRedoTests.kt create mode 100644 ComposeTextEditor/src/desktopTest/kotlin/texteditmanager/undo/LineBlockUndoTests.kt diff --git a/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/markdown/MarkdownExtension.kt b/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/markdown/MarkdownExtension.kt index 6e8ad89..eb3e029 100644 --- a/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/markdown/MarkdownExtension.kt +++ b/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/markdown/MarkdownExtension.kt @@ -306,14 +306,7 @@ class MarkdownExtension( fun toggleCodeFence(lines: IntRange) = toggleLineBlock(lines, CodeFence) private fun toggleLineBlock(lines: IntRange, block: LineBlockStyle) { - val anyOff = lines.any { !editorState.hasLineBlock(it, block) } - for (lineIdx in lines) { - if (anyOff) { - if (!editorState.hasLineBlock(lineIdx, block)) editorState.applyLineBlock(lineIdx, block) - } else { - editorState.demoteLineBlock(lineIdx, block) - } - } + editorState.editManager.toggleLineBlock(lines, block) } override fun equals(other: Any?): Boolean { diff --git a/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/richstyle/LineBlockStyle.kt b/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/richstyle/LineBlockStyle.kt index 1bd6b0e..fd4dc58 100644 --- a/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/richstyle/LineBlockStyle.kt +++ b/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/richstyle/LineBlockStyle.kt @@ -1,5 +1,6 @@ package com.darkrockstudios.texteditor.richstyle +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.ParagraphStyle import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString @@ -125,6 +126,36 @@ internal fun TextEditorState.hasLineBlock(line: Int, block: LineBlockStyle): Boo span.style === block.spanStyle && span.range.start.line == line } +/** Wraps [existing] in [block]'s indent paragraph style (and optional text style). */ +internal fun rebuildWithBlock(existing: AnnotatedString, block: LineBlockStyle): AnnotatedString = + buildAnnotatedString { + withStyle(block.paragraphStyle) { + if (block.textStyle != null) { + withStyle(block.textStyle) { + append(existing) + } + } else { + append(existing) + } + } + } + +/** Strips [block]'s indent paragraph style (and optional text style) from [existing]. */ +internal fun rebuildWithoutBlock(existing: AnnotatedString, block: LineBlockStyle): AnnotatedString = + buildAnnotatedString { + append(existing.text) + existing.spanStyles.forEach { range -> + if (block.textStyle == null || range.item != block.textStyle) { + addStyle(range.item, range.start, range.end) + } + } + existing.paragraphStyles.forEach { range -> + if (range.item != block.paragraphStyle) { + addStyle(range.item, range.start, range.end) + } + } + } + /** * Idempotent — no-op if [line] already carries [block]. Otherwise demotes any * mutually-exclusive block on the same line, wraps the line's text in the @@ -145,28 +176,30 @@ internal fun TextEditorState.applyLineBlock(line: Int, block: LineBlockStyle) { .filter { hasLineBlock(line, it) } .forEach { demoteLineBlock(line, it) } val existing = textLines.getOrNull(line) ?: return - val rebuilt = buildAnnotatedString { - withStyle(block.paragraphStyle) { - if (block.textStyle != null) { - withStyle(block.textStyle) { - append(existing) - } - } else { - append(existing) - } - } - } - updateLine(line, rebuilt) - // Direct manager call: the line-block change is driven by `updateLine` (which - // isn't history-tracked), so recording only the span would make undo leave a - // half-applied block. Block-level undo is out of scope here. + updateLine(line, rebuildWithBlock(existing, block)) + addLineBlockSpan(line, existing.length, block) +} + +/** + * Attaches the line-anchored span for [block] to [line] via the direct, non- + * recording manager path. Callers that want the toggle in undo history record a + * [TextEditOperation.LineBlock] separately — recording here too would double-count. + */ +internal fun TextEditorState.addLineBlockSpan(line: Int, length: Int, block: LineBlockStyle) { richSpanManager.addRichSpan( start = CharLineOffset(line, 0), - end = CharLineOffset(line, existing.length), + end = CharLineOffset(line, length), style = block.spanStyle, ) } +/** Drops every span anchored to [line] for [block] via the direct manager path. */ +internal fun TextEditorState.removeLineBlockSpans(line: Int, block: LineBlockStyle) { + richSpanManager.getAllRichSpans() + .filter { it.style === block.spanStyle && it.range.start.line == line } + .forEach { richSpanManager.removeRichSpan(it) } +} + /** * Drops every span anchored to [line] for [block] and rebuilds the line without * its indent paragraph style (and without the baked-in text style, if any). @@ -174,26 +207,32 @@ internal fun TextEditorState.applyLineBlock(line: Int, block: LineBlockStyle) { */ internal fun TextEditorState.demoteLineBlock(line: Int, block: LineBlockStyle) { val existing = textLines.getOrNull(line) ?: return - val spans = richSpanManager.getAllRichSpans() - .filter { it.style === block.spanStyle && it.range.start.line == line } - if (spans.isEmpty()) return - spans.forEach { richSpanManager.removeRichSpan(it) } - val rebuilt = buildAnnotatedString { - append(existing.text) - existing.spanStyles.forEach { range -> - if (block.textStyle == null || range.item != block.textStyle) { - addStyle(range.item, range.start, range.end) - } - } - existing.paragraphStyles.forEach { range -> - if (range.item != block.paragraphStyle) { - addStyle(range.item, range.start, range.end) - } - } - } - updateLine(line, rebuilt) + if (!hasLineBlock(line, block)) return + removeLineBlockSpans(line, block) + updateLine(line, rebuildWithoutBlock(existing, block)) } /** Returns the [LineBlockStyle] currently attached to [line], or null if none. */ internal fun TextEditorState.detectLineBlock(line: Int): LineBlockStyle? = ALL_BLOCK_STYLES.firstOrNull { hasLineBlock(line, it) } + +/** The line-anchored block span styles currently attached to [line]. */ +internal fun TextEditorState.lineBlockSpanStyles(line: Int): List = + ALL_BLOCK_STYLES.filter { hasLineBlock(line, it) }.map { it.spanStyle } + +/** + * Replaces every line-anchored block span on [line] so that exactly [spanStyles] + * are attached, spanning the full line content. Used to restore the precise span + * set captured for an atomic line-block undo/redo. + */ +internal fun TextEditorState.setLineBlockSpans(line: Int, spanStyles: List) { + ALL_BLOCK_STYLES.forEach { removeLineBlockSpans(line, it) } + val length = textLines.getOrNull(line)?.length ?: return + spanStyles.forEach { style -> + richSpanManager.addRichSpan( + start = CharLineOffset(line, 0), + end = CharLineOffset(line, length), + style = style, + ) + } +} diff --git a/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/state/RichSpanManager.kt b/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/state/RichSpanManager.kt index 740bc6c..5a2204b 100644 --- a/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/state/RichSpanManager.kt +++ b/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/state/RichSpanManager.kt @@ -60,6 +60,7 @@ class RichSpanManager( is TextEditOperation.Replace -> handleReplace(operation, updatedSpans, span) is TextEditOperation.StyleSpan -> handleSpanOnly(updatedSpans, span) is TextEditOperation.RichSpan -> handleSpanOnly(updatedSpans, span) + is TextEditOperation.LineBlock -> handleSpanOnly(updatedSpans, span) } } } diff --git a/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/state/TextEditManager.kt b/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/state/TextEditManager.kt index 2fb31d1..3c49f19 100644 --- a/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/state/TextEditManager.kt +++ b/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/state/TextEditManager.kt @@ -6,7 +6,13 @@ import androidx.compose.ui.text.buildAnnotatedString import com.darkrockstudios.texteditor.CharLineOffset import com.darkrockstudios.texteditor.TextEditorRange import com.darkrockstudios.texteditor.annotatedstring.splitAnnotatedString +import com.darkrockstudios.texteditor.richstyle.LineBlockStyle import com.darkrockstudios.texteditor.richstyle.RichSpanStyle +import com.darkrockstudios.texteditor.richstyle.applyLineBlock +import com.darkrockstudios.texteditor.richstyle.demoteLineBlock +import com.darkrockstudios.texteditor.richstyle.hasLineBlock +import com.darkrockstudios.texteditor.richstyle.lineBlockSpanStyles +import com.darkrockstudios.texteditor.richstyle.setLineBlockSpans import com.darkrockstudios.texteditor.utils.appendAnnotatedStrings import com.darkrockstudios.texteditor.utils.buildAnnotatedStringWithSpans import com.darkrockstudios.texteditor.utils.mergeAnnotatedStrings @@ -39,6 +45,7 @@ class TextEditManager(private val state: TextEditorState) { is TextEditOperation.Replace -> applyReplace(addToHistory, operation) is TextEditOperation.StyleSpan -> applyStyleOperation(operation) is TextEditOperation.RichSpan -> applyRichSpanOperation(operation) + is TextEditOperation.LineBlock -> applyLineBlockOperation(operation) } state.cursor.updatePosition(operation.cursorAfter) @@ -528,6 +535,57 @@ class TextEditManager(private val state: TextEditorState) { return null } + private fun applyLineBlockOperation(operation: TextEditOperation.LineBlock): OperationMetadata? { + applyLineBlockState(operation.lines, undo = false) + return null + } + + // Restores each affected line's content and its exact block-span set for one + // direction of the toggle. The spans go through the direct manager path so the + // single LineBlock history entry isn't double-counted. + private fun applyLineBlockState(lines: List, undo: Boolean) { + lines.forEach { change -> + val content = if (undo) change.contentBefore else change.contentAfter + val spans = if (undo) change.blockSpansBefore else change.blockSpansAfter + state.updateLine(change.lineIndex, content) + state.setLineBlockSpans(change.lineIndex, spans) + } + } + + /** + * Captures each line's before/after content + block spans, applies the toggle + * via the direct (non-recording) path, then records ONE atomic LineBlock entry. + */ + internal fun toggleLineBlock(lines: IntRange, block: LineBlockStyle) { + val anyOff = lines.any { !state.hasLineBlock(it, block) } + val cursorBefore = state.cursorPosition + + val changes = lines.map { lineIdx -> + val contentBefore = state.getLine(lineIdx) + val spansBefore = state.lineBlockSpanStyles(lineIdx) + if (anyOff) { + if (!state.hasLineBlock(lineIdx, block)) state.applyLineBlock(lineIdx, block) + } else { + state.demoteLineBlock(lineIdx, block) + } + LineBlockChange( + lineIndex = lineIdx, + contentBefore = contentBefore, + contentAfter = state.getLine(lineIdx), + blockSpansBefore = spansBefore, + blockSpansAfter = state.lineBlockSpanStyles(lineIdx), + ) + } + + applyOperation( + TextEditOperation.LineBlock( + lines = changes, + cursorBefore = cursorBefore, + cursorAfter = cursorBefore, + ) + ) + } + fun undo() { history.undo()?.let { entry -> when (entry.operation) { @@ -536,10 +594,18 @@ class TextEditManager(private val state: TextEditorState) { is TextEditOperation.Replace -> undoReplace(entry.operation, entry) is TextEditOperation.StyleSpan -> undoStyleSpan(entry.operation) is TextEditOperation.RichSpan -> undoRichSpan(entry.operation) + is TextEditOperation.LineBlock -> undoLineBlock(entry.operation) } } } + private fun undoLineBlock(operation: TextEditOperation.LineBlock) { + applyLineBlockState(operation.lines, undo = true) + state.cursor.updatePosition(operation.cursorBefore) + state.invalidateCopiedRichSpans() + state.updateBookKeeping() + } + private fun undoReplace( operation: TextEditOperation.Replace, entry: HistoryEntry diff --git a/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/state/TextEditOperation.kt b/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/state/TextEditOperation.kt index 7715c22..a09c3e6 100644 --- a/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/state/TextEditOperation.kt +++ b/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/state/TextEditOperation.kt @@ -163,4 +163,36 @@ sealed class TextEditOperation { state: TextEditorState ): CharLineOffset = offset // Rich span changes don't affect positions } -} \ No newline at end of file + + /** + * An atomic line-block toggle (list / blockquote / code fence). Each entry in + * [lines] snapshots an affected line's content and line-anchored block spans + * both BEFORE and AFTER the toggle, so undo/redo restore paragraph style, text + * style, and every block span (including any mutually-excluded block demoted as + * a side effect) in one step. + */ + data class LineBlock( + val lines: List, + override val cursorBefore: CharLineOffset, + override val cursorAfter: CharLineOffset + ) : TextEditOperation() { + override fun transformOffset( + offset: CharLineOffset, + state: TextEditorState + ): CharLineOffset = offset // Toggles change paragraph style only, not text length + } +} + +/** + * One line's before/after snapshot for an atomic [TextEditOperation.LineBlock]. + * [blockSpansBefore]/[blockSpansAfter] list the line-anchored block span styles + * attached to the line in each state — restoring them rebuilds the gutter markers + * exactly. The paragraph/text indent itself lives in the captured content. + */ +data class LineBlockChange( + val lineIndex: Int, + val contentBefore: AnnotatedString, + val contentAfter: AnnotatedString, + val blockSpansBefore: List, + val blockSpansAfter: List, +) \ No newline at end of file diff --git a/ComposeTextEditor/src/desktopTest/kotlin/texteditmanager/redo/LineBlockRedoTests.kt b/ComposeTextEditor/src/desktopTest/kotlin/texteditmanager/redo/LineBlockRedoTests.kt new file mode 100644 index 0000000..783ac73 --- /dev/null +++ b/ComposeTextEditor/src/desktopTest/kotlin/texteditmanager/redo/LineBlockRedoTests.kt @@ -0,0 +1,132 @@ +package texteditmanager.redo + +import androidx.compose.ui.text.AnnotatedString +import com.darkrockstudios.texteditor.CharLineOffset +import com.darkrockstudios.texteditor.markdown.MarkdownExtension +import com.darkrockstudios.texteditor.richstyle.BlockquoteSpanStyle +import com.darkrockstudios.texteditor.richstyle.CodeFenceSpanStyle +import com.darkrockstudios.texteditor.richstyle.ORDERED_LIST_PARAGRAPH_STYLE +import com.darkrockstudios.texteditor.richstyle.OrderedListSpanStyle +import com.darkrockstudios.texteditor.state.TextEditOperation +import com.darkrockstudios.texteditor.state.TextEditorState +import io.mockk.mockk +import kotlinx.coroutines.test.TestScope +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class LineBlockRedoTests { + private lateinit var scope: TestScope + private lateinit var state: TextEditorState + private lateinit var markdown: MarkdownExtension + + @BeforeTest + fun setup() { + scope = TestScope() + state = TextEditorState( + scope = scope.backgroundScope, + measurer = mockk(relaxed = true) + ) + markdown = MarkdownExtension(state) + } + + private fun spanLines(style: Any) = state.richSpanManager.getAllRichSpans() + .filter { it.style === style } + .map { it.range.start.line } + .sorted() + + private fun hasParagraphStyle(line: Int, paragraphStyle: Any) = + state.textLines[line].paragraphStyles.any { it.item == paragraphStyle } + + @Test + fun `redo ordered list re-applies span and indent`() { + state.setText("Hello World") + markdown.toggleOrderedList(0..0) + state.undo() + assertTrue(spanLines(OrderedListSpanStyle).isEmpty()) + + state.redo() + assertEquals(listOf(0), spanLines(OrderedListSpanStyle)) + assertTrue(hasParagraphStyle(0, ORDERED_LIST_PARAGRAPH_STYLE)) + } + + @Test + fun `type char then toggle list then undo redo ends in correct state`() { + state.setText("Hello World") + + state.editManager.applyOperation( + TextEditOperation.Insert( + position = CharLineOffset(0, 11), + text = AnnotatedString("!"), + cursorBefore = CharLineOffset(0, 11), + cursorAfter = CharLineOffset(0, 12) + ) + ) + + markdown.toggleOrderedList(0..0) + + state.undo() + assertEquals("Hello World!", state.textLines[0].text) + assertTrue(spanLines(OrderedListSpanStyle).isEmpty()) + + state.redo() + assertEquals("Hello World!", state.textLines[0].text) + assertEquals(listOf(0), spanLines(OrderedListSpanStyle)) + assertTrue(hasParagraphStyle(0, ORDERED_LIST_PARAGRAPH_STYLE)) + } + + @Test + fun `redo blockquote re-applies it`() { + state.setText("quote me") + markdown.toggleBlockquote(0..0) + state.undo() + assertTrue(spanLines(BlockquoteSpanStyle).isEmpty()) + + state.redo() + assertEquals(listOf(0), spanLines(BlockquoteSpanStyle)) + } + + @Test + fun `redo code fence re-applies it`() { + state.setText("code line") + markdown.toggleCodeFence(0..0) + state.undo() + assertTrue(spanLines(CodeFenceSpanStyle).isEmpty()) + + state.redo() + assertEquals(listOf(0), spanLines(CodeFenceSpanStyle)) + } + + @Test + fun `redo multi-line list toggle re-applies every line`() { + state.setText("one\ntwo\nthree") + markdown.toggleOrderedList(0..2) + state.undo() + assertTrue(spanLines(OrderedListSpanStyle).isEmpty()) + + state.redo() + assertEquals(listOf(0, 1, 2), spanLines(OrderedListSpanStyle)) + (0..2).forEach { line -> + assertTrue(hasParagraphStyle(line, ORDERED_LIST_PARAGRAPH_STYLE)) + } + } + + @Test + fun `undo demote restores the list`() { + state.setText("item") + markdown.toggleOrderedList(0..0) + assertEquals(listOf(0), spanLines(OrderedListSpanStyle)) + + // Toggle off (demote) — this is its own atomic, undoable entry. + markdown.toggleOrderedList(0..0) + assertTrue(spanLines(OrderedListSpanStyle).isEmpty()) + + state.undo() + assertEquals(listOf(0), spanLines(OrderedListSpanStyle)) + assertTrue(hasParagraphStyle(0, ORDERED_LIST_PARAGRAPH_STYLE)) + + state.redo() + assertTrue(spanLines(OrderedListSpanStyle).isEmpty()) + } +} diff --git a/ComposeTextEditor/src/desktopTest/kotlin/texteditmanager/undo/LineBlockUndoTests.kt b/ComposeTextEditor/src/desktopTest/kotlin/texteditmanager/undo/LineBlockUndoTests.kt new file mode 100644 index 0000000..b2e31ba --- /dev/null +++ b/ComposeTextEditor/src/desktopTest/kotlin/texteditmanager/undo/LineBlockUndoTests.kt @@ -0,0 +1,128 @@ +package texteditmanager.undo + +import androidx.compose.ui.text.AnnotatedString +import com.darkrockstudios.texteditor.CharLineOffset +import com.darkrockstudios.texteditor.markdown.MarkdownExtension +import com.darkrockstudios.texteditor.richstyle.BLOCKQUOTE_PARAGRAPH_STYLE +import com.darkrockstudios.texteditor.richstyle.BlockquoteSpanStyle +import com.darkrockstudios.texteditor.richstyle.CODE_FENCE_PARAGRAPH_STYLE +import com.darkrockstudios.texteditor.richstyle.CodeFenceSpanStyle +import com.darkrockstudios.texteditor.richstyle.ORDERED_LIST_PARAGRAPH_STYLE +import com.darkrockstudios.texteditor.richstyle.OrderedListSpanStyle +import com.darkrockstudios.texteditor.state.TextEditOperation +import com.darkrockstudios.texteditor.state.TextEditorState +import io.mockk.mockk +import kotlinx.coroutines.test.TestScope +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class LineBlockUndoTests { + private lateinit var scope: TestScope + private lateinit var state: TextEditorState + private lateinit var markdown: MarkdownExtension + + @BeforeTest + fun setup() { + scope = TestScope() + state = TextEditorState( + scope = scope.backgroundScope, + measurer = mockk(relaxed = true) + ) + markdown = MarkdownExtension(state) + } + + private fun spanLines(style: Any) = state.richSpanManager.getAllRichSpans() + .filter { it.style === style } + .map { it.range.start.line } + .sorted() + + private fun hasParagraphStyle(line: Int, paragraphStyle: Any) = + state.textLines[line].paragraphStyles.any { it.item == paragraphStyle } + + @Test + fun `undo ordered list removes span and paragraph indent`() { + state.setText("Hello World") + val prior = state.textLines[0] + + markdown.toggleOrderedList(0..0) + assertEquals(listOf(0), spanLines(OrderedListSpanStyle)) + assertTrue(hasParagraphStyle(0, ORDERED_LIST_PARAGRAPH_STYLE)) + + state.undo() + assertTrue(spanLines(OrderedListSpanStyle).isEmpty()) + assertFalse(hasParagraphStyle(0, ORDERED_LIST_PARAGRAPH_STYLE)) + assertEquals(prior.text, state.textLines[0].text) + assertEquals(prior.paragraphStyles, state.textLines[0].paragraphStyles) + } + + @Test + fun `type char then toggle list then undo keeps char and removes list`() { + state.setText("Hello World") + + state.editManager.applyOperation( + TextEditOperation.Insert( + position = CharLineOffset(0, 11), + text = AnnotatedString("!"), + cursorBefore = CharLineOffset(0, 11), + cursorAfter = CharLineOffset(0, 12) + ) + ) + assertEquals("Hello World!", state.textLines[0].text) + + markdown.toggleOrderedList(0..0) + assertEquals(listOf(0), spanLines(OrderedListSpanStyle)) + + // Undo only the list — the typed char stays, and the list is fully gone. + state.undo() + assertEquals("Hello World!", state.textLines[0].text) + assertTrue(spanLines(OrderedListSpanStyle).isEmpty()) + assertFalse(hasParagraphStyle(0, ORDERED_LIST_PARAGRAPH_STYLE)) + + // A second undo reverts the prior char edit. + state.undo() + assertEquals("Hello World", state.textLines[0].text) + } + + @Test + fun `undo blockquote removes span and indent`() { + state.setText("quote me") + markdown.toggleBlockquote(0..0) + assertEquals(listOf(0), spanLines(BlockquoteSpanStyle)) + assertTrue(hasParagraphStyle(0, BLOCKQUOTE_PARAGRAPH_STYLE)) + + state.undo() + assertTrue(spanLines(BlockquoteSpanStyle).isEmpty()) + assertFalse(hasParagraphStyle(0, BLOCKQUOTE_PARAGRAPH_STYLE)) + assertEquals("quote me", state.textLines[0].text) + } + + @Test + fun `undo code fence removes span and indent`() { + state.setText("code line") + markdown.toggleCodeFence(0..0) + assertEquals(listOf(0), spanLines(CodeFenceSpanStyle)) + assertTrue(hasParagraphStyle(0, CODE_FENCE_PARAGRAPH_STYLE)) + + state.undo() + assertTrue(spanLines(CodeFenceSpanStyle).isEmpty()) + assertFalse(hasParagraphStyle(0, CODE_FENCE_PARAGRAPH_STYLE)) + assertEquals("code line", state.textLines[0].text) + } + + @Test + fun `undo multi-line list toggle restores every line`() { + state.setText("one\ntwo\nthree") + markdown.toggleOrderedList(0..2) + assertEquals(listOf(0, 1, 2), spanLines(OrderedListSpanStyle)) + + state.undo() + assertTrue(spanLines(OrderedListSpanStyle).isEmpty()) + (0..2).forEach { line -> + assertFalse(hasParagraphStyle(line, ORDERED_LIST_PARAGRAPH_STYLE)) + } + assertEquals("one\ntwo\nthree", state.getAllText().text) + } +} diff --git a/ComposeTextEditorSpellCheck/src/commonMain/kotlin/com/darkrockstudios/texteditor/spellcheck/SpellCheckState.kt b/ComposeTextEditorSpellCheck/src/commonMain/kotlin/com/darkrockstudios/texteditor/spellcheck/SpellCheckState.kt index 1d787e0..cfde2ac 100644 --- a/ComposeTextEditorSpellCheck/src/commonMain/kotlin/com/darkrockstudios/texteditor/spellcheck/SpellCheckState.kt +++ b/ComposeTextEditorSpellCheck/src/commonMain/kotlin/com/darkrockstudios/texteditor/spellcheck/SpellCheckState.kt @@ -323,6 +323,7 @@ class SpellCheckState( is TextEditOperation.Replace -> operation.range is TextEditOperation.StyleSpan -> null is TextEditOperation.RichSpan -> null + is TextEditOperation.LineBlock -> null } range?.let { From 24e538f70f5a5eaed1920548ab85648e163c9070 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Fri, 12 Jun 2026 00:49:05 -0700 Subject: [PATCH 2/2] Preserve selection on line-block toggle; test span safety A LineBlock toggle changes no text positions, so applyOperation must keep the active selection like the other span operations do. Add LineBlock to the selection-preserving set so the select-lines -> click-list flow no longer clears the selection. Add tests covering the highest-risk cases, all via the real markdown.toggle* entry points: a character bold span and a standalone highlight rich span survive toggle/undo/redo unchanged, and undoing an ordered-list toggle that demoted an existing bullet restores the bullet. Co-Authored-By: Claude Opus 4.8 --- .../texteditor/state/TextEditManager.kt | 3 +- .../redo/LineBlockRedoTests.kt | 59 ++++++++++++ .../undo/LineBlockUndoTests.kt | 92 +++++++++++++++++++ 3 files changed, 153 insertions(+), 1 deletion(-) diff --git a/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/state/TextEditManager.kt b/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/state/TextEditManager.kt index 3c49f19..f6278d9 100644 --- a/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/state/TextEditManager.kt +++ b/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/state/TextEditManager.kt @@ -34,7 +34,8 @@ class TextEditManager(private val state: TextEditorState) { // Selection offsets must not outlive a content mutation. Span operations // leave the text untouched, so they keep the selection. val isSpanOperation = operation is TextEditOperation.StyleSpan || - operation is TextEditOperation.RichSpan + operation is TextEditOperation.RichSpan || + operation is TextEditOperation.LineBlock if (!isSpanOperation && state.selector.selection != null) { state.selector.clearSelection() } diff --git a/ComposeTextEditor/src/desktopTest/kotlin/texteditmanager/redo/LineBlockRedoTests.kt b/ComposeTextEditor/src/desktopTest/kotlin/texteditmanager/redo/LineBlockRedoTests.kt index 783ac73..1efe770 100644 --- a/ComposeTextEditor/src/desktopTest/kotlin/texteditmanager/redo/LineBlockRedoTests.kt +++ b/ComposeTextEditor/src/desktopTest/kotlin/texteditmanager/redo/LineBlockRedoTests.kt @@ -1,10 +1,16 @@ package texteditmanager.redo +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight import com.darkrockstudios.texteditor.CharLineOffset +import com.darkrockstudios.texteditor.TextEditorRange import com.darkrockstudios.texteditor.markdown.MarkdownExtension import com.darkrockstudios.texteditor.richstyle.BlockquoteSpanStyle import com.darkrockstudios.texteditor.richstyle.CodeFenceSpanStyle +import com.darkrockstudios.texteditor.richstyle.HighlightSpanStyle import com.darkrockstudios.texteditor.richstyle.ORDERED_LIST_PARAGRAPH_STYLE import com.darkrockstudios.texteditor.richstyle.OrderedListSpanStyle import com.darkrockstudios.texteditor.state.TextEditOperation @@ -112,6 +118,59 @@ class LineBlockRedoTests { } } + @Test + fun `toggle list undo redo keeps a character bold span intact`() { + val bold = SpanStyle(fontWeight = FontWeight.Bold) + state.setText(buildAnnotatedString { + append("Hello ") + pushStyle(bold) + append("World") + pop() + }) + fun boldRange() = state.textLines[0].spanStyles + .singleOrNull { it.item == bold } + ?.let { it.start to it.end } + + markdown.toggleOrderedList(0..0) + assertEquals(6 to 11, boldRange()) + assertEquals("Hello World", state.textLines[0].text) + + state.undo() + assertEquals(6 to 11, boldRange()) + assertEquals("Hello World", state.textLines[0].text) + + state.redo() + assertEquals(listOf(0), spanLines(OrderedListSpanStyle)) + assertEquals(6 to 11, boldRange()) + assertEquals("Hello World", state.textLines[0].text) + } + + @Test + fun `toggle list undo redo keeps a standalone highlight span intact`() { + state.setText("Hello World") + val highlight = HighlightSpanStyle(Color.Yellow) + val range = TextEditorRange( + start = CharLineOffset(0, 0), + end = CharLineOffset(0, 5) + ) + state.addRichSpan(range, highlight) + fun highlightSpans() = state.richSpanManager.getAllRichSpans() + .filter { it.style === highlight } + + markdown.toggleOrderedList(0..0) + assertEquals(1, highlightSpans().size) + assertEquals(range, highlightSpans().single().range) + + state.undo() + assertEquals(1, highlightSpans().size) + assertEquals(range, highlightSpans().single().range) + + state.redo() + assertEquals(listOf(0), spanLines(OrderedListSpanStyle)) + assertEquals(1, highlightSpans().size) + assertEquals(range, highlightSpans().single().range) + } + @Test fun `undo demote restores the list`() { state.setText("item") diff --git a/ComposeTextEditor/src/desktopTest/kotlin/texteditmanager/undo/LineBlockUndoTests.kt b/ComposeTextEditor/src/desktopTest/kotlin/texteditmanager/undo/LineBlockUndoTests.kt index b2e31ba..86f5914 100644 --- a/ComposeTextEditor/src/desktopTest/kotlin/texteditmanager/undo/LineBlockUndoTests.kt +++ b/ComposeTextEditor/src/desktopTest/kotlin/texteditmanager/undo/LineBlockUndoTests.kt @@ -1,12 +1,20 @@ package texteditmanager.undo +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight import com.darkrockstudios.texteditor.CharLineOffset +import com.darkrockstudios.texteditor.TextEditorRange import com.darkrockstudios.texteditor.markdown.MarkdownExtension import com.darkrockstudios.texteditor.richstyle.BLOCKQUOTE_PARAGRAPH_STYLE +import com.darkrockstudios.texteditor.richstyle.BULLET_LIST_PARAGRAPH_STYLE import com.darkrockstudios.texteditor.richstyle.BlockquoteSpanStyle +import com.darkrockstudios.texteditor.richstyle.BulletListSpanStyle import com.darkrockstudios.texteditor.richstyle.CODE_FENCE_PARAGRAPH_STYLE import com.darkrockstudios.texteditor.richstyle.CodeFenceSpanStyle +import com.darkrockstudios.texteditor.richstyle.HighlightSpanStyle import com.darkrockstudios.texteditor.richstyle.ORDERED_LIST_PARAGRAPH_STYLE import com.darkrockstudios.texteditor.richstyle.OrderedListSpanStyle import com.darkrockstudios.texteditor.state.TextEditOperation @@ -112,6 +120,20 @@ class LineBlockUndoTests { assertEquals("code line", state.textLines[0].text) } + @Test + fun `toggle list keeps the active multi-line selection`() { + state.setText("one\ntwo\nthree") + val selection = TextEditorRange( + start = CharLineOffset(0, 0), + end = CharLineOffset(2, 5) + ) + state.selector.updateSelection(selection.start, selection.end) + + markdown.toggleOrderedList(0..2) + + assertEquals(selection, state.selector.selection) + } + @Test fun `undo multi-line list toggle restores every line`() { state.setText("one\ntwo\nthree") @@ -125,4 +147,74 @@ class LineBlockUndoTests { } assertEquals("one\ntwo\nthree", state.getAllText().text) } + + @Test + fun `toggle list then undo preserves a character bold span`() { + val bold = SpanStyle(fontWeight = FontWeight.Bold) + state.setText(buildAnnotatedString { + append("Hello ") + pushStyle(bold) + append("World") + pop() + }) + fun boldRange() = state.textLines[0].spanStyles + .singleOrNull { it.item == bold } + ?.let { it.start to it.end } + assertEquals(6 to 11, boldRange()) + + markdown.toggleOrderedList(0..0) + assertEquals("Hello World", state.textLines[0].text) + assertEquals(6 to 11, boldRange()) + + state.undo() + assertEquals("Hello World", state.textLines[0].text) + assertEquals(6 to 11, boldRange()) + } + + @Test + fun `toggle list then undo preserves a standalone highlight span`() { + state.setText("Hello World") + val highlight = HighlightSpanStyle(Color.Yellow) + val range = TextEditorRange( + start = CharLineOffset(0, 0), + end = CharLineOffset(0, 5) + ) + state.addRichSpan(range, highlight) + fun highlightSpans() = state.richSpanManager.getAllRichSpans() + .filter { it.style === highlight } + assertEquals(1, highlightSpans().size) + assertEquals(range, highlightSpans().single().range) + + markdown.toggleOrderedList(0..0) + assertEquals(1, highlightSpans().size) + assertEquals(range, highlightSpans().single().range) + + // One undo reverts only the list toggle; the highlight is its own entry. + state.undo() + assertEquals(1, highlightSpans().size) + assertEquals(range, highlightSpans().single().range) + } + + @Test + fun `undo ordered list demoting a bullet restores the bullet`() { + state.setText("item") + markdown.toggleBulletList(0..0) + assertEquals(listOf(0), spanLines(BulletListSpanStyle)) + assertTrue(hasParagraphStyle(0, BULLET_LIST_PARAGRAPH_STYLE)) + val bulletContent = state.textLines[0] + + // Ordered list demotes the bullet as a mutually-excluded block. + markdown.toggleOrderedList(0..0) + assertEquals(listOf(0), spanLines(OrderedListSpanStyle)) + assertTrue(spanLines(BulletListSpanStyle).isEmpty()) + + // Bullet vs ordered is distinguished by the rich span — the two share an + // identical ParagraphStyle, so the indent alone can't tell them apart. + state.undo() + assertEquals(listOf(0), spanLines(BulletListSpanStyle)) + assertTrue(spanLines(OrderedListSpanStyle).isEmpty()) + assertTrue(hasParagraphStyle(0, BULLET_LIST_PARAGRAPH_STYLE)) + assertEquals(bulletContent.text, state.textLines[0].text) + assertEquals(bulletContent.paragraphStyles, state.textLines[0].paragraphStyles) + } }