From a9312a75f091a6a9b35eb7ede0a4ca2e6aebfc9f Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Thu, 11 Jun 2026 22:55:02 -0700 Subject: [PATCH 1/2] Record rich-span (list/format) operations in undo history addRichSpan/removeRichSpan mutated RichSpanManager directly and never called editManager.recordEdit, so applying or removing a list/format span was invisible to undo/redo and Ctrl+Z/Ctrl+Y operated on the previous text edit instead. Add a TextEditOperation.RichSpan variant mirroring StyleSpan, route the addRichSpan/removeRichSpan overloads through editManager, and reverse it as a self-inverse op. Document-loading and paragraph-indent block paths keep the direct manager path so they don't pollute or half-apply through history. Fixes #20 Co-Authored-By: Claude Opus 4.8 --- .../texteditor/markdown/MarkdownExtension.kt | 6 +- .../texteditor/richstyle/LineBlockStyle.kt | 7 +- .../texteditor/state/RichSpanManager.kt | 7 +- .../texteditor/state/TextEditManager.kt | 61 +++++++++++- .../texteditor/state/TextEditOperation.kt | 14 +++ .../texteditor/state/TextEditorState.kt | 13 ++- .../texteditmanager/redo/RichSpanRedoTests.kt | 96 +++++++++++++++++++ .../texteditmanager/undo/RichSpanUndoTests.kt | 96 +++++++++++++++++++ 8 files changed, 285 insertions(+), 15 deletions(-) create mode 100644 ComposeTextEditor/src/desktopTest/kotlin/texteditmanager/redo/RichSpanRedoTests.kt create mode 100644 ComposeTextEditor/src/desktopTest/kotlin/texteditmanager/undo/RichSpanUndoTests.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 474b2fe..6e8ad89 100644 --- a/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/markdown/MarkdownExtension.kt +++ b/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/markdown/MarkdownExtension.kt @@ -240,15 +240,17 @@ class MarkdownExtension( val processedMarkdown = processedLines.joinToString("\n") val annotatedString = processedMarkdown.toAnnotatedStringFromMarkdown(markdownConfiguration) editorState.setText(annotatedString) + // Direct manager calls: importing a document populates spans as part of + // loading content, not as a user edit, so it must not enter undo history. hrLineIndices.forEach { lineIdx -> - editorState.addRichSpan( + editorState.richSpanManager.addRichSpan( start = CharLineOffset(lineIdx, 0), end = CharLineOffset(lineIdx, HR_PLACEHOLDER.length), style = HorizontalRuleSpanStyle, ) } imageLines.forEach { (lineIdx, style) -> - editorState.addRichSpan( + editorState.richSpanManager.addRichSpan( start = CharLineOffset(lineIdx, 0), end = CharLineOffset(lineIdx, IMAGE_PLACEHOLDER.length), style = style, 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 2275378..1bd6b0e 100644 --- a/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/richstyle/LineBlockStyle.kt +++ b/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/richstyle/LineBlockStyle.kt @@ -157,7 +157,10 @@ internal fun TextEditorState.applyLineBlock(line: Int, block: LineBlockStyle) { } } updateLine(line, rebuilt) - addRichSpan( + // 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. + richSpanManager.addRichSpan( start = CharLineOffset(line, 0), end = CharLineOffset(line, existing.length), style = block.spanStyle, @@ -174,7 +177,7 @@ internal fun TextEditorState.demoteLineBlock(line: Int, block: LineBlockStyle) { val spans = richSpanManager.getAllRichSpans() .filter { it.style === block.spanStyle && it.range.start.line == line } if (spans.isEmpty()) return - spans.forEach { removeRichSpan(it) } + spans.forEach { richSpanManager.removeRichSpan(it) } val rebuilt = buildAnnotatedString { append(existing.text) existing.spanStyles.forEach { range -> 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 a6cc6a9..ebdc663 100644 --- a/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/state/RichSpanManager.kt +++ b/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/state/RichSpanManager.kt @@ -58,7 +58,8 @@ class RichSpanManager( span ) is TextEditOperation.Replace -> handleReplace(operation, updatedSpans, span) - is TextEditOperation.StyleSpan -> handleStyleSpan(updatedSpans, span) + is TextEditOperation.StyleSpan -> handleSpanOnly(updatedSpans, span) + is TextEditOperation.RichSpan -> handleSpanOnly(updatedSpans, span) } } } @@ -366,11 +367,11 @@ class RichSpanManager( } } - private fun handleStyleSpan( + private fun handleSpanOnly( updatedSpans: MutableSet, span: RichSpan ) { - // Noop for StyleSpan + // Span-only operations leave existing spans in place. updatedSpans.add(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 ab7b0f0..1afa125 100644 --- a/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/state/TextEditManager.kt +++ b/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/state/TextEditManager.kt @@ -6,6 +6,7 @@ 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.RichSpanStyle import com.darkrockstudios.texteditor.utils.appendAnnotatedStrings import com.darkrockstudios.texteditor.utils.buildAnnotatedStringWithSpans import com.darkrockstudios.texteditor.utils.mergeAnnotatedStrings @@ -24,8 +25,11 @@ class TextEditManager(private val state: TextEditorState) { val editOperations: SharedFlow = _editOperations fun applyOperation(operation: TextEditOperation, addToHistory: Boolean = true) { - // Selection offsets must not outlive a content mutation - if (operation !is TextEditOperation.StyleSpan && state.selector.selection != null) { + // 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 + if (!isSpanOperation && state.selector.selection != null) { state.selector.clearSelection() } @@ -34,6 +38,7 @@ class TextEditManager(private val state: TextEditorState) { is TextEditOperation.Delete -> applyDelete(addToHistory, operation) is TextEditOperation.Replace -> applyReplace(addToHistory, operation) is TextEditOperation.StyleSpan -> applyStyleOperation(operation) + is TextEditOperation.RichSpan -> applyRichSpanOperation(operation) } state.cursor.updatePosition(operation.cursorAfter) @@ -509,6 +514,19 @@ class TextEditManager(private val state: TextEditorState) { return null } + private fun applyRichSpanOperation(operation: TextEditOperation.RichSpan): OperationMetadata? { + if (operation.isAdd) { + state.richSpanManager.addRichSpan(operation.range, operation.style) + } else { + state.richSpanManager.removeRichSpan( + operation.range.start, + operation.range.end, + operation.style + ) + } + return null + } + fun undo() { history.undo()?.let { entry -> when (entry.operation) { @@ -516,6 +534,7 @@ class TextEditManager(private val state: TextEditorState) { is TextEditOperation.Delete -> undoDelete(entry, entry.operation) is TextEditOperation.Replace -> undoReplace(entry.operation, entry) is TextEditOperation.StyleSpan -> undoStyleSpan(entry.operation) + is TextEditOperation.RichSpan -> undoRichSpan(entry.operation) } } } @@ -630,6 +649,18 @@ class TextEditManager(private val state: TextEditorState) { applyOperation(inverseOperation, addToHistory = false) } + private fun undoRichSpan(operation: TextEditOperation.RichSpan) { + val inverseOperation = TextEditOperation.RichSpan( + range = operation.range, + style = operation.style, + isAdd = !operation.isAdd, + cursorBefore = operation.cursorAfter, + cursorAfter = operation.cursorBefore + ) + + applyOperation(inverseOperation, addToHistory = false) + } + fun redo() { history.redo()?.let { entry -> applyOperation(entry.operation, addToHistory = false) @@ -657,7 +688,9 @@ class TextEditManager(private val state: TextEditorState) { preserved.relativeEnd.char ) - state.addRichSpan(startPos, endPos, preserved.style) + // Bypass the history-recording path: this restoration is itself part of + // an undo/redo and must not push a new edit onto the queue. + state.richSpanManager.addRichSpan(startPos, endPos, preserved.style) } } @@ -682,4 +715,26 @@ class TextEditManager(private val state: TextEditorState) { ) applyOperation(operation) } + + fun addRichSpan(textRange: TextEditorRange, style: RichSpanStyle) { + val operation = TextEditOperation.RichSpan( + range = textRange, + style = style, + isAdd = true, + cursorBefore = state.cursorPosition, + cursorAfter = state.cursorPosition // Keep cursor in same position + ) + applyOperation(operation) + } + + fun removeRichSpan(textRange: TextEditorRange, style: RichSpanStyle) { + val operation = TextEditOperation.RichSpan( + range = textRange, + style = style, + isAdd = false, + cursorBefore = state.cursorPosition, + cursorAfter = state.cursorPosition // Keep cursor in same position + ) + applyOperation(operation) + } } \ No newline at end of file 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 e251993..7715c22 100644 --- a/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/state/TextEditOperation.kt +++ b/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/state/TextEditOperation.kt @@ -4,6 +4,7 @@ import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import com.darkrockstudios.texteditor.CharLineOffset import com.darkrockstudios.texteditor.TextEditorRange +import com.darkrockstudios.texteditor.richstyle.RichSpanStyle sealed class TextEditOperation { abstract val cursorBefore: CharLineOffset @@ -149,4 +150,17 @@ sealed class TextEditOperation { state: TextEditorState ): CharLineOffset = offset // Style changes don't affect positions } + + data class RichSpan( + val range: TextEditorRange, + val style: RichSpanStyle, + val isAdd: Boolean, // true for add, false for remove + override val cursorBefore: CharLineOffset, + override val cursorAfter: CharLineOffset + ) : TextEditOperation() { + override fun transformOffset( + offset: CharLineOffset, + state: TextEditorState + ): CharLineOffset = offset // Rich span changes don't affect positions + } } \ No newline at end of file diff --git a/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/state/TextEditorState.kt b/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/state/TextEditorState.kt index 01e678d..6aebfdf 100644 --- a/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/state/TextEditorState.kt +++ b/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/state/TextEditorState.kt @@ -785,27 +785,30 @@ class TextEditorState( } fun addRichSpan(range: TextEditorRange, style: RichSpanStyle) { - richSpanManager.addRichSpan(range, style) + editManager.addRichSpan(range, style) updateBookKeeping() } fun addRichSpan(start: CharLineOffset, end: CharLineOffset, style: RichSpanStyle) { - richSpanManager.addRichSpan(start, end, style) + editManager.addRichSpan(TextEditorRange(start, end), style) updateBookKeeping() } fun addRichSpan(start: Int, end: Int, style: RichSpanStyle) { - richSpanManager.addRichSpan(start.toCharLineOffset(), end.toCharLineOffset(), style) + editManager.addRichSpan( + TextEditorRange(start.toCharLineOffset(), end.toCharLineOffset()), + style + ) updateBookKeeping() } fun removeRichSpan(start: CharLineOffset, end: CharLineOffset, style: RichSpanStyle) { - richSpanManager.removeRichSpan(start, end, style) + editManager.removeRichSpan(TextEditorRange(start, end), style) updateBookKeeping() } fun removeRichSpan(span: RichSpan) { - richSpanManager.removeRichSpan(span) + editManager.removeRichSpan(span.range, span.style) updateBookKeeping() } diff --git a/ComposeTextEditor/src/desktopTest/kotlin/texteditmanager/redo/RichSpanRedoTests.kt b/ComposeTextEditor/src/desktopTest/kotlin/texteditmanager/redo/RichSpanRedoTests.kt new file mode 100644 index 0000000..1fae25e --- /dev/null +++ b/ComposeTextEditor/src/desktopTest/kotlin/texteditmanager/redo/RichSpanRedoTests.kt @@ -0,0 +1,96 @@ +package texteditmanager.redo + +import androidx.compose.ui.text.AnnotatedString +import com.darkrockstudios.texteditor.CharLineOffset +import com.darkrockstudios.texteditor.TextEditorRange +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 RichSpanRedoTests { + private lateinit var scope: TestScope + private lateinit var state: TextEditorState + + @BeforeTest + fun setup() { + scope = TestScope() + state = TextEditorState( + scope = scope.backgroundScope, + measurer = mockk(relaxed = true) + ) + } + + private fun hasOrderedListSpan() = state.richSpanManager.getAllRichSpans() + .any { it.style == OrderedListSpanStyle } + + @Test + fun `test redo add rich span re-applies it`() { + state.setText("Hello World") + + val range = TextEditorRange( + start = CharLineOffset(0, 0), + end = CharLineOffset(0, 11) + ) + state.addRichSpan(range, OrderedListSpanStyle) + state.undo() + assertTrue(state.richSpanManager.getAllRichSpans().isEmpty()) + + state.redo() + assertTrue(hasOrderedListSpan()) + } + + @Test + fun `test redo remove rich span re-removes it`() { + state.setText("Hello World") + + val range = TextEditorRange( + start = CharLineOffset(0, 0), + end = CharLineOffset(0, 11) + ) + state.addRichSpan(range, OrderedListSpanStyle) + state.removeRichSpan(range.start, range.end, OrderedListSpanStyle) + assertTrue(state.richSpanManager.getAllRichSpans().isEmpty()) + + state.undo() + assertTrue(hasOrderedListSpan()) + + state.redo() + assertTrue(state.richSpanManager.getAllRichSpans().isEmpty()) + } + + @Test + fun `test type char then add 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) + ) + ) + + val range = TextEditorRange( + start = CharLineOffset(0, 0), + end = CharLineOffset(0, 12) + ) + state.addRichSpan(range, OrderedListSpanStyle) + + // Undo the list: char stays, list gone + state.undo() + assertEquals("Hello World!", state.textLines[0].text) + assertTrue(state.richSpanManager.getAllRichSpans().isEmpty()) + + // Redo the list: char stays, list back + state.redo() + assertEquals("Hello World!", state.textLines[0].text) + assertTrue(hasOrderedListSpan()) + } +} diff --git a/ComposeTextEditor/src/desktopTest/kotlin/texteditmanager/undo/RichSpanUndoTests.kt b/ComposeTextEditor/src/desktopTest/kotlin/texteditmanager/undo/RichSpanUndoTests.kt new file mode 100644 index 0000000..f23800a --- /dev/null +++ b/ComposeTextEditor/src/desktopTest/kotlin/texteditmanager/undo/RichSpanUndoTests.kt @@ -0,0 +1,96 @@ +package texteditmanager.undo + +import androidx.compose.ui.text.AnnotatedString +import com.darkrockstudios.texteditor.CharLineOffset +import com.darkrockstudios.texteditor.TextEditorRange +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 RichSpanUndoTests { + private lateinit var scope: TestScope + private lateinit var state: TextEditorState + + @BeforeTest + fun setup() { + scope = TestScope() + state = TextEditorState( + scope = scope.backgroundScope, + measurer = mockk(relaxed = true) + ) + } + + private fun hasOrderedListSpan() = state.richSpanManager.getAllRichSpans() + .any { it.style == OrderedListSpanStyle } + + @Test + fun `test undo add rich span removes only the span`() { + state.setText("Hello World") + + val range = TextEditorRange( + start = CharLineOffset(0, 0), + end = CharLineOffset(0, 11) + ) + state.addRichSpan(range, OrderedListSpanStyle) + + assertTrue(hasOrderedListSpan()) + + state.undo() + + assertTrue(state.richSpanManager.getAllRichSpans().isEmpty()) + assertEquals("Hello World", state.textLines[0].text) + } + + @Test + fun `test undo add rich span preserves prior edit`() { + state.setText("Hello World") + + // Prior edit: insert a character + 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) + + // Now apply a rich span (the list) + val range = TextEditorRange( + start = CharLineOffset(0, 0), + end = CharLineOffset(0, 12) + ) + state.addRichSpan(range, OrderedListSpanStyle) + assertTrue(hasOrderedListSpan()) + + // Undo should remove only the list, leaving the typed character intact + state.undo() + assertTrue(state.richSpanManager.getAllRichSpans().isEmpty()) + assertEquals("Hello World!", state.textLines[0].text) + } + + @Test + fun `test undo remove rich span restores it`() { + state.setText("Hello World") + + val range = TextEditorRange( + start = CharLineOffset(0, 0), + end = CharLineOffset(0, 11) + ) + state.addRichSpan(range, OrderedListSpanStyle) + assertTrue(hasOrderedListSpan()) + + state.removeRichSpan(range.start, range.end, OrderedListSpanStyle) + assertTrue(state.richSpanManager.getAllRichSpans().isEmpty()) + + state.undo() + assertTrue(hasOrderedListSpan()) + } +} From 10dd34bdb7fae7d7e2b9eb5b58fa4303ee1e6c0d Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Thu, 11 Jun 2026 23:56:33 -0700 Subject: [PATCH 2/2] Add RichSpan branch to spell-check span invalidation Merge origin/main and fix non-exhaustive 'when' over TextEditOperation in SpellCheckState.invalidateSpellCheckSpans for the new RichSpan variant. Co-Authored-By: Claude Opus 4.8 --- .../com/darkrockstudios/texteditor/spellcheck/SpellCheckState.kt | 1 + 1 file changed, 1 insertion(+) 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 803b0e3..1d787e0 100644 --- a/ComposeTextEditorSpellCheck/src/commonMain/kotlin/com/darkrockstudios/texteditor/spellcheck/SpellCheckState.kt +++ b/ComposeTextEditorSpellCheck/src/commonMain/kotlin/com/darkrockstudios/texteditor/spellcheck/SpellCheckState.kt @@ -322,6 +322,7 @@ class SpellCheckState( is TextEditOperation.Replace -> operation.range is TextEditOperation.StyleSpan -> null + is TextEditOperation.RichSpan -> null } range?.let {