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 @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand All @@ -145,55 +176,63 @@ 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).
* No-op if [line] is out of range or has no such span.
*/
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<RichSpanStyle> =
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<RichSpanStyle>) {
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,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -28,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()
}
Expand All @@ -39,6 +46,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)
Expand Down Expand Up @@ -528,6 +536,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<LineBlockChange>, 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) {
Expand All @@ -536,10 +595,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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -163,4 +163,36 @@ sealed class TextEditOperation {
state: TextEditorState
): CharLineOffset = offset // Rich span changes don't affect positions
}
}

/**
* 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<LineBlockChange>,
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<RichSpanStyle>,
val blockSpansAfter: List<RichSpanStyle>,
)
Loading
Loading