Files
i7t5--edmund/Sources/EdmundCore/Editing/EditorTextView+Indentation.swift
wehub-resource-sync 5b99bf6bca
CI / test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:34:54 +08:00

225 lines
9.3 KiB
Swift

import AppKit
// MARK: - Tab / Shift-Tab List Indentation
//
// Tab / Shift-Tab change the nesting of list items by adding or removing one
// indent unit of leading whitespace. They apply to every list block the
// selection touches (so a whole sub-list can be indented at once) and only kick
// in on list lines — elsewhere Tab inserts a literal tab as usual.
extension EditorTextView {
private static let listLineRegex = try! NSRegularExpression(pattern: #"^\s*(?:[-*+]|\d+\.)\s"#)
static let indentUnit = " " // 2 spaces
/// Returns true if the line looks like a markdown list item
/// (optionally indented): `- `, `* `, `+ `, `1. `, etc.
func isListLine(_ line: String) -> Bool {
let range = NSRange(location: 0, length: (line as NSString).length)
return Self.listLineRegex.firstMatch(in: line, range: range) != nil
}
// MARK: - Key Overrides
public override func insertTab(_ sender: Any?) {
guard let (startBlock, endBlock) = affectedListBlockRange() else {
super.insertTab(sender)
return
}
indentListBlocks(from: startBlock, to: endBlock)
}
public override func insertBacktab(_ sender: Any?) {
guard let (startBlock, endBlock) = affectedListBlockRange() else {
return
}
dedentListBlocks(from: startBlock, to: endBlock)
}
// MARK: - Block Range Detection
/// Returns the inclusive range of block indices covered by the current
/// selection, but only if every covered block is a list line.
private func affectedListBlockRange() -> (Int, Int)? {
let sel = selectedRange()
let rawStart = sel.location
let rawEnd = sel.location + sel.length
guard let startIdx = blockIndexForRawOffset(rawStart),
var endIdx = blockIndexForRawOffset(rawEnd) else {
return nil
}
// If the selection end lands exactly on the first character of a
// block, that block isn't meaningfully selected — exclude it.
if sel.length > 0 && endIdx > startIdx && endIdx < blocks.count
&& rawEnd == blocks[endIdx].range.location {
endIdx -= 1
}
for i in startIdx...endIdx {
guard i < blocks.count, isListLine(blocks[i].content) else {
return nil
}
}
return (startIdx, endIdx)
}
// MARK: - Indent (Tab)
private func indentListBlocks(from startBlock: Int, to endBlock: Int) {
let sel = selectedRange()
let rawStart = sel.location
let rawEnd = sel.location + sel.length
let indentLen = (Self.indentUnit as NSString).length
// The pre-edit storage span covering exactly the affected blocks; only
// this is replaced so layout above/below — and the viewport — is kept.
let oldRange = NSRange(
location: blocks[startBlock].range.location,
length: blocks[endBlock].range.upperBound - blocks[startBlock].range.location)
// Record undo
undoStack.append(UndoSnapshot(rawSource: rawSource, cursorInRaw: rawStart))
redoStack.removeAll()
lastEditType = .other
lastEditBlockIndex = nil
// Build new rawSource
var parts: [String] = []
for (i, block) in blocks.enumerated() {
if i >= startBlock && i <= endBlock {
parts.append(Self.indentUnit + block.content)
} else {
parts.append(block.content)
}
}
let newText = parts[startBlock...endBlock].joined(separator: blockSeparator)
let oldIndentUnit = listIndentUnit
rawSource = parts.joined(separator: blockSeparator)
rebuildListIndentState()
rebuildLinkDefState()
// Cursor in startBlock shifts by 1 indent; rawEnd in endBlock
// shifts by (endBlock - startBlock + 1) indents (one per block).
let newRawStart = rawStart + indentLen
let newRawEnd = rawEnd + indentLen * (endBlock - startBlock + 1)
blocks = BlockParser.parse(rawSource, previous: blocks)
let selInRaw = sel.length > 0
? NSRange(location: newRawStart, length: newRawEnd - newRawStart) : nil
stabilizingViewport {
recomposeReplacing(oldRange: oldRange, with: newText,
dirty: indentDirtySet(startBlock, endBlock,
unitChanged: listIndentUnit != oldIndentUnit),
cursorInRaw: newRawStart, selectionInRaw: selInRaw)
}
// The indented blocks changed depth: they may now belong to a
// different ordered run (or start a new one), and the old depth's
// remaining siblings lost a member — both need renumbering.
renumberOrderedListRunsIfNeeded(touching: startBlock..<(endBlock + 1),
depthChanged: Set(startBlock...endBlock))
document?.updateChangeCount(.changeDone)
}
/// Blocks to restyle for an indent/dedent: the directly-edited span, plus —
/// when the document-global list indent unit moved — every list block,
/// whose rendered indentation is derived from that unit.
private func indentDirtySet(_ startBlock: Int, _ endBlock: Int,
unitChanged: Bool) -> IndexSet {
var dirty = IndexSet(integersIn: startBlock...min(endBlock, blocks.count - 1))
if unitChanged {
for (i, block) in blocks.enumerated() where block.kind == .listItem {
dirty.insert(i)
}
}
return dirty
}
// MARK: - Dedent (Shift-Tab)
private func dedentListBlocks(from startBlock: Int, to endBlock: Int) {
let sel = selectedRange()
let rawStart = sel.location
let rawEnd = sel.location + sel.length
let maxRemove = Self.indentUnit.count
// Compute how many leading whitespace characters to strip from each block.
var removed: [Int] = Array(repeating: 0, count: blocks.count)
for i in startBlock...endBlock {
let content = blocks[i].content
if content.hasPrefix("\t") {
removed[i] = 1
} else {
let leading = content.prefix(while: { $0 == " " }).count
removed[i] = min(leading, maxRemove)
}
}
let totalRemoved = removed[startBlock...endBlock].reduce(0, +)
guard totalRemoved > 0 else { return }
// The pre-edit storage span covering exactly the affected blocks; only
// this is replaced so layout above/below — and the viewport — is kept.
let oldRange = NSRange(
location: blocks[startBlock].range.location,
length: blocks[endBlock].range.upperBound - blocks[startBlock].range.location)
// Record undo
undoStack.append(UndoSnapshot(rawSource: rawSource, cursorInRaw: rawStart))
redoStack.removeAll()
lastEditType = .other
lastEditBlockIndex = nil
// Build new rawSource
var parts: [String] = []
for (i, block) in blocks.enumerated() {
if i >= startBlock && i <= endBlock {
parts.append(String(block.content.dropFirst(removed[i])))
} else {
parts.append(block.content)
}
}
let newText = parts[startBlock...endBlock].joined(separator: blockSeparator)
let oldIndentUnit = listIndentUnit
rawSource = parts.joined(separator: blockSeparator)
rebuildListIndentState()
rebuildLinkDefState()
// Adjust rawStart (in startBlock). No blocks before startBlock
// were modified, so its start position is unchanged.
let startOff = rawStart - blocks[startBlock].range.location
let newRawStart = blocks[startBlock].range.location
+ max(0, startOff - removed[startBlock])
// Adjust rawEnd (in endBlock). Every indented block before
// endBlock shifted its start position left.
var cumulativeBefore = 0
for i in startBlock..<endBlock {
cumulativeBefore += removed[i]
}
let endBlockNewStart = blocks[endBlock].range.location - cumulativeBefore
let endOff = rawEnd - blocks[endBlock].range.location
let newRawEnd = endBlockNewStart + max(0, endOff - removed[endBlock])
blocks = BlockParser.parse(rawSource, previous: blocks)
let selInRaw = sel.length > 0
? NSRange(location: newRawStart, length: max(0, newRawEnd - newRawStart)) : nil
stabilizingViewport {
recomposeReplacing(oldRange: oldRange, with: newText,
dirty: indentDirtySet(startBlock, endBlock,
unitChanged: listIndentUnit != oldIndentUnit),
cursorInRaw: newRawStart, selectionInRaw: selInRaw)
}
// The dedented blocks changed depth: they may now belong to a
// different ordered run (or merge into an existing one), and the
// old depth's remaining siblings lost a member — both need renumbering.
renumberOrderedListRunsIfNeeded(touching: startBlock..<(endBlock + 1),
depthChanged: Set(startBlock...endBlock))
document?.updateChangeCount(.changeDone)
}
}