From c8b8a9696fa557506bded2b63ad0492be56eae6d Mon Sep 17 00:00:00 2001 From: luctst Date: Tue, 9 Jun 2026 10:40:37 +0200 Subject: [PATCH 1/9] feat: make list marker kind depth-aware --- .../CheckboxIconLayoutManager.swift | 79 +++++++++++++---- .../ConcealmentAttribute.swift | 34 ++++++-- .../MarcdownStyling/ListLineScanner.swift | 28 ++++++- .../ListAttributeTests.swift | 3 +- .../ListLineScannerTests.swift | 3 +- .../NestedBulletDepthTests.swift | 84 +++++++++++++++++++ 6 files changed, 202 insertions(+), 29 deletions(-) create mode 100644 Packages/MarcdownStyling/Tests/MarcdownStylingTests/NestedBulletDepthTests.swift diff --git a/Packages/MarcdownEditor/Sources/MarcdownEditor/CheckboxIconLayoutManager.swift b/Packages/MarcdownEditor/Sources/MarcdownEditor/CheckboxIconLayoutManager.swift index d1c4002..20d760d 100644 --- a/Packages/MarcdownEditor/Sources/MarcdownEditor/CheckboxIconLayoutManager.swift +++ b/Packages/MarcdownEditor/Sources/MarcdownEditor/CheckboxIconLayoutManager.swift @@ -161,9 +161,9 @@ final class CheckboxIconLayoutManager: NSLayoutManager { if !drawnListRanges.contains(markerRange) { drawnListRanges.insert(markerRange) switch kind { - case .bullet: - drawBullet(charRange: markerRange, origin: origin) - case .ordered(let number): + case .bullet(let depth): + drawBullet(charRange: markerRange, origin: origin, depth: depth) + case .ordered(let number, _): drawOrderedNumber(number: number, charRange: markerRange, origin: origin) } } @@ -172,14 +172,22 @@ final class CheckboxIconLayoutManager: NSLayoutManager { } } - /// Draws a filled circle in the first marker glyph's advance box. The - /// 2-char marker is ``; both glyphs are `.clear`-painted by - /// the styler with a forced monospaced font so each contributes a stable, - /// wide advance. We anchor on the first (dash) glyph and centre the - /// circle inside its advance box — the icon may bleed slightly past the - /// dash's right edge, but only into the (invisible) space glyph's slot, - /// never into the body text. - private nonisolated func drawBullet(charRange: NSRange, origin: NSPoint) { + /// Draws a depth-aware bullet glyph in the first marker glyph's advance + /// box. The 2-char marker is ``; both glyphs are + /// `.clear`-painted by the styler with a forced monospaced font so each + /// contributes a stable, wide advance. We anchor on the first (dash) + /// glyph and centre the glyph inside its advance box. + /// + /// Glyph cycling per Spec §4.7 / Thomas §2: + /// - depth 0: filled circle + /// - depth 1: hollow circle (stroked) + /// - depth 2: filled diamond (rotated square) + /// - depth 3+: hollow diamond + /// + /// All bullets use `labelColor` (body weight, not accent) per Thomas §2: + /// "bullets in Bear use labelColor at full opacity — they are part of + /// the content, not chrome." + private nonisolated func drawBullet(charRange: NSRange, origin: NSPoint, depth: Int) { guard charRange.length == 2 else { return } let glyphRange = glyphRange(forCharacterRange: charRange, actualCharacterRange: nil) @@ -196,20 +204,52 @@ final class CheckboxIconLayoutManager: NSLayoutManager { let secondLocation = location(forGlyphAt: firstGlyph + 1) let advance = secondLocation.x - firstLocation.x - let maxSide: CGFloat = 13 - let side = min(lineFragRect.height * 0.65, maxSide) + let maxSide: CGFloat = 7 + let side = min(lineFragRect.height * 0.32, maxSide) let advanceBoxWidth = advance > 0 ? advance : side let glyphX = origin.x + lineFragRect.origin.x + firstLocation.x let lineY = origin.y + lineFragRect.origin.y let iconX = glyphX + (advanceBoxWidth - side) / 2 - let iconY = lineY + (lineFragRect.height - side) / 2 + // Centre on x-height (~0.5 line height) rather than baseline for a + // better optical alignment with body text. + let iconY = lineY + lineFragRect.height * 0.5 - side * 0.5 let rect = NSRect(x: iconX, y: iconY, width: side, height: side) - let path = NSBezierPath(ovalIn: rect) - NSColor.controlAccentColor.setFill() - path.fill() + let path: NSBezierPath + let isDiamond = (depth % 4) >= 2 + if isDiamond { + path = diamondPath(in: rect) + } else { + path = NSBezierPath(ovalIn: rect) + } + + let isHollow = (depth % 2) == 1 + let color = NSColor.labelColor + if isHollow { + color.setStroke() + path.lineWidth = 1.0 + path.stroke() + } else { + color.setFill() + path.fill() + } + } + + /// Build a rotated-square (diamond) path inscribed in `rect`. + private nonisolated func diamondPath(in rect: NSRect) -> NSBezierPath { + let path = NSBezierPath() + let cx = rect.midX + let cy = rect.midY + let hw = rect.width / 2 + let hh = rect.height / 2 + path.move(to: NSPoint(x: cx, y: cy + hh)) + path.line(to: NSPoint(x: cx + hw, y: cy)) + path.line(to: NSPoint(x: cx, y: cy - hh)) + path.line(to: NSPoint(x: cx - hw, y: cy)) + path.close() + return path } /// Draws `"."` anchored at the first digit's baseline, at the overlay @@ -252,8 +292,11 @@ final class CheckboxIconLayoutManager: NSLayoutManager { let drawY = baselineY - font.ascender let drawPoint = NSPoint(x: glyphX, y: drawY) + // Body color rather than accent (per Thomas §2a): the number is + // content, not chrome — accent-coloured digits across a long list + // read as visual noise. let attributes: [NSAttributedString.Key: Any] = [ - .foregroundColor: NSColor.controlAccentColor, + .foregroundColor: NSColor.labelColor, .font: font, ] diff --git a/Packages/MarcdownStyling/Sources/MarcdownStyling/ConcealmentAttribute.swift b/Packages/MarcdownStyling/Sources/MarcdownStyling/ConcealmentAttribute.swift index 06ae09f..ecc0fe0 100644 --- a/Packages/MarcdownStyling/Sources/MarcdownStyling/ConcealmentAttribute.swift +++ b/Packages/MarcdownStyling/Sources/MarcdownStyling/ConcealmentAttribute.swift @@ -11,6 +11,15 @@ import Foundation extension NSAttributedString.Key { public static let marcdownConcealed = NSAttributedString.Key("marcdownConcealed") + /// Mirror of `.marcdownConcealed` that survives focus-line reveal. + /// When the styler reveals the focused line, it strips + /// `.marcdownConcealed` (so the layout delegate stops suppressing the + /// glyphs) but leaves `.marcdownConcealedLogical` intact. The editor's + /// arrow-key handler reads the logical flag to know whether to jump + /// over a concealed run in one keystroke — even when the user is + /// currently parked on that line (Thomas §1c). + public static let marcdownConcealedLogical = NSAttributedString.Key("marcdownConcealedLogical") + /// Tags the 3-char `[ ]` / `[x]` / `[X]` source range of a complete task /// marker. Value is a `MarcdownCheckboxState`. Read by the editor's /// custom layout manager to draw the icon and by the click handler to @@ -43,10 +52,25 @@ extension NSAttributedString.Key { /// Kind of plain list marker tagged by the list-scanner pass. /// -/// `.bullet` covers any of `-`, `*`, `+` followed by a single space/tab. -/// `.ordered(number:)` carries the parsed integer that the layout manager -/// re-draws as the visible glyph (e.g. `"10."`). +/// `.bullet(depth:)` covers any of `-`, `*`, `+` followed by a single +/// space/tab. `depth` is the 0-indexed nesting depth derived from the +/// leading whitespace on the line (2-space-per-level or 1-tab-per-level +/// — see `ListLineScanner` / `ListIndentation`). +/// +/// `.ordered(number:depth:)` carries the parsed integer that the layout +/// manager re-draws as the visible glyph (e.g. `"10."`) plus the same +/// depth value used for bullets. public enum MarcdownListMarkerKind: Sendable, Equatable, Hashable { - case bullet - case ordered(number: Int) + case bullet(depth: Int) + case ordered(number: Int, depth: Int) +} + +extension MarcdownListMarkerKind { + /// Backwards-compatible factory for top-level bullets (depth 0). + public static var bullet: MarcdownListMarkerKind { .bullet(depth: 0) } + + /// Backwards-compatible factory for top-level ordered items (depth 0). + public static func ordered(number: Int) -> MarcdownListMarkerKind { + .ordered(number: number, depth: 0) + } } diff --git a/Packages/MarcdownStyling/Sources/MarcdownStyling/ListLineScanner.swift b/Packages/MarcdownStyling/Sources/MarcdownStyling/ListLineScanner.swift index d25e5cc..a52fa14 100644 --- a/Packages/MarcdownStyling/Sources/MarcdownStyling/ListLineScanner.swift +++ b/Packages/MarcdownStyling/Sources/MarcdownStyling/ListLineScanner.swift @@ -79,12 +79,14 @@ public enum ListLineScanner { guard units[i] == 0x20 || units[i] == 0x09 else { return .none } i += 1 + let depth = depth(forIndentUnits: units, indentLength: indentLength) + // `- ` with no body — complete marker, empty body. if i == length { return .complete( indentLength: indentLength, markerLength: 2, - kind: .bullet + kind: .bullet(depth: depth) ) } @@ -96,7 +98,7 @@ public enum ListLineScanner { return .complete( indentLength: indentLength, markerLength: 2, - kind: .bullet + kind: .bullet(depth: depth) ) } @@ -150,6 +152,8 @@ public enum ListLineScanner { guard units[i] == 0x20 || units[i] == 0x09 else { return .none } i += 1 + let depth = depth(forIndentUnits: units, indentLength: indentLength) + // `. ` with no body — complete marker, empty body. // Treat overflow as not-a-list-line — silently miscounting would be // worse than not concealing. @@ -158,7 +162,7 @@ public enum ListLineScanner { return .complete( indentLength: indentLength, markerLength: i - indentLength, - kind: .ordered(number: number) + kind: .ordered(number: number, depth: depth) ) } @@ -169,7 +173,23 @@ public enum ListLineScanner { return .complete( indentLength: indentLength, markerLength: i - indentLength, - kind: .ordered(number: number) + kind: .ordered(number: number, depth: depth) ) } + + /// Compute nesting depth from leading whitespace. A tab counts as one + /// depth level; spaces count as `floor(spaceCount / 2)`. Mixed indents + /// sum the two contributions. + private static func depth(forIndentUnits units: [UInt16], indentLength: Int) -> Int { + var spaces = 0 + var tabs = 0 + for k in 0.. NSTextStorage { + let styler = MarkdownStyler() + let storage = NSTextStorage(string: source) + styler.restyle(storage: storage, source: source) + return storage + } + + @Test func topLevelBulletIsDepthZero() { + let storage = restyle("- foo") + let kind = storage.attribute(.marcdownListMarker, at: 0, effectiveRange: nil) as? MarcdownListMarkerKind + #expect(kind == .bullet(depth: 0)) + } + + @Test func twoSpaceIndentedBulletIsDepthOne() { + let storage = restyle(" - foo") + let kind = storage.attribute(.marcdownListMarker, at: 2, effectiveRange: nil) as? MarcdownListMarkerKind + #expect(kind == .bullet(depth: 1)) + } + + @Test func fourSpaceIndentedBulletIsDepthTwo() { + let storage = restyle(" - foo") + let kind = storage.attribute(.marcdownListMarker, at: 4, effectiveRange: nil) as? MarcdownListMarkerKind + #expect(kind == .bullet(depth: 2)) + } + + @Test func sixSpaceIndentedBulletIsDepthThree() { + let storage = restyle(" - foo") + let kind = storage.attribute(.marcdownListMarker, at: 6, effectiveRange: nil) as? MarcdownListMarkerKind + #expect(kind == .bullet(depth: 3)) + } + + @Test func tabIndentedBulletIsDepthOne() { + // One tab counts as one depth level (same indent unit as the + // outdent helper strips). + let storage = restyle("\t- foo") + let kind = storage.attribute(.marcdownListMarker, at: 1, effectiveRange: nil) as? MarcdownListMarkerKind + #expect(kind == .bullet(depth: 1)) + } + + @Test func indentedOrderedItemCarriesDepth() { + let storage = restyle(" 1. foo") + let kind = storage.attribute(.marcdownListMarker, at: 2, effectiveRange: nil) as? MarcdownListMarkerKind + #expect(kind == .ordered(number: 1, depth: 1)) + } + + @Test func topLevelOrderedItemIsDepthZero() { + let storage = restyle("1. foo") + let kind = storage.attribute(.marcdownListMarker, at: 0, effectiveRange: nil) as? MarcdownListMarkerKind + #expect(kind == .ordered(number: 1, depth: 0)) + } +} From 6afcf504442be7084904b38772411f66e4d54469 Mon Sep 17 00:00:00 2001 From: luctst Date: Tue, 9 Jun 2026 10:40:51 +0200 Subject: [PATCH 2/9] feat: add list indentation helper for tab and shift-tab --- .../MarcdownStyling/ListIndentation.swift | 127 ++++++++ .../ListIndentationTests.swift | 285 ++++++++++++++++++ 2 files changed, 412 insertions(+) create mode 100644 Packages/MarcdownStyling/Sources/MarcdownStyling/ListIndentation.swift create mode 100644 Packages/MarcdownStyling/Tests/MarcdownStylingTests/ListIndentationTests.swift diff --git a/Packages/MarcdownStyling/Sources/MarcdownStyling/ListIndentation.swift b/Packages/MarcdownStyling/Sources/MarcdownStyling/ListIndentation.swift new file mode 100644 index 0000000..7a592a9 --- /dev/null +++ b/Packages/MarcdownStyling/Sources/MarcdownStyling/ListIndentation.swift @@ -0,0 +1,127 @@ +import Foundation + +/// Outcome of pressing Tab / Shift-Tab on a buffer where the cursor sits on +/// a list line. Mirrors the shape of `ListContinuation.ListEnterOutcome`: +/// pure helper, buffer-relative, UTF-16 throughout, AppKit-free. +public enum ListIndentOutcome: Sendable, Equatable { + /// Caller should fall through to AppKit. On a non-list line this means + /// Tab inserts a literal tab (the spec deliberately diverges from Bear's + /// "Tab on non-list line creates a code block" behaviour — see Spec §19). + case noOp + + /// Replace `range` with `replacement`, then place the cursor at + /// `cursorOffsetInBuffer` (absolute UTF-16 offset). + case replace(range: NSRange, replacement: String, cursorOffsetInBuffer: Int) +} + +/// Pure helper that decides Tab / Shift-Tab outcomes for a list (or task) +/// line. Implements Spec §4.4 / §4.5 and the corresponding ordered/task +/// variants. The indent unit is exactly 2 ASCII spaces — tabs are accepted +/// as legacy input on outdent but new indents always emit spaces so the +/// exported markdown is CommonMark-valid. +public enum ListIndentation { + + /// Tab on a list/task line: prepend `" "` (2 spaces) at lineStart. + /// Returns `.noOp` on a non-list line so the caller can fall through to + /// AppKit's default tab insertion. + public static func indentOutcome(buffer: String, cursorOffset: Int) -> ListIndentOutcome { + let units = Array(buffer.utf16) + let length = units.count + guard cursorOffset >= 0, cursorOffset <= length else { return .noOp } + + let (lineStart, lineEnd) = lineRange(units: units, cursor: cursorOffset) + guard lineEnd > lineStart else { return .noOp } + + let lineContent = substring(units: units, from: lineStart, to: lineEnd) + guard isListOrTaskLine(line: lineContent) else { return .noOp } + + return .replace( + range: NSRange(location: lineStart, length: 0), + replacement: " ", + cursorOffsetInBuffer: cursorOffset + 2 + ) + } + + /// Shift-Tab on a list/task line: strip up to one indent unit at lineStart. + /// - Two leading spaces → strip both. + /// - One leading tab → strip the tab. + /// - One leading space → strip just that space (defensive — preserves + /// the user's odd indent rather than refusing). + /// Returns `.noOp` if there is no leading whitespace, or on a non-list line. + public static func outdentOutcome(buffer: String, cursorOffset: Int) -> ListIndentOutcome { + let units = Array(buffer.utf16) + let length = units.count + guard cursorOffset >= 0, cursorOffset <= length else { return .noOp } + + let (lineStart, lineEnd) = lineRange(units: units, cursor: cursorOffset) + guard lineEnd > lineStart else { return .noOp } + + let lineContent = substring(units: units, from: lineStart, to: lineEnd) + guard isListOrTaskLine(line: lineContent) else { return .noOp } + + // Determine how many leading whitespace units to strip. + let stripLength: Int + if lineEnd - lineStart >= 1, units[lineStart] == 0x09 { + stripLength = 1 + } else if lineEnd - lineStart >= 2, + units[lineStart] == 0x20, units[lineStart + 1] == 0x20 + { + stripLength = 2 + } else if lineEnd - lineStart >= 1, units[lineStart] == 0x20 { + stripLength = 1 + } else { + return .noOp + } + + // Cursor adjustment: subtract however much of the stripped range lies + // before the cursor. + let cursorOffsetInLine = cursorOffset - lineStart + let removedBeforeCursor = min(stripLength, max(0, cursorOffsetInLine)) + return .replace( + range: NSRange(location: lineStart, length: stripLength), + replacement: "", + cursorOffsetInBuffer: cursorOffset - removedBeforeCursor + ) + } + + // MARK: - Helpers + + /// Returns true if the line is a bullet/ordered list (`ListLineScanner` + /// reports `.complete` or `.partial`) OR a task list (`CheckboxLineScanner` + /// reports `.complete` or `.partial`). Empty / plain lines return false. + private static func isListOrTaskLine(line: String) -> Bool { + switch CheckboxLineScanner.scan(line: line) { + case .complete, .partial: + return true + case .none: + break + } + switch ListLineScanner.scan(line: line) { + case .complete, .partial: + return true + case .none: + return false + } + } + + /// Locate the `\n` boundaries surrounding the cursor. + private static func lineRange(units: [UInt16], cursor: Int) -> (Int, Int) { + var lineStart = cursor + while lineStart > 0, units[lineStart - 1] != 0x0A { + lineStart -= 1 + } + var lineEnd = cursor + while lineEnd < units.count, units[lineEnd] != 0x0A { + lineEnd += 1 + } + return (lineStart, lineEnd) + } + + private static func substring(units: [UInt16], from: Int, to: Int) -> String { + let slice = Array(units[from.. ListIndentOutcome +// +// /// Shift-Tab on a list/task line: strip up to 2 leading spaces (or 1 +// /// leading tab) from lineStart. `.noOp` when already at the left margin +// /// or on a non-list line. +// public static func outdentOutcome(buffer: String, cursorOffset: Int) -> ListIndentOutcome +// } +// +// And the renumbering helper (Section 5.3/5.4): +// +// public enum OrderedListRenumber { +// /// Renumber the contiguous ordered-list run that *contains* the line at +// /// `anchorOffset`. A run is a maximal sequence of consecutive lines +// /// at the same indent depth whose marker is `.ordered`. The first +// /// item's number is preserved; subsequent items get sequential numbers. +// /// Returns the new buffer string and the adjusted cursor offset. +// /// Returns `.noOp` if the anchor line is not part of an ordered run. +// public static func renumberRun( +// buffer: String, +// anchorOffset: Int, +// cursorOffset: Int +// ) -> RenumberOutcome +// } +// +// public enum RenumberOutcome: Sendable, Equatable { +// case noOp +// case rewrite(newBuffer: String, newCursorOffset: Int) +// } + +@Suite("List Tab / Shift-Tab indentation") +struct ListIndentationTests { + + // MARK: - Tab on a bullet line + + @Test func tabOnBulletAtBufferStartPrependsTwoSpaces() { + // Before: "- foo|" + // After: " - foo|" (cursor shifts by +2 buffer offsets, stays on same logical column) + #expect( + ListIndentation.indentOutcome(buffer: "- foo", cursorOffset: 5) + == .replace( + range: NSRange(location: 0, length: 0), + replacement: " ", + cursorOffsetInBuffer: 7 + ) + ) + } + + @Test func tabOnStarBulletPrependsTwoSpaces() { + #expect( + ListIndentation.indentOutcome(buffer: "* foo", cursorOffset: 5) + == .replace( + range: NSRange(location: 0, length: 0), + replacement: " ", + cursorOffsetInBuffer: 7 + ) + ) + } + + @Test func tabOnAlreadyIndentedBulletAddsAnotherLevel() { + // " - foo" → " - foo" + #expect( + ListIndentation.indentOutcome(buffer: " - foo", cursorOffset: 7) + == .replace( + range: NSRange(location: 0, length: 0), + replacement: " ", + cursorOffsetInBuffer: 9 + ) + ) + } + + @Test func tabOnBulletMidBufferPrependsAtLineStart() { + // Buffer: "prev\n- foo\nnext" + // Offsets: 0 5 6 10 11 ... + // Line "- foo" starts at offset 5. Cursor at end of that line (offset 10). + #expect( + ListIndentation.indentOutcome( + buffer: "prev\n- foo\nnext", + cursorOffset: 10 + ) + == .replace( + range: NSRange(location: 5, length: 0), + replacement: " ", + cursorOffsetInBuffer: 12 + ) + ) + } + + // MARK: - Tab on an ordered line + + @Test func tabOnOrderedItemPrependsTwoSpaces() { + #expect( + ListIndentation.indentOutcome(buffer: "1. foo", cursorOffset: 6) + == .replace( + range: NSRange(location: 0, length: 0), + replacement: " ", + cursorOffsetInBuffer: 8 + ) + ) + } + + // MARK: - Tab on a task line + + @Test func tabOnTaskItemPrependsTwoSpaces() { + #expect( + ListIndentation.indentOutcome(buffer: "- [ ] foo", cursorOffset: 9) + == .replace( + range: NSRange(location: 0, length: 0), + replacement: " ", + cursorOffsetInBuffer: 11 + ) + ) + } + + // MARK: - Tab on a non-list line is a no-op (caller falls through to AppKit) + + @Test func tabOnPlainLineIsNoOp() { + // Plain paragraph: Tab is a literal indent, NOT a list indent. + // Helper returns .noOp so the coordinator falls through to AppKit. + // This deliberately diverges from Bear (see Spec §19 / Thomas §10). + #expect( + ListIndentation.indentOutcome(buffer: "hello", cursorOffset: 5) + == .noOp + ) + } + + @Test func tabOnEmptyBufferIsNoOp() { + #expect( + ListIndentation.indentOutcome(buffer: "", cursorOffset: 0) + == .noOp + ) + } + + // MARK: - Tab cursor position invariants + + @Test func tabWithCursorInsideBodyShiftsCursorByTwo() { + // "- fo|o" → cursor at offset 4 → " - fo|o" → cursor at offset 6 + #expect( + ListIndentation.indentOutcome(buffer: "- foo", cursorOffset: 4) + == .replace( + range: NSRange(location: 0, length: 0), + replacement: " ", + cursorOffsetInBuffer: 6 + ) + ) + } + + @Test func tabWithCursorAtBodyStartShiftsCursorByTwo() { + // Cursor right after "- " on offset 2 → after indent at offset 4. + #expect( + ListIndentation.indentOutcome(buffer: "- foo", cursorOffset: 2) + == .replace( + range: NSRange(location: 0, length: 0), + replacement: " ", + cursorOffsetInBuffer: 4 + ) + ) + } + + // MARK: - Shift-Tab on a bullet line + + @Test func outdentOnIndentedBulletRemovesTwoLeadingSpaces() { + // " - foo|" (cursor at 7) → "- foo|" (cursor at 5) + #expect( + ListIndentation.outdentOutcome(buffer: " - foo", cursorOffset: 7) + == .replace( + range: NSRange(location: 0, length: 2), + replacement: "", + cursorOffsetInBuffer: 5 + ) + ) + } + + @Test func outdentOnDoubleIndentedBulletRemovesTwoSpaces() { + // " - foo" → " - foo" + #expect( + ListIndentation.outdentOutcome(buffer: " - foo", cursorOffset: 9) + == .replace( + range: NSRange(location: 0, length: 2), + replacement: "", + cursorOffsetInBuffer: 7 + ) + ) + } + + @Test func outdentOnTopLevelBulletIsNoOp() { + // No leading whitespace → nothing to remove. Bear behaviour: no-op + // (does NOT consume the marker character). + #expect( + ListIndentation.outdentOutcome(buffer: "- foo", cursorOffset: 5) + == .noOp + ) + } + + @Test func outdentOnSingleSpaceBulletRemovesOneSpace() { + // Defensive: indent unit is 2 but we shouldn't lose user data if they + // typed a single-space indent. Outdent removes whatever indent exists + // up to the indent unit (max 2). + #expect( + ListIndentation.outdentOutcome(buffer: " - foo", cursorOffset: 6) + == .replace( + range: NSRange(location: 0, length: 1), + replacement: "", + cursorOffsetInBuffer: 5 + ) + ) + } + + @Test func outdentOnTabIndentedBulletRemovesOneTab() { + // Per spec: tabs are accepted as indent input. Outdent strips one tab + // at a time (not two spaces' worth). + #expect( + ListIndentation.outdentOutcome(buffer: "\t- foo", cursorOffset: 6) + == .replace( + range: NSRange(location: 0, length: 1), + replacement: "", + cursorOffsetInBuffer: 5 + ) + ) + } + + // MARK: - Shift-Tab on ordered + task + + @Test func outdentOnIndentedOrderedItemRemovesTwoSpaces() { + #expect( + ListIndentation.outdentOutcome(buffer: " 1. foo", cursorOffset: 8) + == .replace( + range: NSRange(location: 0, length: 2), + replacement: "", + cursorOffsetInBuffer: 6 + ) + ) + } + + @Test func outdentOnIndentedTaskItemRemovesTwoSpaces() { + #expect( + ListIndentation.outdentOutcome(buffer: " - [ ] foo", cursorOffset: 11) + == .replace( + range: NSRange(location: 0, length: 2), + replacement: "", + cursorOffsetInBuffer: 9 + ) + ) + } + + // MARK: - Shift-Tab on non-list line + + @Test func outdentOnPlainLineIsNoOp() { + #expect( + ListIndentation.outdentOutcome(buffer: " hello", cursorOffset: 7) + == .noOp + ) + } + + @Test func outdentOnEmptyBufferIsNoOp() { + #expect( + ListIndentation.outdentOutcome(buffer: "", cursorOffset: 0) + == .noOp + ) + } +} From 921c0b69803e99c5fb581908ff32c9e1d5608b89 Mon Sep 17 00:00:00 2001 From: luctst Date: Tue, 9 Jun 2026 10:40:52 +0200 Subject: [PATCH 3/9] feat: add ordered list renumber helper --- .../MarcdownStyling/OrderedListRenumber.swift | 282 ++++++++++++++++++ .../OrderedListRenumberTests.swift | 239 +++++++++++++++ 2 files changed, 521 insertions(+) create mode 100644 Packages/MarcdownStyling/Sources/MarcdownStyling/OrderedListRenumber.swift create mode 100644 Packages/MarcdownStyling/Tests/MarcdownStylingTests/OrderedListRenumberTests.swift diff --git a/Packages/MarcdownStyling/Sources/MarcdownStyling/OrderedListRenumber.swift b/Packages/MarcdownStyling/Sources/MarcdownStyling/OrderedListRenumber.swift new file mode 100644 index 0000000..4d0a68f --- /dev/null +++ b/Packages/MarcdownStyling/Sources/MarcdownStyling/OrderedListRenumber.swift @@ -0,0 +1,282 @@ +import Foundation + +/// Outcome of an ordered-list renumber pass. Pure helper, buffer-relative. +public enum RenumberOutcome: Sendable, Equatable { + /// No rewrite needed (anchor not on an ordered line, or the run is + /// already consecutive starting from its first item's number). + case noOp + /// Replace the entire buffer with `newBuffer` and place the cursor at + /// `newCursorOffset`. Callers translate this into a single + /// `replaceCharacters(in:)` covering just the run for minimal redraw. + case rewrite(newBuffer: String, newCursorOffset: Int) +} + +/// Renumbers the contiguous ordered-list run that contains a given anchor +/// line. A "run" is the maximal consecutive sequence of lines whose +/// `ListLineScanner` shape is `.complete(.ordered)` AND share the same +/// `indentLength` as the anchor line. The first number is preserved; each +/// subsequent item is renumbered to `firstNumber + i`. +/// +/// Spec §5.3 / §5.4: invoked by the editor coordinator after Enter, Backspace, +/// Tab, or Shift-Tab on an ordered item to keep the visible numbering in sync. +public enum OrderedListRenumber { + + public static func renumberRun( + buffer: String, + anchorOffset: Int, + cursorOffset: Int + ) -> RenumberOutcome { + let units = Array(buffer.utf16) + let length = units.count + guard anchorOffset >= 0, anchorOffset <= length else { return .noOp } + + // 1. Identify the anchor line and its ordered metadata. + guard let anchor = orderedLineMetadata(units: units, offset: anchorOffset) else { + return .noOp + } + + // 2. Walk backward. Stop on any line that is not ordered-at-anchor-depth + // AND not a strictly-deeper list/task line (nested children are + // "owned" by the run and skipped over, not break-points). + var runLineStarts: [Int] = [anchor.lineStart] + var cursor = anchor.lineStart + while cursor > 0 { + let prevLineEnd = cursor - 1 + guard prevLineEnd >= 0 else { break } + let prevLineStart = scanLineStart(units: units, endOffset: prevLineEnd) + let walk = walkClassification( + units: units, + lineStart: prevLineStart, + lineEnd: prevLineEnd, + anchorIndent: anchor.indentLength + ) + switch walk { + case .matches: + runLineStarts.append(prevLineStart) + cursor = prevLineStart + case .skipNested: + cursor = prevLineStart + case .breaks: + break + } + if case .breaks = walk { break } + } + runLineStarts.reverse() + + // 3. Walk forward with the same classification. + var lookahead = anchor.lineEnd + while lookahead < length { + guard units[lookahead] == 0x0A else { break } + let nextStart = lookahead + 1 + guard nextStart <= length else { break } + let nextEnd = scanLineEnd(units: units, startOffset: nextStart) + let walk = walkClassification( + units: units, + lineStart: nextStart, + lineEnd: nextEnd, + anchorIndent: anchor.indentLength + ) + switch walk { + case .matches: + runLineStarts.append(nextStart) + lookahead = nextEnd + case .skipNested: + lookahead = nextEnd + case .breaks: + break + } + if case .breaks = walk { break } + } + + // 4. Compute the new numbers. Preserve the first line's number. + guard let firstStart = runLineStarts.first, + let firstMeta = orderedLineMetadata(units: units, lineStart: firstStart, lineEnd: scanLineEnd(units: units, startOffset: firstStart)) + else { return .noOp } + + let firstNumber = firstMeta.number + + // 5. Build the new buffer in one pass. Compute cursor delta along the + // way so we don't have to re-scan. + var newUnits: [UInt16] = [] + newUnits.reserveCapacity(units.count) + var changed = false + var cursorDelta = 0 + var cursorAdjusted = cursorOffset + + var writeIndex = 0 + for (i, lineStart) in runLineStarts.enumerated() { + let expectedNumber = firstNumber + i + // Copy untouched units up to this line's start. + if writeIndex < lineStart { + newUnits.append(contentsOf: units[writeIndex.. 0 { + newUnits.append(contentsOf: units[lineStart..<(lineStart + meta.indentLength)]) + } + // Emit the new digit string. + let oldDigitCount = meta.digitCount + let newDigitString = "\(expectedNumber)" + let newDigitUnits = Array(newDigitString.utf16) + let newDigitCount = newDigitUnits.count + newUnits.append(contentsOf: newDigitUnits) + if expectedNumber != meta.number { + changed = true + } + if newDigitCount != oldDigitCount { + changed = true + let widthDelta = newDigitCount - oldDigitCount + let digitsEndInOriginal = lineStart + meta.indentLength + oldDigitCount + // Cursor adjustments: + if cursorOffset >= digitsEndInOriginal { + // Cursor is past the digit run on this or a later line — + // shift by the full width delta. + cursorAdjusted += widthDelta + } else if cursorOffset > lineStart + meta.indentLength { + // Cursor is INSIDE the digit run that grew/shrunk — + // snap to the body-start of this item. + cursorAdjusted = lineStart + meta.indentLength + newDigitCount + 2 // `. ` + } + cursorDelta += widthDelta + } + // Emit the `. ` and the rest of the line content (body). + let bodyStart = lineStart + meta.indentLength + oldDigitCount + let lineEndOrig = lineEnd + newUnits.append(contentsOf: units[bodyStart.. WalkClassification { + guard lineEnd >= lineStart else { return .breaks } + let line = Array(units[lineStart.. anchorIndent { return .skipNested } + return .breaks + } + // Check bullet / partial list lines. + switch ListLineScanner.scan(line: line) { + case .complete(let indent, _, _): + // Bullet at anchor depth breaks the run. + if indent > anchorIndent { return .skipNested } + return .breaks + case .partial(let indent, _): + if indent > anchorIndent { return .skipNested } + return .breaks + case .none: + break + } + // Check task lines. + switch CheckboxLineScanner.scan(line: line) { + case .complete(let indent, _, _): + if indent > anchorIndent { return .skipNested } + return .breaks + case .partial(let indent, _): + if indent > anchorIndent { return .skipNested } + return .breaks + case .none: + return .breaks + } + } + + // MARK: - Metadata extraction + + private struct OrderedLineMeta { + let lineStart: Int + let lineEnd: Int + let indentLength: Int + let digitCount: Int + let number: Int + } + + /// Locate the line containing `offset` and decode its ordered-list metadata, + /// if any. + private static func orderedLineMetadata(units: [UInt16], offset: Int) -> OrderedLineMeta? { + let lineStart = scanLineStart(units: units, endOffset: offset) + let lineEnd = scanLineEnd(units: units, startOffset: lineStart) + return orderedLineMetadata(units: units, lineStart: lineStart, lineEnd: lineEnd) + } + + /// Decode the ordered-list metadata for an already-located line. + private static func orderedLineMetadata(units: [UInt16], lineStart: Int, lineEnd: Int) -> OrderedLineMeta? { + guard lineEnd >= lineStart else { return nil } + let line = Array(units[lineStart.. Int { + var probe = max(0, min(endOffset, units.count)) + while probe > 0, units[probe - 1] != 0x0A { + probe -= 1 + } + return probe + } + + private static func scanLineEnd(units: [UInt16], startOffset: Int) -> Int { + var probe = max(0, min(startOffset, units.count)) + while probe < units.count, units[probe] != 0x0A { + probe += 1 + } + return probe + } +} diff --git a/Packages/MarcdownStyling/Tests/MarcdownStylingTests/OrderedListRenumberTests.swift b/Packages/MarcdownStyling/Tests/MarcdownStylingTests/OrderedListRenumberTests.swift new file mode 100644 index 0000000..e0e9ed0 --- /dev/null +++ b/Packages/MarcdownStyling/Tests/MarcdownStylingTests/OrderedListRenumberTests.swift @@ -0,0 +1,239 @@ +import Foundation +import Testing + +@testable import MarcdownStyling + +// MARK: - Expected production API (to be implemented by guy) +// +// `OrderedListRenumber` is a pure helper invoked by the editor after any edit +// that may have changed the ordering of an ordered-list run (Enter, Backspace, +// Tab, Shift-Tab on an ordered item). It walks the buffer from the anchor line +// outward to find the contiguous run at the same indent depth, then rewrites +// each item's number so the sequence is consecutive starting from the run's +// first item. +// +// public enum RenumberOutcome: Sendable, Equatable { +// case noOp +// case rewrite(newBuffer: String, newCursorOffset: Int) +// } +// +// public enum OrderedListRenumber { +// public static func renumberRun( +// buffer: String, +// anchorOffset: Int, +// cursorOffset: Int +// ) -> RenumberOutcome +// } +// +// Contract notes: +// - The run is the maximal consecutive sequence of lines whose `ListLineScanner` +// shape is `.complete(.ordered)` AND share the same `indentLength` as the +// anchor line. +// - The first number of the run is preserved (so a list "5. a / 6. b" that +// gets a new item inserted between renumbers consistently from 5). +// - Cursor offset is adjusted by the net byte delta of the rewrite. If the +// cursor lies in a digit run that grows/shrinks (e.g. "9. foo" → "10. foo"), +// it lands at the body-start of that item. +// - Different indent depths are independent runs. Renumbering only touches the +// anchor's depth; nested ordered sub-lists are NOT affected. + +@Suite("OrderedListRenumber") +struct OrderedListRenumberTests { + + // MARK: - Anchor not in an ordered run + + @Test func anchorOnPlainLineIsNoOp() { + #expect( + OrderedListRenumber.renumberRun( + buffer: "hello", + anchorOffset: 0, + cursorOffset: 5 + ) == .noOp + ) + } + + @Test func anchorOnBulletLineIsNoOp() { + #expect( + OrderedListRenumber.renumberRun( + buffer: "- foo", + anchorOffset: 0, + cursorOffset: 5 + ) == .noOp + ) + } + + @Test func anchorOnSingleOrderedItemDoesNothing() { + // Run is just "1. foo" — already consecutive. Helper still returns + // `.rewrite` with an identical buffer (idempotent rewrite) OR `.noOp`. + // We require idempotence: same content out = `.noOp` to avoid undo + // bloat. + #expect( + OrderedListRenumber.renumberRun( + buffer: "1. foo", + anchorOffset: 0, + cursorOffset: 6 + ) == .noOp + ) + } + + // MARK: - Renumber after insertion (Section 5.3 acceptance #4) + + @Test func runAfterInsertionRenumbersDownstream() { + // Buffer reflects state immediately after Enter continuation produced + // "1. a\n2. \n2. b" (the new "2. " was inserted, but the old "2. b" + // wasn't renumbered yet). Renumber pass turns it into: + // "1. a\n2. \n3. b" + let buffer = "1. a\n2. \n2. b" + let outcome = OrderedListRenumber.renumberRun( + buffer: buffer, + anchorOffset: 5, // start of the new "2. " line + cursorOffset: 8 // cursor at end of marker on the new line + ) + #expect( + outcome == .rewrite( + newBuffer: "1. a\n2. \n3. b", + newCursorOffset: 8 + ) + ) + } + + @Test func runStartingMidDocumentPreservesFirstNumber() { + // First item is "5." — renumber must continue from 5, not reset to 1. + let buffer = "preamble\n\n5. a\n7. b\n9. c" + let outcome = OrderedListRenumber.renumberRun( + buffer: buffer, + anchorOffset: 10, // anchor on "5. a" + cursorOffset: 14 + ) + #expect( + outcome == .rewrite( + newBuffer: "preamble\n\n5. a\n6. b\n7. c", + newCursorOffset: 14 + ) + ) + } + + // MARK: - Renumber after deletion (Section 5.4 acceptance #5) + + @Test func runAfterDeletionRenumbersDownstream() { + // After Backspace consumed "2. |" item, buffer is "1. a\n3. c". + // Renumber pass should turn it into "1. a\n2. c". + let buffer = "1. a\n3. c" + let outcome = OrderedListRenumber.renumberRun( + buffer: buffer, + anchorOffset: 0, // anchor on the still-existing first item + cursorOffset: 4 // cursor at end of "1. a" + ) + #expect( + outcome == .rewrite( + newBuffer: "1. a\n2. c", + newCursorOffset: 4 + ) + ) + } + + // MARK: - Indent-depth isolation + + @Test func nestedOrderedRunIsIndependentOfParent() { + // Parent run: "1. a", "2. d" at depth 0. + // Nested run under "1. a": " 1. b", " 2. c" at depth 2. + // Anchoring on the parent run only renumbers depth-0 items. + let buffer = "1. a\n 1. b\n 2. c\n5. d" + let outcome = OrderedListRenumber.renumberRun( + buffer: buffer, + anchorOffset: 0, // anchor at depth 0 + cursorOffset: 0 + ) + #expect( + outcome == .rewrite( + newBuffer: "1. a\n 1. b\n 2. c\n2. d", + newCursorOffset: 0 + ) + ) + } + + @Test func anchoringOnNestedRunOnlyRenumbersThatDepth() { + // Same buffer; anchor on the nested " 3. c" (which is wrong — should + // be 2). Parent items keep their existing numbers. + let buffer = "1. a\n 1. b\n 3. c\n5. d" + let outcome = OrderedListRenumber.renumberRun( + buffer: buffer, + anchorOffset: 5, // anchor at depth 2 (the " 1. b" line) + cursorOffset: 5 + ) + #expect( + outcome == .rewrite( + newBuffer: "1. a\n 1. b\n 2. c\n5. d", + newCursorOffset: 5 + ) + ) + } + + // MARK: - Digit-width changes ripple cursor offset + + @Test func renumberAcrossDigitWidthBoundaryShiftsCursor() { + // Items "8. a", "9. b", "1. c" (broken — needs renumber). + // Renumber rewrites to "8. a", "9. b", "10. c". The third line grows + // by one digit. Cursor on line 3 at end shifts +1. + let buffer = "8. a\n9. b\n1. c" + // Line 3 starts at offset 10. Cursor at end of "1. c" = offset 14. + let outcome = OrderedListRenumber.renumberRun( + buffer: buffer, + anchorOffset: 0, + cursorOffset: 14 + ) + #expect( + outcome == .rewrite( + newBuffer: "8. a\n9. b\n10. c", + newCursorOffset: 15 + ) + ) + } + + @Test func renumberShrinkingDigitsShiftsCursor() { + // "9. a", "11. b", "12. c" — renumber should give 9/10/11. + // Line 2 shrinks from "11. b" to "10. b" (no change in width). + // Line 3 shrinks from "12. c" to "11. c" (no change in width). + // Cursor on line 3 stays put. + let buffer = "9. a\n11. b\n12. c" + let outcome = OrderedListRenumber.renumberRun( + buffer: buffer, + anchorOffset: 0, + cursorOffset: 16 + ) + #expect( + outcome == .rewrite( + newBuffer: "9. a\n10. b\n11. c", + newCursorOffset: 16 + ) + ) + } + + // MARK: - Run boundary detection + + @Test func runBreaksOnPlainLineGap() { + // "1. a\nplain\n3. b" — the plain line splits this into two runs of + // length 1 each. Anchoring on the first does nothing (single-item + // run, already consecutive). Anchoring on "3. b" likewise no-ops. + let buffer = "1. a\nplain\n3. b" + #expect( + OrderedListRenumber.renumberRun( + buffer: buffer, + anchorOffset: 0, + cursorOffset: 0 + ) == .noOp + ) + } + + @Test func runBreaksOnBulletLine() { + // "1. a\n- b\n3. c" — the bullet splits the ordered run. + let buffer = "1. a\n- b\n3. c" + #expect( + OrderedListRenumber.renumberRun( + buffer: buffer, + anchorOffset: 0, + cursorOffset: 0 + ) == .noOp + ) + } +} From 252d7969960027d74b720187f04d8995faed101a Mon Sep 17 00:00:00 2001 From: luctst Date: Tue, 9 Jun 2026 10:40:52 +0200 Subject: [PATCH 4/9] feat: add blockquote continuation helper --- .../BlockquoteContinuation.swift | 166 ++++++++++++++++ .../BlockquoteContinuationTests.swift | 182 ++++++++++++++++++ 2 files changed, 348 insertions(+) create mode 100644 Packages/MarcdownStyling/Sources/MarcdownStyling/BlockquoteContinuation.swift create mode 100644 Packages/MarcdownStyling/Tests/MarcdownStylingTests/BlockquoteContinuationTests.swift diff --git a/Packages/MarcdownStyling/Sources/MarcdownStyling/BlockquoteContinuation.swift b/Packages/MarcdownStyling/Sources/MarcdownStyling/BlockquoteContinuation.swift new file mode 100644 index 0000000..3ee381a --- /dev/null +++ b/Packages/MarcdownStyling/Sources/MarcdownStyling/BlockquoteContinuation.swift @@ -0,0 +1,166 @@ +import Foundation + +/// Outcome of pressing Return inside a buffer that may contain a blockquote +/// marker. Mirrors `ListContinuation.ListEnterOutcome`. +public enum BlockquoteEnterOutcome: Sendable, Equatable { + case noOp + case replace(range: NSRange, replacement: String, cursorOffsetInBuffer: Int) +} + +/// Outcome of pressing Backspace on a blockquote line. +public enum BlockquoteBackspaceOutcome: Sendable, Equatable { + case standard + case replace(range: NSRange, cursorOffsetInBuffer: Int) +} + +/// Pure helper that decides Enter / Backspace outcomes for blockquote lines. +/// Spec §9: lines starting with one or more `> ` markers get continuation on +/// Enter (preserving depth) and atomic strip on Enter/Backspace when the +/// marker line is empty. +public enum BlockquoteContinuation { + + /// Decide the Enter outcome. + public static func enterOutcome(buffer: String, cursorOffset: Int) -> BlockquoteEnterOutcome { + let units = Array(buffer.utf16) + let length = units.count + guard cursorOffset >= 0, cursorOffset <= length else { return .noOp } + + let (lineStart, lineEnd) = lineRange(units: units, cursor: cursorOffset) + guard let prefix = scanBlockquotePrefix(units: units, lineStart: lineStart, lineEnd: lineEnd) else { + return .noOp + } + + let lineLength = lineEnd - lineStart + let cursorOffsetInLine = cursorOffset - lineStart + + // Cursor before / inside the marker prefix → fall through. + if cursorOffsetInLine < prefix.prefixLength { return .noOp } + + let bodyLength = lineLength - prefix.prefixLength + if bodyLength > 0 { + // Non-empty body — continue with the same prefix. + let prefixString = substring(units: units, from: lineStart, to: lineStart + prefix.prefixLength) + let continuation = "\n" + prefixString + let replacementUTF16Count = continuation.utf16.count + return .replace( + range: NSRange(location: cursorOffset, length: 0), + replacement: continuation, + cursorOffsetInBuffer: cursorOffset + replacementUTF16Count + ) + } + + // Empty body. If nested (`>> `, `>>> `, …) strip one level only; + // otherwise strip the entire prefix. + if prefix.depth > 1 { + // Strip one `>` and possibly a trailing space. The exact bytes to + // strip: the FIRST `>` of the prefix plus an immediately-following + // space if present. + // Simpler: the new prefix is the existing prefix minus one level. + let newPrefix = buildPrefix(depth: prefix.depth - 1) + return .replace( + range: NSRange(location: lineStart, length: prefix.prefixLength), + replacement: newPrefix, + cursorOffsetInBuffer: lineStart + (newPrefix as NSString).length + ) + } + + // Top-level empty blockquote → strip entire prefix. + return .replace( + range: NSRange(location: lineStart, length: prefix.prefixLength), + replacement: "", + cursorOffsetInBuffer: lineStart + ) + } + + /// Decide the Backspace outcome. + public static func backspaceOutcome(buffer: String, cursorOffset: Int) -> BlockquoteBackspaceOutcome { + let units = Array(buffer.utf16) + let length = units.count + guard cursorOffset >= 0, cursorOffset <= length else { return .standard } + + let (lineStart, lineEnd) = lineRange(units: units, cursor: cursorOffset) + guard let prefix = scanBlockquotePrefix(units: units, lineStart: lineStart, lineEnd: lineEnd) else { + return .standard + } + + let lineLength = lineEnd - lineStart + let cursorOffsetInLine = cursorOffset - lineStart + + // Atomic delete only when cursor is at the end of an empty-body prefix. + let bodyLength = lineLength - prefix.prefixLength + guard cursorOffsetInLine == prefix.prefixLength, bodyLength == 0 else { + return .standard + } + + if lineStart > 0 { + let range = NSRange(location: lineStart - 1, length: lineEnd - lineStart + 1) + return .replace(range: range, cursorOffsetInBuffer: lineStart - 1) + } + let range = NSRange(location: 0, length: lineEnd) + return .replace(range: range, cursorOffsetInBuffer: 0) + } + + // MARK: - Prefix scanning + + private struct BlockquotePrefix { + /// Total UTF-16 length of the prefix (`>` chars plus any single + /// trailing space). + let prefixLength: Int + /// Number of `>` characters in the prefix (i.e. nesting depth). + let depth: Int + } + + /// If the line at `lineStart..` (optionally + /// followed by more `>` for nesting and a single trailing space), return + /// the prefix length and depth. Otherwise return nil. + private static func scanBlockquotePrefix( + units: [UInt16], + lineStart: Int, + lineEnd: Int + ) -> BlockquotePrefix? { + var i = lineStart + guard i < lineEnd, units[i] == 0x3E else { return nil } // '>' + + // Bear and most editors only treat `>` runs without intervening + // characters as multi-level (`>>`, `>>>`). A `>` followed by ` >` is + // a single-level quote whose body happens to be another quote. + var depth = 0 + while i < lineEnd, units[i] == 0x3E { + depth += 1 + i += 1 + } + // Allow a single trailing space (CommonMark canonical form). + if i < lineEnd, units[i] == 0x20 { + i += 1 + } + return BlockquotePrefix(prefixLength: i - lineStart, depth: depth) + } + + /// Build a prefix string like `> `, `>> `, `>>> ` for `depth >= 1`. + private static func buildPrefix(depth: Int) -> String { + guard depth >= 1 else { return "" } + return String(repeating: ">", count: depth) + " " + } + + // MARK: - Boundaries + + private static func lineRange(units: [UInt16], cursor: Int) -> (Int, Int) { + var lineStart = cursor + while lineStart > 0, units[lineStart - 1] != 0x0A { + lineStart -= 1 + } + var lineEnd = cursor + while lineEnd < units.count, units[lineEnd] != 0x0A { + lineEnd += 1 + } + return (lineStart, lineEnd) + } + + private static func substring(units: [UInt16], from: Int, to: Int) -> String { + let slice = Array(units[from..` markers. +// +// public enum BlockquoteEnterOutcome: Sendable, Equatable { +// case noOp +// case replace(range: NSRange, replacement: String, cursorOffsetInBuffer: Int) +// } +// +// public enum BlockquoteBackspaceOutcome: Sendable, Equatable { +// case standard +// case replace(range: NSRange, cursorOffsetInBuffer: Int) +// } +// +// public enum BlockquoteContinuation { +// public static func enterOutcome(buffer: String, cursorOffset: Int) -> BlockquoteEnterOutcome +// public static func backspaceOutcome(buffer: String, cursorOffset: Int) -> BlockquoteBackspaceOutcome +// } +// +// Contract (Spec §9): +// - On non-empty `> body` Enter, insert "\n> " continuation at cursor. +// - On empty `> ` Enter, strip the entire `> ` prefix from the line. +// - On nested `>> ` Enter (empty), strip ONE level only (`>> ` → `> `). +// - On empty `> ` Backspace, atomic delete (line + preceding newline). +// - Plain lines / lines without `>` return `.noOp` / `.standard`. + +@Suite("BlockquoteContinuation") +struct BlockquoteContinuationTests { + + // MARK: - Enter on non-empty blockquote + + @Test func endOfBlockquoteLineSplits() { + // "> hello|" + let buffer = "> hello" + #expect( + BlockquoteContinuation.enterOutcome(buffer: buffer, cursorOffset: 7) + == .replace( + range: NSRange(location: 7, length: 0), + replacement: "\n> ", + cursorOffsetInBuffer: 10 + ) + ) + } + + @Test func midBlockquoteSplits() { + // "> hel|lo" — split mid-body. Continuation inserted at cursor, tail + // "lo" carries down to the new line after the marker. + let buffer = "> hello" + #expect( + BlockquoteContinuation.enterOutcome(buffer: buffer, cursorOffset: 5) + == .replace( + range: NSRange(location: 5, length: 0), + replacement: "\n> ", + cursorOffsetInBuffer: 8 + ) + ) + } + + @Test func nestedBlockquoteContinuationPreservesDepth() { + // ">> hello" — Enter continues with ">> ". + let buffer = ">> hello" + #expect( + BlockquoteContinuation.enterOutcome(buffer: buffer, cursorOffset: 8) + == .replace( + range: NSRange(location: 8, length: 0), + replacement: "\n>> ", + cursorOffsetInBuffer: 12 + ) + ) + } + + // MARK: - Enter on empty blockquote (exit one level) + + @Test func emptyBlockquoteAtBufferStartStripsPrefix() { + // "> " — Enter strips the entire prefix. + let buffer = "> " + #expect( + BlockquoteContinuation.enterOutcome(buffer: buffer, cursorOffset: 2) + == .replace( + range: NSRange(location: 0, length: 2), + replacement: "", + cursorOffsetInBuffer: 0 + ) + ) + } + + @Test func emptyBlockquoteAfterContentStripsPrefix() { + // "> foo\n> |" — Enter strips just the prefix of the empty line. + let buffer = "> foo\n> " + #expect( + BlockquoteContinuation.enterOutcome(buffer: buffer, cursorOffset: 8) + == .replace( + range: NSRange(location: 6, length: 2), + replacement: "", + cursorOffsetInBuffer: 6 + ) + ) + } + + @Test func emptyNestedBlockquoteStripsOneLevelOnly() { + // ">> " — Enter strips one level, leaving "> " (Spec §9.6). + let buffer = ">> " + #expect( + BlockquoteContinuation.enterOutcome(buffer: buffer, cursorOffset: 3) + == .replace( + range: NSRange(location: 0, length: 3), + replacement: "> ", + cursorOffsetInBuffer: 2 + ) + ) + } + + // MARK: - Enter falls through + + @Test func cursorBeforeMarkerIsNoOp() { + #expect( + BlockquoteContinuation.enterOutcome(buffer: "> foo", cursorOffset: 0) + == .noOp + ) + } + + @Test func plainTextLineIsNoOp() { + #expect( + BlockquoteContinuation.enterOutcome(buffer: "hello", cursorOffset: 5) + == .noOp + ) + } + + @Test func emptyBufferIsNoOp() { + #expect( + BlockquoteContinuation.enterOutcome(buffer: "", cursorOffset: 0) + == .noOp + ) + } + + // MARK: - Backspace on empty blockquote (atomic) + + @Test func backspaceAtEndOfEmptyBlockquoteAtBufferStartRemovesPrefix() { + // "> " — Backspace atomically deletes the marker. + #expect( + BlockquoteContinuation.backspaceOutcome(buffer: "> ", cursorOffset: 2) + == .replace( + range: NSRange(location: 0, length: 2), + cursorOffsetInBuffer: 0 + ) + ) + } + + @Test func backspaceAtEndOfEmptyBlockquoteAfterContentEatsPrecedingNewline() { + // "line1\n> " — Backspace eats `\n` + `> `. + #expect( + BlockquoteContinuation.backspaceOutcome(buffer: "line1\n> ", cursorOffset: 8) + == .replace( + range: NSRange(location: 5, length: 3), + cursorOffsetInBuffer: 5 + ) + ) + } + + // MARK: - Backspace standard cases + + @Test func backspaceOnNonEmptyBlockquoteBodyIsStandard() { + #expect( + BlockquoteContinuation.backspaceOutcome(buffer: "> foo", cursorOffset: 5) + == .standard + ) + } + + @Test func backspaceOnPlainLineIsStandard() { + #expect( + BlockquoteContinuation.backspaceOutcome(buffer: "hello", cursorOffset: 5) + == .standard + ) + } +} From 9f8d9b740d4341ed08e36cb2f6a4f66771e17fe7 Mon Sep 17 00:00:00 2001 From: luctst Date: Tue, 9 Jun 2026 10:40:53 +0200 Subject: [PATCH 5/9] feat: add heading backspace helper --- .../MarcdownStyling/HeadingContinuation.swift | 65 ++++++++++ .../HeadingBackspaceTests.swift | 120 ++++++++++++++++++ 2 files changed, 185 insertions(+) create mode 100644 Packages/MarcdownStyling/Sources/MarcdownStyling/HeadingContinuation.swift create mode 100644 Packages/MarcdownStyling/Tests/MarcdownStylingTests/HeadingBackspaceTests.swift diff --git a/Packages/MarcdownStyling/Sources/MarcdownStyling/HeadingContinuation.swift b/Packages/MarcdownStyling/Sources/MarcdownStyling/HeadingContinuation.swift new file mode 100644 index 0000000..7c39f4f --- /dev/null +++ b/Packages/MarcdownStyling/Sources/MarcdownStyling/HeadingContinuation.swift @@ -0,0 +1,65 @@ +import Foundation + +/// Outcome of pressing Backspace on a buffer that may contain an ATX heading +/// marker. Spec §8.2 / acceptance #9: pressing Backspace at the end of an +/// empty heading line (`# |`) strips the marker, leaving an empty plain line. +public enum HeadingBackspaceOutcome: Sendable, Equatable { + case standard + case replace(range: NSRange, cursorOffsetInBuffer: Int) +} + +/// Pure helper for ATX heading marker handling on Backspace. All other +/// keystrokes (Enter, character insertion) on heading lines remain the +/// AppKit default — they already produce the right behaviour. +public enum HeadingContinuation { + + /// If the cursor sits at the end of an ATX marker (`#`…`# `) with no + /// body content, atomically strip the marker. Otherwise return `.standard`. + public static func backspaceOutcome(buffer: String, cursorOffset: Int) -> HeadingBackspaceOutcome { + let units = Array(buffer.utf16) + let length = units.count + guard cursorOffset >= 0, cursorOffset <= length else { return .standard } + + let (lineStart, lineEnd) = lineRange(units: units, cursor: cursorOffset) + let lineLength = lineEnd - lineStart + guard lineLength > 0 else { return .standard } + + // Scan an ATX marker: 1-6 leading `#` chars + a single trailing space. + var hashCount = 0 + var probe = lineStart + while probe < lineEnd, hashCount < 6, units[probe] == 0x23 { + hashCount += 1 + probe += 1 + } + guard hashCount >= 1 else { return .standard } + // Trailing space required for an empty heading we'd strip. + guard probe < lineEnd, units[probe] == 0x20 else { return .standard } + probe += 1 + + let markerLength = probe - lineStart + // Atomic strip only when cursor is right after the marker AND the + // line ends there. + guard cursorOffset - lineStart == markerLength, lineLength == markerLength else { + return .standard + } + + if lineStart > 0 { + let range = NSRange(location: lineStart - 1, length: lineEnd - lineStart + 1) + return .replace(range: range, cursorOffsetInBuffer: lineStart - 1) + } + let range = NSRange(location: 0, length: lineEnd) + return .replace(range: range, cursorOffsetInBuffer: 0) + } + + private static func lineRange(units: [UInt16], cursor: Int) -> (Int, Int) { + var lineStart = cursor + while lineStart > 0, units[lineStart - 1] != 0x0A { + lineStart -= 1 + } + var lineEnd = cursor + while lineEnd < units.count, units[lineEnd] != 0x0A { + lineEnd += 1 + } + return (lineStart, lineEnd) + } +} diff --git a/Packages/MarcdownStyling/Tests/MarcdownStylingTests/HeadingBackspaceTests.swift b/Packages/MarcdownStyling/Tests/MarcdownStylingTests/HeadingBackspaceTests.swift new file mode 100644 index 0000000..96f4b10 --- /dev/null +++ b/Packages/MarcdownStyling/Tests/MarcdownStylingTests/HeadingBackspaceTests.swift @@ -0,0 +1,120 @@ +import Foundation +import Testing + +@testable import MarcdownStyling + +// MARK: - Expected production API (to be implemented by guy) +// +// Spec §8.2: "Backspace at the start of an empty heading line (`# |`) should +// remove the marker characters and leave an empty plain line — analogous to +// exiting an empty list item." +// +// A pure helper mirrors the shape of the other continuation helpers so the +// editor coordinator can compose them without per-helper conditionals. +// +// public enum HeadingBackspaceOutcome: Sendable, Equatable { +// case standard +// case replace(range: NSRange, cursorOffsetInBuffer: Int) +// } +// +// public enum HeadingContinuation { +// /// Atomic strip of an empty ATX heading marker when Backspace is hit +// /// at the marker end. All other positions return `.standard`. +// public static func backspaceOutcome(buffer: String, cursorOffset: Int) -> HeadingBackspaceOutcome +// } + +@Suite("HeadingContinuation backspace") +struct HeadingBackspaceTests { + + // MARK: - Empty heading at marker end + + @Test func backspaceAtEndOfEmptyH1AtBufferStartStripsMarker() { + // "# " (cursor at offset 2) — Backspace strips the marker. Cursor lands + // at the start of the (now empty plain) line. Atomic delete also eats + // the preceding `\n` if one exists, mirroring the list helpers. + #expect( + HeadingContinuation.backspaceOutcome(buffer: "# ", cursorOffset: 2) + == .replace( + range: NSRange(location: 0, length: 2), + cursorOffsetInBuffer: 0 + ) + ) + } + + @Test func backspaceAtEndOfEmptyH2AtBufferStartStripsMarker() { + #expect( + HeadingContinuation.backspaceOutcome(buffer: "## ", cursorOffset: 3) + == .replace( + range: NSRange(location: 0, length: 3), + cursorOffsetInBuffer: 0 + ) + ) + } + + @Test func backspaceAtEndOfEmptyH6AtBufferStartStripsMarker() { + // "###### " — 7 chars total. + #expect( + HeadingContinuation.backspaceOutcome(buffer: "###### ", cursorOffset: 7) + == .replace( + range: NSRange(location: 0, length: 7), + cursorOffsetInBuffer: 0 + ) + ) + } + + @Test func backspaceAtEndOfEmptyHeadingAfterContentEatsPrecedingNewline() { + // "hello\n# " — Backspace at offset 8 eats `\n# ` (length 3). + #expect( + HeadingContinuation.backspaceOutcome(buffer: "hello\n# ", cursorOffset: 8) + == .replace( + range: NSRange(location: 5, length: 3), + cursorOffsetInBuffer: 5 + ) + ) + } + + // MARK: - Non-empty heading → standard + + @Test func backspaceOnNonEmptyHeadingBodyIsStandard() { + // Cursor at end of "# Hello" — standard char delete (deletes 'o'). + #expect( + HeadingContinuation.backspaceOutcome(buffer: "# Hello", cursorOffset: 7) + == .standard + ) + } + + @Test func backspaceAtBodyStartOfNonEmptyHeadingIsStandard() { + // Cursor right after "# " on a non-empty heading deletes one char of + // the marker (the trailing space), per AppKit. Helper must not + // hijack — this is the "user wants to demote" path. + #expect( + HeadingContinuation.backspaceOutcome(buffer: "# Hello", cursorOffset: 2) + == .standard + ) + } + + // MARK: - Non-headings + + @Test func backspaceOnPlainLineIsStandard() { + #expect( + HeadingContinuation.backspaceOutcome(buffer: "hello", cursorOffset: 5) + == .standard + ) + } + + @Test func backspaceOnBulletLineIsStandard() { + // Bullet lines are handled by `ListContinuation`; the heading helper + // must not touch them. + #expect( + HeadingContinuation.backspaceOutcome(buffer: "- foo", cursorOffset: 5) + == .standard + ) + } + + @Test func backspaceOnEmptyBufferIsStandard() { + #expect( + HeadingContinuation.backspaceOutcome(buffer: "", cursorOffset: 0) + == .standard + ) + } +} From 65a282ae36cdfbdacba9385f1b226b2e19c6a4a0 Mon Sep 17 00:00:00 2001 From: luctst Date: Tue, 9 Jun 2026 10:41:01 +0200 Subject: [PATCH 6/9] feat: add focus-line conceal reveal --- .../Sources/MarcdownStyling/FocusLine.swift | 21 ++ .../MarcdownStyling/MarkdownStyler.swift | 67 +++++- .../Sources/MarcdownStyling/StyleWalker.swift | 5 +- .../FocusLineRevealTests.swift | 197 ++++++++++++++++++ 4 files changed, 284 insertions(+), 6 deletions(-) create mode 100644 Packages/MarcdownStyling/Sources/MarcdownStyling/FocusLine.swift create mode 100644 Packages/MarcdownStyling/Tests/MarcdownStylingTests/FocusLineRevealTests.swift diff --git a/Packages/MarcdownStyling/Sources/MarcdownStyling/FocusLine.swift b/Packages/MarcdownStyling/Sources/MarcdownStyling/FocusLine.swift new file mode 100644 index 0000000..2570c6b --- /dev/null +++ b/Packages/MarcdownStyling/Sources/MarcdownStyling/FocusLine.swift @@ -0,0 +1,21 @@ +import Foundation + +/// A single focused line in the editor — the line containing the cursor +/// (or one of the lines spanned by the active selection). +/// +/// When passed to `MarkdownStyler.restyle(storage:source:focusLine:)`, the +/// styler removes `.marcdownConcealed` from any character in the line and +/// repaints those characters with the dim theme color (Spec §3 / acceptance +/// #1). It also strips `.marcdownListMarker` / `.marcdownCheckbox` tags on +/// the focused line so the layout-manager overlays defer to the raw text. +public struct FocusLine: Sendable, Equatable { + /// UTF-16 offset of the start of the focused line in the source string. + public let lineStart: Int + /// UTF-16 length of the line content, excluding any trailing `\n`. + public let lineLength: Int + + public init(lineStart: Int, lineLength: Int) { + self.lineStart = lineStart + self.lineLength = lineLength + } +} diff --git a/Packages/MarcdownStyling/Sources/MarcdownStyling/MarkdownStyler.swift b/Packages/MarcdownStyling/Sources/MarcdownStyling/MarkdownStyler.swift index 4965b6f..379052e 100644 --- a/Packages/MarcdownStyling/Sources/MarcdownStyling/MarkdownStyler.swift +++ b/Packages/MarcdownStyling/Sources/MarcdownStyling/MarkdownStyler.swift @@ -39,9 +39,13 @@ public final class MarkdownStyler { /// Wrapped in `beginEditing()`/`endEditing()` so layout is updated once and /// the cursor / selection is preserved. Only attributes are mutated; the /// character contents are never touched. + /// + /// If `focusLine` is non-nil, the styler runs an additional pass that + /// reveals concealed syntax on that line (Spec §3 — focus-line reveal). public func restyle( storage: NSTextStorage, - source: String + source: String, + focusLine: FocusLine? = nil ) { let document = Document(parsing: source) let index = LineOffsetIndex(source: source) @@ -56,6 +60,7 @@ public final class MarkdownStyler { // that switches to `addAttributes` still strips them. if fullRange.length > 0 { storage.removeAttribute(.marcdownConcealed, range: fullRange) + storage.removeAttribute(.marcdownConcealedLogical, range: fullRange) storage.removeAttribute(.marcdownCheckbox, range: fullRange) storage.removeAttribute(.marcdownListMarker, range: fullRange) storage.removeAttribute(.marcdownCodeBlock, range: fullRange) @@ -80,9 +85,60 @@ public final class MarkdownStyler { // the AST walk so any AST-applied attributes get overwritten. applyListScannerPass(storage: storage, source: source) + // Focus-line reveal: last pass so it can override conceal/clear-paint + // attributes set by the walker and the scanner passes. + if let focusLine { + applyFocusReveal(storage: storage, focusLine: focusLine) + } + storage.endEditing() } + /// Reveal all concealed syntax (and bullet/checkbox overlay tags) on the + /// focused line. The styler's previous passes have already applied + /// concealment / clear-paint / overlay tags; this pass strips them on the + /// focus line so the underlying characters become visible (dim) instead. + /// + /// Spec §3 / Thomas §1: the cursor lands on a line, the user expects to + /// see the raw markdown that produced the rendered output, then it + /// vanishes again when the cursor leaves. + private func applyFocusReveal(storage: NSTextStorage, focusLine: FocusLine) { + let storageLength = storage.length + let lower = max(0, min(focusLine.lineStart, storageLength)) + let upper = max(lower, min(focusLine.lineStart + focusLine.lineLength, storageLength)) + guard upper > lower else { return } + let range = NSRange(location: lower, length: upper - lower) + + // Strip the overlay-trigger tags so the layout manager does NOT draw + // a bullet circle / number / checkbox icon on this line. + storage.removeAttribute(.marcdownListMarker, range: range) + storage.removeAttribute(.marcdownCheckbox, range: range) + + // Strip concealment and repaint any previously concealed OR + // clear-painted run in the dim theme color so the raw syntax becomes + // visible. + storage.enumerateAttribute( + .marcdownConcealed, + in: range, + options: [] + ) { value, subrange, _ in + guard (value as? Bool) == true else { return } + storage.removeAttribute(.marcdownConcealed, range: subrange) + storage.addAttribute(.foregroundColor, value: theme.dim, range: subrange) + } + + // Flip clear-painted glyphs (bullet/ordered/checkbox markers) to dim + // so the raw `- ` / `1. ` / `- [ ]` becomes legible. + storage.enumerateAttribute( + .foregroundColor, + in: range, + options: [] + ) { value, subrange, _ in + guard let color = value as? NSColor, color == .clear else { return } + storage.addAttribute(.foregroundColor, value: theme.dim, range: subrange) + } + } + /// Walks `source` line-by-line, classifying each via `CheckboxLineScanner`, /// and tags `.marcdownConcealed` / `.marcdownCheckbox` accordingly. This /// is the only writer of `.marcdownCheckbox` in the entire pipeline. @@ -255,7 +311,7 @@ public final class MarkdownStyler { applyParagraphSpacing(storage: storage, lineStart: lineStart, lineLength: lineLength) case .complete(let indentLength, let markerLength, let kind): switch kind { - case .bullet: + case .bullet(let depth): // Marker layout is `` (exactly 2 chars). let markerRange = NSRange( location: lineStart + indentLength, @@ -282,12 +338,12 @@ public final class MarkdownStyler { // the bullet glyph. storage.addAttribute( .marcdownListMarker, - value: MarcdownListMarkerKind.bullet, + value: MarcdownListMarkerKind.bullet(depth: depth), range: markerRange ) applyParagraphSpacing(storage: storage, lineStart: lineStart, lineLength: lineLength) - case .ordered(let number): + case .ordered(let number, let depth): // Marker layout is `.` — markerLength = digits + 2. let digitCount = markerLength - 2 guard digitCount > 0 else { return } @@ -316,7 +372,7 @@ public final class MarkdownStyler { // Tag the full marker range with the ordered kind. storage.addAttribute( .marcdownListMarker, - value: MarcdownListMarkerKind.ordered(number: number), + value: MarcdownListMarkerKind.ordered(number: number, depth: depth), range: markerRange ) @@ -332,6 +388,7 @@ public final class MarkdownStyler { guard upper > lower else { return } let clamped = NSRange(location: lower, length: upper - lower) storage.addAttribute(.marcdownConcealed, value: true, range: clamped) + storage.addAttribute(.marcdownConcealedLogical, value: true, range: clamped) } private func applyParagraphSpacing( diff --git a/Packages/MarcdownStyling/Sources/MarcdownStyling/StyleWalker.swift b/Packages/MarcdownStyling/Sources/MarcdownStyling/StyleWalker.swift index 3c59505..e180056 100644 --- a/Packages/MarcdownStyling/Sources/MarcdownStyling/StyleWalker.swift +++ b/Packages/MarcdownStyling/Sources/MarcdownStyling/StyleWalker.swift @@ -493,11 +493,14 @@ struct StyleWalker: @preconcurrency MarkupWalker { } /// Tags `subrange` with `.marcdownConcealed = true` so the editor's - /// layout delegate will suppress the corresponding glyphs. + /// layout delegate will suppress the corresponding glyphs. Also tags + /// `.marcdownConcealedLogical` for the arrow-jump helper (see + /// `NSAttributedString.Key.marcdownConcealedLogical`). private func applyConceal(to subrange: NSRange) { let clamped = clampedToStorage(subrange) guard clamped.length > 0 else { return } storage.addAttribute(.marcdownConcealed, value: true, range: clamped) + storage.addAttribute(.marcdownConcealedLogical, value: true, range: clamped) } private func clampedToStorage(_ range: NSRange) -> NSRange { diff --git a/Packages/MarcdownStyling/Tests/MarcdownStylingTests/FocusLineRevealTests.swift b/Packages/MarcdownStyling/Tests/MarcdownStylingTests/FocusLineRevealTests.swift new file mode 100644 index 0000000..d1370f5 --- /dev/null +++ b/Packages/MarcdownStyling/Tests/MarcdownStylingTests/FocusLineRevealTests.swift @@ -0,0 +1,197 @@ +import AppKit +import Foundation +import Testing + +@testable import MarcdownStyling + +// MARK: - Expected production API (to be implemented by guy) +// +// Focus-line reveal is the headline feature (Spec §3, Thomas §1). When the +// cursor sits on a line, all `.marcdownConcealed` ranges on that line must +// be revealed: concealment cleared and the dim foreground color applied so +// the syntax becomes visible (but visually dim) instead of zero-width. +// +// The styler must accept a "current focus line" parameter and apply the +// reveal during its restyle pass: +// +// public final class MarkdownStyler { +// public func restyle( +// storage: NSTextStorage, +// source: String, +// focusLine: FocusLine? = nil +// ) +// } +// +// public struct FocusLine: Sendable, Equatable { +// /// UTF-16 offset of the start of the focused line in `source`. +// public let lineStart: Int +// /// UTF-16 length of the focused line content (excluding any trailing `\n`). +// public let lineLength: Int +// public init(lineStart: Int, lineLength: Int) +// } +// +// Reveal contract: +// - All characters in the focus line that previously carried +// `.marcdownConcealed = true` have the attribute removed. +// - Those same characters get `.foregroundColor = theme.dim` (so they appear +// as dim text rather than zero-width glyphs). +// - Non-focus lines retain their existing conceal/clear-paint treatment. +// - Block markers that are already dim (blockquote `>`, fence ```) are not +// affected by reveal (they are not concealed; reveal is a no-op for them). +// - Bullet / ordered / task markers (clear-painted, not concealed) ALSO +// reveal when the cursor sits on them: foreground flips from `.clear` +// to `theme.dim` and the `.marcdownListMarker` / `.marcdownCheckbox` +// tags are REMOVED so the layout manager skips its overlay. +// +// The test selection-extension case below is the "either end of selection" +// rule from §3.1; for now a single focus line is enough — guy can extend to +// a selection range later. + +@MainActor +@Suite("Focus-line reveal") +struct FocusLineRevealTests { + + private func restyle(_ source: String, focus: FocusLine? = nil) -> NSTextStorage { + let styler = MarkdownStyler() + let storage = NSTextStorage(string: source) + styler.restyle(storage: storage, source: source, focusLine: focus) + return storage + } + + // MARK: - Inline conceal: bold delimiters + + @Test func boldDelimitersConcealedWhenCursorOnDifferentLine() { + // No focus → existing behaviour: `**` concealed. + let storage = restyle("**foo**") + let leftConcealed = storage.attribute(.marcdownConcealed, at: 0, effectiveRange: nil) as? Bool + let rightConcealed = storage.attribute(.marcdownConcealed, at: 5, effectiveRange: nil) as? Bool + #expect(leftConcealed == true) + #expect(rightConcealed == true) + } + + @Test func boldDelimitersRevealedWhenCursorOnSameLine() { + // Focus on the only line — `**` reveals. + let storage = restyle("**foo**", focus: FocusLine(lineStart: 0, lineLength: 7)) + // Concealment must be cleared on the delimiter characters. + for index in [0, 1, 5, 6] { + let concealed = storage.attribute(.marcdownConcealed, at: index, effectiveRange: nil) as? Bool + #expect(concealed != true, "expected `**` at \(index) to be revealed") + } + // And the foreground must be the dim color. + let dim = StylingTheme.system.dim + let leftColor = storage.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? NSColor + let rightColor = storage.attribute(.foregroundColor, at: 6, effectiveRange: nil) as? NSColor + #expect(leftColor == dim) + #expect(rightColor == dim) + } + + @Test func boldBodyTextIsNotDimmedWhenLineFocused() { + // Focus must dim only the syntax characters, not the body. + let storage = restyle("**foo**", focus: FocusLine(lineStart: 0, lineLength: 7)) + let dim = StylingTheme.system.dim + for index in 2...4 { + let color = storage.attribute(.foregroundColor, at: index, effectiveRange: nil) as? NSColor + #expect(color != dim, "expected body char at \(index) to stay body-coloured, not dim") + } + } + + // MARK: - Multi-line: only the focus line reveals + + @Test func nonFocusLinesKeepConcealment() { + // Two lines, both bold; focus on the first one only. + let source = "**foo**\n**bar**" + let storage = restyle(source, focus: FocusLine(lineStart: 0, lineLength: 7)) + + // Focus line: revealed. + let focusLeftConcealed = storage.attribute(.marcdownConcealed, at: 0, effectiveRange: nil) as? Bool + #expect(focusLeftConcealed != true) + + // Non-focus line offsets: "\n" at 7, "**" starts at 8. + let otherLeftConcealed = storage.attribute(.marcdownConcealed, at: 8, effectiveRange: nil) as? Bool + let otherRightConcealed = storage.attribute(.marcdownConcealed, at: 13, effectiveRange: nil) as? Bool + #expect(otherLeftConcealed == true) + #expect(otherRightConcealed == true) + } + + // MARK: - Heading marker reveal + + @Test func headingMarkerRevealedWhenFocused() { + let storage = restyle("# Hello", focus: FocusLine(lineStart: 0, lineLength: 7)) + let dim = StylingTheme.system.dim + // "# " at offsets 0..<2 — concealed when off-line, dim when focused. + for index in 0...1 { + let concealed = storage.attribute(.marcdownConcealed, at: index, effectiveRange: nil) as? Bool + #expect(concealed != true, "expected `# ` at \(index) to be revealed") + let color = storage.attribute(.foregroundColor, at: index, effectiveRange: nil) as? NSColor + #expect(color == dim, "expected `# ` at \(index) to be dim-coloured") + } + } + + @Test func headingMarkerConcealedWhenNotFocused() { + // Multi-line buffer with heading on line 2; focus on line 1. + let source = "first\n# Hello" + let storage = restyle(source, focus: FocusLine(lineStart: 0, lineLength: 5)) + // Heading marker at offsets 6..<8. + let leftConcealed = storage.attribute(.marcdownConcealed, at: 6, effectiveRange: nil) as? Bool + #expect(leftConcealed == true) + } + + // MARK: - Bullet marker reveal — overlay tag removed + + @Test func bulletMarkerLosesListMarkerTagWhenFocused() { + // Spec §13: when focused, the bullet icon is NOT drawn — the raw `- ` + // is shown dim instead. The layout manager keys off + // `.marcdownListMarker`, so removing the tag is how we tell it to + // skip the overlay. + let storage = restyle("- foo", focus: FocusLine(lineStart: 0, lineLength: 5)) + let marker = storage.attribute(.marcdownListMarker, at: 0, effectiveRange: nil) + #expect(marker == nil, "expected list-marker tag to be stripped on focused line") + } + + @Test func bulletMarkerKeepsTagWhenNotFocused() { + // Two lines, both bullets; focus on line 2. + let source = "- foo\n- bar" + let storage = restyle(source, focus: FocusLine(lineStart: 6, lineLength: 5)) + // First line: tag intact. + let marker = storage.attribute(.marcdownListMarker, at: 0, effectiveRange: nil) as? MarcdownListMarkerKind + #expect(marker == .bullet) + } + + @Test func bulletMarkerForegroundFlipsFromClearToDimWhenFocused() { + let storage = restyle("- foo", focus: FocusLine(lineStart: 0, lineLength: 5)) + let dim = StylingTheme.system.dim + for index in 0...1 { + let color = storage.attribute(.foregroundColor, at: index, effectiveRange: nil) as? NSColor + #expect(color == dim, "expected `- ` at \(index) to be dim, not clear, when focused") + } + } + + // MARK: - Checkbox marker reveal — checkbox tag removed + + @Test func checkboxMarkerLosesTagWhenFocused() { + // When focused, the checkbox icon is NOT drawn; the raw `- [ ]` is + // shown dim instead. Tag removal disables the overlay. + let storage = restyle("- [ ] task", focus: FocusLine(lineStart: 0, lineLength: 10)) + // Tag would normally be at offset 2 (the `[`). + let state = storage.attribute(.marcdownCheckbox, at: 2, effectiveRange: nil) + #expect(state == nil, "expected checkbox state tag to be stripped on focused line") + } + + // MARK: - Link reveal + + @Test func linkSyntaxRevealedWhenFocused() { + // "[a](b)" — without focus, `[`, `](b)` concealed and label "a" styled + // as link. With focus, the whole syntax should be visible (dim) but + // the label still wears the link color/underline. + let source = "[a](b)" + let storage = restyle(source, focus: FocusLine(lineStart: 0, lineLength: 6)) + // Opening `[` at offset 0 — revealed. + let leftBracketConcealed = storage.attribute(.marcdownConcealed, at: 0, effectiveRange: nil) as? Bool + #expect(leftBracketConcealed != true) + // Closing `](b)` at offsets 2..<6 — revealed. + for index in 2..<6 { + let concealed = storage.attribute(.marcdownConcealed, at: index, effectiveRange: nil) as? Bool + #expect(concealed != true, "expected link syntax at \(index) to be revealed") + } + } +} From c49eb9b28a261fda90eb53b1fb8670a3bde16264 Mon Sep 17 00:00:00 2001 From: luctst Date: Tue, 9 Jun 2026 10:41:03 +0200 Subject: [PATCH 7/9] feat: refine list continuation exit and join behavior --- .../MarcdownStyling/ListContinuation.swift | 40 ++++-- .../ListExitTrailingBlankLineTests.swift | 73 +++++++++++ .../MarcdownStylingTests/ListJoinTests.swift | 120 ++++++++++++++++++ 3 files changed, 225 insertions(+), 8 deletions(-) create mode 100644 Packages/MarcdownStyling/Tests/MarcdownStylingTests/ListExitTrailingBlankLineTests.swift create mode 100644 Packages/MarcdownStyling/Tests/MarcdownStylingTests/ListJoinTests.swift diff --git a/Packages/MarcdownStyling/Sources/MarcdownStyling/ListContinuation.swift b/Packages/MarcdownStyling/Sources/MarcdownStyling/ListContinuation.swift index 87dcf9b..b83c4a6 100644 --- a/Packages/MarcdownStyling/Sources/MarcdownStyling/ListContinuation.swift +++ b/Packages/MarcdownStyling/Sources/MarcdownStyling/ListContinuation.swift @@ -72,7 +72,18 @@ public enum ListContinuation { // Empty body → exit the list. Strip indent + marker entirely and // land the cursor on a blank paragraph (same shape as the // empty-task branch in `TaskListContinuation`). + // + // Spec §4.3 / acceptance #6: if any content precedes this line, + // also insert a blank-line separator (CommonMark requires it for + // subsequent text not to be parsed as part of the list). if bodyLength <= 0 { + if lineStart > 0 { + return .replace( + range: NSRange(location: lineStart, length: bodyStart), + replacement: "\n", + cursorOffsetInBuffer: lineStart + 1 + ) + } return .replace( range: NSRange(location: lineStart, length: bodyStart), replacement: "", @@ -94,7 +105,7 @@ public enum ListContinuation { let markerCharUnit = units[lineStart + indentLength] let markerChar = Character(Unicode.Scalar(markerCharUnit)!) continuation = "\n" + indent + String(markerChar) + " " - case .ordered(let number): + case .ordered(let number, _): continuation = "\n" + indent + "\(number + 1). " } @@ -145,14 +156,27 @@ public enum ListContinuation { return atomicDelete(lineStart: lineStart, lineEnd: lineEnd) case .complete(let indentLength, let markerLength, _): - // Atomic delete only when cursor is at the end of an empty-body - // marker line. + // Atomic delete when cursor is at the end of an empty-body marker. let bodyStart = indentLength + markerLength - guard - cursorOffsetInLine == bodyStart, - lineLength == bodyStart - else { return .standard } - return atomicDelete(lineStart: lineStart, lineEnd: lineEnd) + if cursorOffsetInLine == bodyStart, lineLength == bodyStart { + return atomicDelete(lineStart: lineStart, lineEnd: lineEnd) + } + + // Thomas §8 push-back / acceptance: cursor at marker-start + // (right before the `-` / digit) on a non-first line joins the + // current line into the previous line and strips the indent + + // marker. Otherwise we'd leave a stray `- ` mid-paragraph. + if cursorOffsetInLine == indentLength, lineStart > 0 { + // Delete `\n` + indent + marker (length: 1 + indentLength + markerLength). + let deleteStart = lineStart - 1 + let deleteLength = 1 + indentLength + markerLength + return .replace( + range: NSRange(location: deleteStart, length: deleteLength), + cursorOffsetInBuffer: deleteStart + ) + } + + return .standard } } diff --git a/Packages/MarcdownStyling/Tests/MarcdownStylingTests/ListExitTrailingBlankLineTests.swift b/Packages/MarcdownStyling/Tests/MarcdownStylingTests/ListExitTrailingBlankLineTests.swift new file mode 100644 index 0000000..9f3c626 --- /dev/null +++ b/Packages/MarcdownStyling/Tests/MarcdownStylingTests/ListExitTrailingBlankLineTests.swift @@ -0,0 +1,73 @@ +import Foundation +import Testing + +@testable import MarcdownStyling + +// Spec §4.3 / §20 acceptance #6: +// Pressing Enter on an empty list-item line strips the marker AND inserts +// a trailing blank line so subsequent text does not fuse into the list per +// CommonMark. Today `ListContinuation.enterOutcome` strips the marker but +// does not emit the blank line. These tests pin the future contract. +// +// This is the single behavioural change to existing helpers in this slice. +// `ListContinuationTests` already covers the strip-only behaviour; once guy +// adds the blank line, the tests there will need an update. We intentionally +// keep this regression in a separate suite so the diff is easy to review. +// +// Expected outcome shape (already in `ListContinuation.swift`): +// .replace(range: , replacement: "\n", cursorOffsetInBuffer: ) +// +// The replacement gains a `\n` so the line becomes blank AND a blank line +// follows. Cursor lands at the start of the blank line that follows. + +@Suite("List exit inserts trailing blank line") +struct ListExitTrailingBlankLineTests { + + @Test func emptyBulletAfterContentEmitsBlankLineSeparator() { + // Before: "- foo\n- |" (cursor at 8) + // After: "- foo\n\n|" (the empty marker line becomes blank, plus + // an extra `\n` so a blank line follows) + // The strip range is the marker `- ` at line offsets [6..<8], and the + // replacement is `\n` (a single newline) so the line collapses and a + // separator newline is inserted in its place. Cursor lands at offset 7. + let buffer = "- foo\n- " + #expect( + ListContinuation.enterOutcome(buffer: buffer, cursorOffset: 8) + == .replace( + range: NSRange(location: 6, length: 2), + replacement: "\n", + cursorOffsetInBuffer: 7 + ) + ) + } + + @Test func emptyOrderedAfterContentEmitsBlankLineSeparator() { + // Before: "1. foo\n2. |" (cursor at 10) + // After: "1. foo\n\n|" + let buffer = "1. foo\n2. " + #expect( + ListContinuation.enterOutcome(buffer: buffer, cursorOffset: 10) + == .replace( + range: NSRange(location: 7, length: 3), + replacement: "\n", + cursorOffsetInBuffer: 8 + ) + ) + } + + @Test func emptyBulletAtBufferStartStillStripsWithoutLeadingNewline() { + // No preceding content → no preceding `\n` → no blank line needed, + // the marker simply strips. The cursor lands at offset 0. + // (CommonMark's blank-line requirement is for separating consecutive + // blocks; with no block above there's nothing to separate from.) + let buffer = "- " + #expect( + ListContinuation.enterOutcome(buffer: buffer, cursorOffset: 2) + == .replace( + range: NSRange(location: 0, length: 2), + replacement: "", + cursorOffsetInBuffer: 0 + ) + ) + } +} diff --git a/Packages/MarcdownStyling/Tests/MarcdownStylingTests/ListJoinTests.swift b/Packages/MarcdownStyling/Tests/MarcdownStylingTests/ListJoinTests.swift new file mode 100644 index 0000000..a8d0036 --- /dev/null +++ b/Packages/MarcdownStyling/Tests/MarcdownStylingTests/ListJoinTests.swift @@ -0,0 +1,120 @@ +import Foundation +import Testing + +@testable import MarcdownStyling + +// Thomas §8 push-back: when Backspace joins a list line into the line above, +// the joined-in marker characters must be stripped — leaving them in the +// middle of the resulting line is a known-bad experience. +// +// MARK: - Expected production API (to be implemented by guy) +// +// The contract lives in `ListContinuation.backspaceOutcome`. We add a new +// trigger position: cursor at `lineStart + indentLength` (i.e. just before +// the marker character) on a complete list line, with the previous line +// being non-empty. The outcome is a replace that deletes: +// - the preceding `\n` +// - the leading indent of the current line +// - the marker characters (e.g. `- ` or `1. `) of the current line +// +// Cursor lands at the end of the previous line's content. +// +// Today's helper returns `.standard` from this position (it only triggers on +// empty-body lines and at-end-of-partial-marker). The new behaviour expands +// the trigger. + +@Suite("List item join via backspace strips joined marker") +struct ListJoinTests { + + @Test func backspaceAtMarkerStartOfBulletWithPreviousBulletStripsMarker() { + // Buffer: "- foo\n- bar" + // Offsets: 0 5 6 7 10 + // Cursor at start of "- bar" line's MARKER (offset 6). + // Pressing Backspace from this position joins the two list lines into + // "- foobar" (the second item's `- ` marker is dropped). + let buffer = "- foo\n- bar" + #expect( + ListContinuation.backspaceOutcome(buffer: buffer, cursorOffset: 6) + == .replace( + range: NSRange(location: 5, length: 3), // `\n- ` + cursorOffsetInBuffer: 5 + ) + ) + } + + @Test func backspaceAtMarkerStartOfOrderedWithPreviousOrderedStripsMarker() { + // Buffer: "1. foo\n2. bar" + // Offsets: 0 6 7 11 ... + // Cursor at start of "2. bar" marker (offset 7) → join. + let buffer = "1. foo\n2. bar" + #expect( + ListContinuation.backspaceOutcome(buffer: buffer, cursorOffset: 7) + == .replace( + range: NSRange(location: 6, length: 4), // `\n2. ` + cursorOffsetInBuffer: 6 + ) + ) + } + + @Test func backspaceAtMarkerStartOfIndentedBulletStripsIndentAndMarker() { + // Buffer: "- foo\n - bar" + // Offsets: 0 5 6 7 8 9 12 + // Cursor at start of the indented marker (offset 8 — just before `-`). + // The join strips the `\n`, the 2-space indent, and the marker `- `. + let buffer = "- foo\n - bar" + #expect( + ListContinuation.backspaceOutcome(buffer: buffer, cursorOffset: 8) + == .replace( + range: NSRange(location: 5, length: 5), // `\n - ` + cursorOffsetInBuffer: 5 + ) + ) + } + + @Test func backspaceAtMarkerStartWithPlainPreviousLineStillStripsMarker() { + // Even when the line above isn't a list line, the marker characters of + // the current list line should be stripped on join — otherwise the + // user gets a stray `- ` mid-paragraph. (Bear behaviour per Thomas.) + let buffer = "hello\n- bar" + #expect( + ListContinuation.backspaceOutcome(buffer: buffer, cursorOffset: 6) + == .replace( + range: NSRange(location: 5, length: 3), // `\n- ` + cursorOffsetInBuffer: 5 + ) + ) + } + + @Test func backspaceMidMarkerStaysStandard() { + // Cursor in the middle of the marker (offset 7 in "- foo\n- bar", + // which is between `-` and ` ` on the second line). This is NOT the + // join trigger; standard char delete runs. + let buffer = "- foo\n- bar" + #expect( + ListContinuation.backspaceOutcome(buffer: buffer, cursorOffset: 7) + == .standard + ) + } + + @Test func backspaceAtBodyStartOfNonEmptyBulletStaysStandard() { + // Cursor right after the marker (offset 8 = body start of "- bar"). + // Standard single-char delete (lands at offset 7, between marker char + // and space). The join only triggers AT the marker start. + let buffer = "- foo\n- bar" + #expect( + ListContinuation.backspaceOutcome(buffer: buffer, cursorOffset: 8) + == .standard + ) + } + + @Test func backspaceAtMarkerStartOfFirstLineIsStandard() { + // No preceding `\n` → cannot join → standard char delete. + // (Cursor at offset 0 of "- foo" — there is no previous line to merge + // into. AppKit's default delete-before-start is a no-op.) + let buffer = "- foo" + #expect( + ListContinuation.backspaceOutcome(buffer: buffer, cursorOffset: 0) + == .standard + ) + } +} From b29df9006892a54ad25b447f7035442c7a1ab2a8 Mon Sep 17 00:00:00 2001 From: luctst Date: Tue, 9 Jun 2026 10:41:05 +0200 Subject: [PATCH 8/9] feat: wire bear-style command routing in note editor --- .../MarcdownEditor/NoteEditorView.swift | 299 +++++++++++++++++- .../BlockquoteCommandTests.swift | 103 ++++++ .../ConcealedRunNavigationTests.swift | 157 +++++++++ .../HeadingBackspaceCommandTests.swift | 97 ++++++ .../ListIndentCommandTests.swift | 207 ++++++++++++ 5 files changed, 853 insertions(+), 10 deletions(-) create mode 100644 Packages/MarcdownEditor/Tests/MarcdownEditorTests/BlockquoteCommandTests.swift create mode 100644 Packages/MarcdownEditor/Tests/MarcdownEditorTests/ConcealedRunNavigationTests.swift create mode 100644 Packages/MarcdownEditor/Tests/MarcdownEditorTests/HeadingBackspaceCommandTests.swift create mode 100644 Packages/MarcdownEditor/Tests/MarcdownEditorTests/ListIndentCommandTests.swift diff --git a/Packages/MarcdownEditor/Sources/MarcdownEditor/NoteEditorView.swift b/Packages/MarcdownEditor/Sources/MarcdownEditor/NoteEditorView.swift index 346e181..ce7bccd 100644 --- a/Packages/MarcdownEditor/Sources/MarcdownEditor/NoteEditorView.swift +++ b/Packages/MarcdownEditor/Sources/MarcdownEditor/NoteEditorView.swift @@ -172,7 +172,11 @@ public struct NoteEditorView: NSViewRepresentable { func restyle() { guard let storage else { return } - styler.restyle(storage: storage, source: storage.string) + styler.restyle( + storage: storage, + source: storage.string, + focusLine: currentFocusLine() + ) } public func textDidChange(_ notification: Notification) { @@ -189,7 +193,11 @@ public struct NoteEditorView: NSViewRepresentable { // Restyle first so the attributed buffer is up to date, then // propagate the plain string to the binding for the view model's // debounced save to pick up. - styler.restyle(storage: storage, source: storage.string) + styler.restyle( + storage: storage, + source: storage.string, + focusLine: currentFocusLine() + ) text.wrappedValue = storage.string // Checkbox markers may have been added/removed by this edit; // refresh the pointing-hand hover rects so the cursor tracks @@ -197,6 +205,50 @@ public struct NoteEditorView: NSViewRepresentable { textView.window?.invalidateCursorRects(for: textView) } + public func textViewDidChangeSelection(_ notification: Notification) { + guard + let textView = notification.object as? NSTextView, + let storage = textView.textStorage + else { return } + // Skip restyle while the user is composing IME — the marked text + // is transient and styling would eat it. + if textView.hasMarkedText() { return } + // Focus-line reveal: restyle so the previously-focused line + // re-conceals and the newly-focused line reveals. + styler.restyle( + storage: storage, + source: storage.string, + focusLine: currentFocusLine() + ) + } + + /// Compute a `FocusLine` for the current selection in the active + /// text view. Multi-line selections currently focus only the line + /// containing the active end (caret). A single caret returns its + /// containing line. + private func currentFocusLine() -> FocusLine? { + guard let textView else { return nil } + guard let storage = textView.textStorage else { return nil } + let selection = textView.selectedRange() + // For a selection, the "active end" is the caret — use `location` + // for a simple caret and `location + length` (or `location`) for + // an extended selection. Picking `location` matches what most + // users perceive as the cursor. + let cursor = selection.location + let buffer = storage.string as NSString + let upper = buffer.length + guard cursor >= 0, cursor <= upper else { return nil } + var lineStart = cursor + while lineStart > 0, buffer.character(at: lineStart - 1) != 0x0A { + lineStart -= 1 + } + var lineEnd = cursor + while lineEnd < upper, buffer.character(at: lineEnd) != 0x0A { + lineEnd += 1 + } + return FocusLine(lineStart: lineStart, lineLength: lineEnd - lineStart) + } + // MARK: - Task list keystroke handling public func textView( @@ -206,6 +258,15 @@ public struct NoteEditorView: NSViewRepresentable { if selector == #selector(NSResponder.insertNewline(_:)) { return handleInsertNewline(in: textView) } + if selector == #selector(NSResponder.insertNewlineIgnoringFieldEditor(_:)) { + return handleShiftReturn(in: textView) + } + if selector == #selector(NSResponder.insertTab(_:)) { + return handleInsertTab(in: textView) + } + if selector == #selector(NSResponder.insertBacktab(_:)) { + return handleInsertBacktab(in: textView) + } if selector == #selector(NSResponder.deleteBackward(_:)) { return handleDeleteBackward(in: textView) } @@ -221,9 +282,166 @@ public struct NoteEditorView: NSViewRepresentable { if selector == #selector(NSResponder.moveToBeginningOfLineAndModifySelection(_:)) { return handleMoveToBeginningOfLine(in: textView, extend: true) } + if selector == #selector(NSResponder.moveLeft(_:)) { + return handleArrow(in: textView, direction: .left) + } + if selector == #selector(NSResponder.moveRight(_:)) { + return handleArrow(in: textView, direction: .right) + } return false } + // MARK: - Tab / Shift-Tab / Shift-Return + + private func handleInsertTab(in textView: NSTextView) -> Bool { + guard let storage = textView.textStorage else { return false } + if textView.hasMarkedText() { return false } + let selection = textView.selectedRange() + // Selection-based indent (multiple lines) — TODO. For now, only + // handle the caret case; with a selection, fall through. + if selection.length > 0 { return false } + let cursor = selection.location + switch ListIndentation.indentOutcome(buffer: storage.string, cursorOffset: cursor) { + case .noOp: + return false + case .replace(let range, let replacement, let cursorOffsetInBuffer): + guard textView.shouldChangeText(in: range, replacementString: replacement) else { + return false + } + textView.undoManager?.setActionName("Indent List Item") + storage.replaceCharacters(in: range, with: replacement) + textView.didChangeText() + textView.setSelectedRange(NSRange(location: cursorOffsetInBuffer, length: 0)) + // If the line is an ordered list item, the depth change may + // require renumbering both the new and old depths. + runRenumberPass(in: textView, around: cursorOffsetInBuffer) + return true + } + } + + private func handleInsertBacktab(in textView: NSTextView) -> Bool { + guard let storage = textView.textStorage else { return false } + if textView.hasMarkedText() { return false } + let selection = textView.selectedRange() + if selection.length > 0 { return false } + let cursor = selection.location + switch ListIndentation.outdentOutcome(buffer: storage.string, cursorOffset: cursor) { + case .noOp: + return false + case .replace(let range, let replacement, let cursorOffsetInBuffer): + guard textView.shouldChangeText(in: range, replacementString: replacement) else { + return false + } + textView.undoManager?.setActionName("Outdent List Item") + storage.replaceCharacters(in: range, with: replacement) + textView.didChangeText() + textView.setSelectedRange(NSRange(location: cursorOffsetInBuffer, length: 0)) + runRenumberPass(in: textView, around: cursorOffsetInBuffer) + return true + } + } + + /// Shift+Return: insert a plain `\n` without any block continuation. + /// Spec §4.10 / §5.7 / §9.5. + private func handleShiftReturn(in textView: NSTextView) -> Bool { + guard let storage = textView.textStorage else { return false } + if textView.hasMarkedText() { return false } + let selection = textView.selectedRange() + let range = selection + guard textView.shouldChangeText(in: range, replacementString: "\n") else { + return false + } + textView.undoManager?.setActionName("Insert Newline") + storage.replaceCharacters(in: range, with: "\n") + textView.didChangeText() + textView.setSelectedRange(NSRange(location: range.location + 1, length: 0)) + return true + } + + // MARK: - Concealed-run arrow navigation + + private enum ArrowDirection { case left, right } + + /// If the character immediately adjacent (in the motion direction) + /// carries `.marcdownConcealed`, jump the entire concealed run in + /// one motion. Otherwise return `false` so AppKit's default single- + /// character motion runs (Thomas §1c). + private func handleArrow(in textView: NSTextView, direction: ArrowDirection) -> Bool { + guard let storage = textView.textStorage else { return false } + if textView.hasMarkedText() { return false } + let selection = textView.selectedRange() + if selection.length > 0 { return false } + let cursor = selection.location + let length = storage.length + + // Use the logical-concealment attribute (mirrored by the styler) + // so the jump still triggers when the focused line has its + // visible concealment stripped by the reveal pass. + switch direction { + case .right: + guard cursor < length else { return false } + guard + let flag = storage.attribute(.marcdownConcealedLogical, at: cursor, effectiveRange: nil) as? Bool, + flag + else { return false } + var effective = NSRange(location: 0, length: 0) + _ = storage.attribute( + .marcdownConcealedLogical, + at: cursor, + longestEffectiveRange: &effective, + in: NSRange(location: 0, length: length) + ) + let target = effective.location + effective.length + textView.setSelectedRange(NSRange(location: target, length: 0)) + return true + case .left: + guard cursor > 0 else { return false } + let probe = cursor - 1 + guard + let flag = storage.attribute(.marcdownConcealedLogical, at: probe, effectiveRange: nil) as? Bool, + flag + else { return false } + var effective = NSRange(location: 0, length: 0) + _ = storage.attribute( + .marcdownConcealedLogical, + at: probe, + longestEffectiveRange: &effective, + in: NSRange(location: 0, length: length) + ) + let target = effective.location + textView.setSelectedRange(NSRange(location: target, length: 0)) + return true + } + } + + // MARK: - Renumber pass + + /// Run `OrderedListRenumber.renumberRun` on the line containing + /// `cursor`. If the run is rewritten, apply the diff to storage and + /// adjust the cursor. Safe to call after every list-affecting edit; + /// non-ordered lines / single-item runs are `.noOp`. + private func runRenumberPass(in textView: NSTextView, around cursor: Int) { + guard let storage = textView.textStorage else { return } + let buffer = storage.string + let outcome = OrderedListRenumber.renumberRun( + buffer: buffer, + anchorOffset: cursor, + cursorOffset: cursor + ) + switch outcome { + case .noOp: + return + case .rewrite(let newBuffer, let newCursorOffset): + let fullRange = NSRange(location: 0, length: storage.length) + guard textView.shouldChangeText(in: fullRange, replacementString: newBuffer) else { + return + } + storage.replaceCharacters(in: fullRange, with: newBuffer) + textView.didChangeText() + textView.setSelectedRange(NSRange(location: newCursorOffset, length: 0)) + } + } + // MARK: - Line-scoped navigation/delete /// Returns the UTF-16 offset of the start of the line containing @@ -312,9 +530,11 @@ public struct NoteEditorView: NSViewRepresentable { return true } - /// True if every character in `range` carries the `.marcdownConcealed` - /// attribute. An empty range returns false (no characters to extend - /// through). + /// True if every character in `range` carries the + /// `.marcdownConcealedLogical` attribute. An empty range returns + /// false (no characters to extend through). We read the *logical* + /// flag (not `.marcdownConcealed`) so focus-line reveal doesn't + /// suppress concealment-aware deletes on the line the user is on. private func rangeIsAllConcealed(in storage: NSTextStorage, range: NSRange) -> Bool { guard range.length > 0, range.location >= 0, @@ -322,7 +542,7 @@ public struct NoteEditorView: NSViewRepresentable { else { return false } var allConcealed = true storage.enumerateAttribute( - .marcdownConcealed, + .marcdownConcealedLogical, in: range, options: [] ) { value, _, stop in @@ -401,7 +621,7 @@ public struct NoteEditorView: NSViewRepresentable { ) switch listOutcome { case .noOp: - return false + break case .replace(let range, let replacement, let cursorOffsetInBuffer): let undoName = replacement.isEmpty ? "Remove List Item" : "New List Item" guard textView.shouldChangeText(in: range, replacementString: replacement) else { @@ -411,7 +631,28 @@ public struct NoteEditorView: NSViewRepresentable { storage.replaceCharacters(in: range, with: replacement) textView.didChangeText() textView.setSelectedRange(NSRange(location: cursorOffsetInBuffer, length: 0)) - // textDidChange will run a restyle pass + propagate text. + // Renumber any ordered run whose structure may have changed. + runRenumberPass(in: textView, around: cursorOffsetInBuffer) + return true + } + + // Blockquote helper — Spec §9. + let blockquoteOutcome = BlockquoteContinuation.enterOutcome( + buffer: storage.string, + cursorOffset: cursor + ) + switch blockquoteOutcome { + case .noOp: + return false + case .replace(let range, let replacement, let cursorOffsetInBuffer): + let undoName = replacement.isEmpty ? "Exit Blockquote" : "New Blockquote Line" + guard textView.shouldChangeText(in: range, replacementString: replacement) else { + return false + } + textView.undoManager?.setActionName(undoName) + storage.replaceCharacters(in: range, with: replacement) + textView.didChangeText() + textView.setSelectedRange(NSRange(location: cursorOffsetInBuffer, length: 0)) return true } } @@ -452,7 +693,7 @@ public struct NoteEditorView: NSViewRepresentable { ) switch listOutcome { case .standard: - return false + break case .replace(let range, let cursorOffsetInBuffer): guard textView.shouldChangeText(in: range, replacementString: "") else { return false @@ -461,7 +702,45 @@ public struct NoteEditorView: NSViewRepresentable { storage.replaceCharacters(in: range, with: "") textView.didChangeText() textView.setSelectedRange(NSRange(location: cursorOffsetInBuffer, length: 0)) - // textDidChange will run a restyle pass + propagate text. + runRenumberPass(in: textView, around: cursorOffsetInBuffer) + return true + } + + // Blockquote helper — Spec §9.4. + let blockquoteOutcome = BlockquoteContinuation.backspaceOutcome( + buffer: storage.string, + cursorOffset: cursor + ) + switch blockquoteOutcome { + case .standard: + break + case .replace(let range, let cursorOffsetInBuffer): + guard textView.shouldChangeText(in: range, replacementString: "") else { + return false + } + textView.undoManager?.setActionName("Remove Blockquote") + storage.replaceCharacters(in: range, with: "") + textView.didChangeText() + textView.setSelectedRange(NSRange(location: cursorOffsetInBuffer, length: 0)) + return true + } + + // Heading helper — Spec §8.2. + let headingOutcome = HeadingContinuation.backspaceOutcome( + buffer: storage.string, + cursorOffset: cursor + ) + switch headingOutcome { + case .standard: + return false + case .replace(let range, let cursorOffsetInBuffer): + guard textView.shouldChangeText(in: range, replacementString: "") else { + return false + } + textView.undoManager?.setActionName("Remove Heading") + storage.replaceCharacters(in: range, with: "") + textView.didChangeText() + textView.setSelectedRange(NSRange(location: cursorOffsetInBuffer, length: 0)) return true } } diff --git a/Packages/MarcdownEditor/Tests/MarcdownEditorTests/BlockquoteCommandTests.swift b/Packages/MarcdownEditor/Tests/MarcdownEditorTests/BlockquoteCommandTests.swift new file mode 100644 index 0000000..116d989 --- /dev/null +++ b/Packages/MarcdownEditor/Tests/MarcdownEditorTests/BlockquoteCommandTests.swift @@ -0,0 +1,103 @@ +import AppKit +import SwiftUI +import Testing + +@testable import MarcdownEditor + +// Coordinator-level integration tests for blockquote Enter / Backspace +// routing. The coordinator must consult `BlockquoteContinuation` after the +// task-list and bullet/ordered helpers have declined the keystroke. +// +// The acceptance criteria are Spec §9 + acceptance #7 and #8: +// 7. Enter on `> hello|` → `> hello\n> |` +// 8. Enter on `> hello\n> |` → `> hello\n\n|` (strip + blank separator) + +@MainActor +@Suite("Blockquote command routing") +struct BlockquoteCommandTests { + + @Test func enterOnNonEmptyBlockquoteContinuesPrefix() { + let harness = makeHarness(buffer: "> hello") + harness.setCaret(7) + + let handled = harness.send(#selector(NSResponder.insertNewline(_:))) + + #expect(handled == true) + #expect(harness.storage.string == "> hello\n> ") + #expect(harness.textView.selectedRange() == NSRange(location: 10, length: 0)) + } + + @Test func enterOnEmptyBlockquoteStripsPrefix() { + let harness = makeHarness(buffer: "> hello\n> ") + harness.setCaret(10) + + let handled = harness.send(#selector(NSResponder.insertNewline(_:))) + + #expect(handled == true) + // The empty `> ` prefix gets stripped. (Whether a trailing blank line + // is inserted depends on the helper's contract — see + // `BlockquoteContinuationTests`. This integration test asserts on the + // strip behaviour only, mirroring the existing pattern for empty + // bullet/task exit.) + #expect(harness.storage.string == "> hello\n") + #expect(harness.textView.selectedRange() == NSRange(location: 8, length: 0)) + } + + @Test func backspaceAtEndOfEmptyBlockquoteAtomicallyRemovesMarker() { + // After "line1\n> ", BS at the end of the `> ` line eats the marker + // AND the preceding `\n`, landing the cursor at end of "line1". + let harness = makeHarness(buffer: "line1\n> ") + harness.setCaret(8) + + let handled = harness.send(#selector(NSResponder.deleteBackward(_:))) + + #expect(handled == true) + #expect(harness.storage.string == "line1") + #expect(harness.textView.selectedRange() == NSRange(location: 5, length: 0)) + } + + // MARK: - Harness (duplicate of LineScopedCommandTests for hermetic suites) + + private func makeHarness(buffer: String) -> Harness { + let storage = NSTextStorage(string: buffer) + let layoutManager = NSLayoutManager() + storage.addLayoutManager(layoutManager) + let container = NSTextContainer( + size: NSSize(width: 400, height: CGFloat.greatestFiniteMagnitude) + ) + layoutManager.addTextContainer(container) + + let textView = NSTextView( + frame: NSRect(x: 0, y: 0, width: 400, height: 400), + textContainer: container + ) + textView.allowsUndo = true + textView.isRichText = false + textView.isEditable = true + textView.isSelectable = true + + var sink = buffer + let binding = Binding(get: { sink }, set: { sink = $0 }) + let coordinator = NoteEditorView.Coordinator(text: binding) + textView.delegate = coordinator + coordinator.install(textView: textView, storage: storage) + coordinator.restyle() + + return Harness(storage: storage, textView: textView, coordinator: coordinator) + } + + @MainActor + private struct Harness { + let storage: NSTextStorage + let textView: NSTextView + let coordinator: NoteEditorView.Coordinator + + func setCaret(_ location: Int) { + textView.setSelectedRange(NSRange(location: location, length: 0)) + } + + func send(_ selector: Selector) -> Bool { + coordinator.textView(textView, doCommandBy: selector) + } + } +} diff --git a/Packages/MarcdownEditor/Tests/MarcdownEditorTests/ConcealedRunNavigationTests.swift b/Packages/MarcdownEditor/Tests/MarcdownEditorTests/ConcealedRunNavigationTests.swift new file mode 100644 index 0000000..f117c69 --- /dev/null +++ b/Packages/MarcdownEditor/Tests/MarcdownEditorTests/ConcealedRunNavigationTests.swift @@ -0,0 +1,157 @@ +import AppKit +import SwiftUI +import Testing + +@testable import MarcdownEditor + +// Thomas §1c (the "arrowing through a concealed link URL" pain point): +// when arrow-key navigation encounters a `.marcdownConcealed` run, the +// cursor should jump the entire run in one keypress instead of crawling +// character by character. This makes long link URLs traversable. +// +// Expected coordinator routing: +// `#selector(NSResponder.moveLeft(_:))` → handle concealed-run skip leftward +// `#selector(NSResponder.moveRight(_:))` → handle concealed-run skip rightward +// `#selector(NSResponder.moveLeftAndModifySelection(_:))` +// `#selector(NSResponder.moveRightAndModifySelection(_:))` +// +// Contract: +// - If the character immediately adjacent (in the motion direction) carries +// `.marcdownConcealed = true`, the cursor jumps to the FAR side of the +// maximal concealed run in one motion. +// - If there is no concealed run adjacent, return `false` so AppKit's default +// single-char motion runs. +// - With Option held, fall back to char-by-char motion. (Future work; for +// now we don't test that path — guy should keep the helper restricted to +// the unmodified arrow keys.) +// +// Note: the styler must have run a restyle pass so `.marcdownConcealed` +// attributes are present. + +@MainActor +@Suite("Concealed-run arrow navigation") +struct ConcealedRunNavigationTests { + + @Test func rightArrowJumpsOverConcealedClosingLinkSyntax() { + // Buffer: "[a](b)" — label "a" at offset 1, closing `](b)` at offsets + // 2..<6 is one big concealed run. Cursor right after the label (at + // offset 2) should jump to offset 6 (past the URL) in one keypress. + let harness = makeHarness(buffer: "[a](b)") + harness.setCaret(2) + + let handled = harness.send(#selector(NSResponder.moveRight(_:))) + + #expect(handled == true) + #expect(harness.textView.selectedRange() == NSRange(location: 6, length: 0)) + } + + @Test func leftArrowJumpsOverConcealedClosingLinkSyntax() { + // Same buffer, cursor at end (offset 6). Left arrow jumps to offset 2 + // (start of the concealed `](b)` run). + let harness = makeHarness(buffer: "[a](b)") + harness.setCaret(6) + + let handled = harness.send(#selector(NSResponder.moveLeft(_:))) + + #expect(handled == true) + #expect(harness.textView.selectedRange() == NSRange(location: 2, length: 0)) + } + + @Test func rightArrowJumpsOverBoldOpeningDelimiter() { + // "**foo**" — concealed `**` at 0..<2, then body "foo", then `**` at 5..<7. + // Cursor at offset 0, right arrow should jump to offset 2. + let harness = makeHarness(buffer: "**foo**") + harness.setCaret(0) + + let handled = harness.send(#selector(NSResponder.moveRight(_:))) + + #expect(handled == true) + #expect(harness.textView.selectedRange() == NSRange(location: 2, length: 0)) + } + + @Test func rightArrowOnPlainCharFallsThroughToAppKit() { + // On body text, the coordinator returns `false` so AppKit's default + // single-char motion runs. Cursor stays put because our synthetic + // path doesn't dispatch AppKit's default action. + let harness = makeHarness(buffer: "hello") + harness.setCaret(0) + + let handled = harness.send(#selector(NSResponder.moveRight(_:))) + + #expect(handled == false) + } + + @Test func leftArrowOnPlainCharFallsThroughToAppKit() { + let harness = makeHarness(buffer: "hello") + harness.setCaret(5) + + let handled = harness.send(#selector(NSResponder.moveLeft(_:))) + + #expect(handled == false) + } + + @Test func rightArrowAtEndOfBufferIsNoOp() { + // Cursor at end → no character to the right → nothing to skip → false. + let harness = makeHarness(buffer: "[a](b)") + harness.setCaret(6) + + let handled = harness.send(#selector(NSResponder.moveRight(_:))) + + #expect(handled == false) + } + + @Test func leftArrowAtBufferStartIsNoOp() { + let harness = makeHarness(buffer: "[a](b)") + harness.setCaret(0) + + let handled = harness.send(#selector(NSResponder.moveLeft(_:))) + + #expect(handled == false) + } + + // MARK: - Harness + + private func makeHarness(buffer: String) -> Harness { + let storage = NSTextStorage(string: buffer) + let layoutManager = NSLayoutManager() + storage.addLayoutManager(layoutManager) + let container = NSTextContainer( + size: NSSize(width: 400, height: CGFloat.greatestFiniteMagnitude) + ) + layoutManager.addTextContainer(container) + + let textView = NSTextView( + frame: NSRect(x: 0, y: 0, width: 400, height: 400), + textContainer: container + ) + textView.allowsUndo = true + textView.isRichText = false + textView.isEditable = true + textView.isSelectable = true + + var sink = buffer + let binding = Binding(get: { sink }, set: { sink = $0 }) + let coordinator = NoteEditorView.Coordinator(text: binding) + textView.delegate = coordinator + coordinator.install(textView: textView, storage: storage) + // Restyle so .marcdownConcealed attributes are present. + coordinator.restyle() + + return Harness(storage: storage, textView: textView, coordinator: coordinator) + } + + @MainActor + private struct Harness { + let storage: NSTextStorage + let textView: NSTextView + let coordinator: NoteEditorView.Coordinator + + func setCaret(_ location: Int) { + textView.setSelectedRange(NSRange(location: location, length: 0)) + } + + func send(_ selector: Selector) -> Bool { + coordinator.textView(textView, doCommandBy: selector) + } + } +} diff --git a/Packages/MarcdownEditor/Tests/MarcdownEditorTests/HeadingBackspaceCommandTests.swift b/Packages/MarcdownEditor/Tests/MarcdownEditorTests/HeadingBackspaceCommandTests.swift new file mode 100644 index 0000000..25c61f4 --- /dev/null +++ b/Packages/MarcdownEditor/Tests/MarcdownEditorTests/HeadingBackspaceCommandTests.swift @@ -0,0 +1,97 @@ +import AppKit +import SwiftUI +import Testing + +@testable import MarcdownEditor + +// Coordinator-level test for Spec §8.2 acceptance criterion #9: +// "On `# |`, press Backspace. Result is an empty plain line `|`." +// +// The coordinator must consult `HeadingContinuation.backspaceOutcome` in +// `handleDeleteBackward` after the task-list and list helpers decline. + +@MainActor +@Suite("Heading backspace command routing") +struct HeadingBackspaceCommandTests { + + @Test func backspaceAtEndOfEmptyH1AtomicallyStripsMarker() { + let harness = makeHarness(buffer: "# ") + harness.setCaret(2) + + let handled = harness.send(#selector(NSResponder.deleteBackward(_:))) + + #expect(handled == true) + #expect(harness.storage.string == "") + #expect(harness.textView.selectedRange() == NSRange(location: 0, length: 0)) + } + + @Test func backspaceAtEndOfEmptyHeadingAfterContentEatsPrecedingNewline() { + let harness = makeHarness(buffer: "hello\n# ") + harness.setCaret(8) + + let handled = harness.send(#selector(NSResponder.deleteBackward(_:))) + + #expect(handled == true) + #expect(harness.storage.string == "hello") + #expect(harness.textView.selectedRange() == NSRange(location: 5, length: 0)) + } + + @Test func backspaceOnNonEmptyHeadingFallsThroughToAppKit() { + // The coordinator must NOT hijack a backspace on a non-empty heading + // body — that's a normal char delete handled by AppKit. + let harness = makeHarness(buffer: "# Hello") + harness.setCaret(7) + + let handled = harness.send(#selector(NSResponder.deleteBackward(_:))) + + #expect(handled == false) + // Storage unchanged because the coordinator did not perform an edit + // and AppKit's default is not dispatched through our synthetic path. + #expect(harness.storage.string == "# Hello") + } + + // MARK: - Harness + + private func makeHarness(buffer: String) -> Harness { + let storage = NSTextStorage(string: buffer) + let layoutManager = NSLayoutManager() + storage.addLayoutManager(layoutManager) + let container = NSTextContainer( + size: NSSize(width: 400, height: CGFloat.greatestFiniteMagnitude) + ) + layoutManager.addTextContainer(container) + + let textView = NSTextView( + frame: NSRect(x: 0, y: 0, width: 400, height: 400), + textContainer: container + ) + textView.allowsUndo = true + textView.isRichText = false + textView.isEditable = true + textView.isSelectable = true + + var sink = buffer + let binding = Binding(get: { sink }, set: { sink = $0 }) + let coordinator = NoteEditorView.Coordinator(text: binding) + textView.delegate = coordinator + coordinator.install(textView: textView, storage: storage) + coordinator.restyle() + + return Harness(storage: storage, textView: textView, coordinator: coordinator) + } + + @MainActor + private struct Harness { + let storage: NSTextStorage + let textView: NSTextView + let coordinator: NoteEditorView.Coordinator + + func setCaret(_ location: Int) { + textView.setSelectedRange(NSRange(location: location, length: 0)) + } + + func send(_ selector: Selector) -> Bool { + coordinator.textView(textView, doCommandBy: selector) + } + } +} diff --git a/Packages/MarcdownEditor/Tests/MarcdownEditorTests/ListIndentCommandTests.swift b/Packages/MarcdownEditor/Tests/MarcdownEditorTests/ListIndentCommandTests.swift new file mode 100644 index 0000000..c6264ed --- /dev/null +++ b/Packages/MarcdownEditor/Tests/MarcdownEditorTests/ListIndentCommandTests.swift @@ -0,0 +1,207 @@ +import AppKit +import SwiftUI +import Testing + +@testable import MarcdownEditor + +// Coordinator-level integration tests for Tab / Shift-Tab on list lines and +// Shift+Return on continuation lines. These exercise the full keystroke +// pipeline via `textView(_:doCommandBy:)`. +// +// Expected coordinator routing (to be implemented by guy in NoteEditorView): +// - `#selector(NSResponder.insertTab(_:))` → indent the current list line by +// prepending " ". On a non-list line, return `false` so AppKit's default +// tab insertion runs. +// - `#selector(NSResponder.insertBacktab(_:))` → outdent the current list +// line by stripping up to 2 leading spaces or 1 tab. +// - `#selector(NSResponder.insertNewlineIgnoringFieldEditor(_:))` → +// Shift+Return: insert a plain "\n" without list continuation. Currently +// AppKit maps Shift+Return to this selector for `NSTextView`. Coordinator +// handles by performing the raw insertion and returning `true`. + +@MainActor +@Suite("List indent / Shift+Return command routing") +struct ListIndentCommandTests { + + // MARK: - Tab on list lines + + @Test func tabOnBulletLineIndentsByTwoSpaces() { + let harness = makeHarness(buffer: "- foo") + harness.setCaret(5) + + let handled = harness.send(#selector(NSResponder.insertTab(_:))) + + #expect(handled == true) + #expect(harness.storage.string == " - foo") + #expect(harness.textView.selectedRange() == NSRange(location: 7, length: 0)) + } + + @Test func tabOnOrderedLineIndentsByTwoSpaces() { + let harness = makeHarness(buffer: "1. foo") + harness.setCaret(6) + + let handled = harness.send(#selector(NSResponder.insertTab(_:))) + + #expect(handled == true) + #expect(harness.storage.string == " 1. foo") + #expect(harness.textView.selectedRange() == NSRange(location: 8, length: 0)) + } + + @Test func tabOnTaskLineIndentsByTwoSpaces() { + let harness = makeHarness(buffer: "- [ ] foo") + harness.setCaret(9) + + let handled = harness.send(#selector(NSResponder.insertTab(_:))) + + #expect(handled == true) + #expect(harness.storage.string == " - [ ] foo") + #expect(harness.textView.selectedRange() == NSRange(location: 11, length: 0)) + } + + @Test func tabOnPlainLineFallsThroughToAppKit() { + // On a plain line, the coordinator returns false. AppKit then inserts + // its default tab character. Spec §19 — explicitly NOT a code-block + // conversion (we diverge from Bear here). + let harness = makeHarness(buffer: "hello") + harness.setCaret(5) + + let handled = harness.send(#selector(NSResponder.insertTab(_:))) + + // Coordinator did not claim → AppKit's default would have inserted + // "\t" (or whatever NSTextView's tab semantics are). The storage is + // unchanged because our synthetic NSTextView delegate path doesn't + // dispatch the default tab insertion. The important assertion is + // that the coordinator did not modify the buffer. + #expect(handled == false) + #expect(harness.storage.string == "hello") + } + + // MARK: - Shift-Tab on list lines + + @Test func backtabOnIndentedBulletOutdentsByTwoSpaces() { + let harness = makeHarness(buffer: " - foo") + harness.setCaret(7) + + let handled = harness.send(#selector(NSResponder.insertBacktab(_:))) + + #expect(handled == true) + #expect(harness.storage.string == "- foo") + #expect(harness.textView.selectedRange() == NSRange(location: 5, length: 0)) + } + + @Test func backtabOnTopLevelBulletIsNoOp() { + let harness = makeHarness(buffer: "- foo") + harness.setCaret(5) + + let handled = harness.send(#selector(NSResponder.insertBacktab(_:))) + + // Coordinator should not consume the keystroke when there is nothing + // to outdent — let AppKit's default (typically nothing visible) run. + #expect(handled == false) + #expect(harness.storage.string == "- foo") + } + + @Test func backtabOnIndentedOrderedItemOutdentsByTwoSpaces() { + let harness = makeHarness(buffer: " 1. foo") + harness.setCaret(8) + + let handled = harness.send(#selector(NSResponder.insertBacktab(_:))) + + #expect(handled == true) + #expect(harness.storage.string == "1. foo") + #expect(harness.textView.selectedRange() == NSRange(location: 6, length: 0)) + } + + // MARK: - Shift+Return: insertNewlineIgnoringFieldEditor + + @Test func shiftReturnOnBulletInsertsPlainNewline() { + // Spec §4.10: Shift+Return inserts a plain `\n` without continuing + // the list. Cursor lands on the new (plain) line. + let harness = makeHarness(buffer: "- foo") + harness.setCaret(5) + + let handled = harness.send(#selector(NSResponder.insertNewlineIgnoringFieldEditor(_:))) + + #expect(handled == true) + #expect(harness.storage.string == "- foo\n") + #expect(harness.textView.selectedRange() == NSRange(location: 6, length: 0)) + } + + @Test func shiftReturnOnOrderedInsertsPlainNewline() { + let harness = makeHarness(buffer: "1. foo") + harness.setCaret(6) + + let handled = harness.send(#selector(NSResponder.insertNewlineIgnoringFieldEditor(_:))) + + #expect(handled == true) + #expect(harness.storage.string == "1. foo\n") + #expect(harness.textView.selectedRange() == NSRange(location: 7, length: 0)) + } + + @Test func shiftReturnOnTaskInsertsPlainNewline() { + let harness = makeHarness(buffer: "- [ ] foo") + harness.setCaret(9) + + let handled = harness.send(#selector(NSResponder.insertNewlineIgnoringFieldEditor(_:))) + + #expect(handled == true) + #expect(harness.storage.string == "- [ ] foo\n") + #expect(harness.textView.selectedRange() == NSRange(location: 10, length: 0)) + } + + @Test func shiftReturnOnBlockquoteInsertsPlainNewline() { + let harness = makeHarness(buffer: "> hello") + harness.setCaret(7) + + let handled = harness.send(#selector(NSResponder.insertNewlineIgnoringFieldEditor(_:))) + + #expect(handled == true) + #expect(harness.storage.string == "> hello\n") + #expect(harness.textView.selectedRange() == NSRange(location: 8, length: 0)) + } + + // MARK: - Harness + + private func makeHarness(buffer: String) -> Harness { + let storage = NSTextStorage(string: buffer) + let layoutManager = NSLayoutManager() + storage.addLayoutManager(layoutManager) + let container = NSTextContainer( + size: NSSize(width: 400, height: CGFloat.greatestFiniteMagnitude) + ) + layoutManager.addTextContainer(container) + + let textView = NSTextView( + frame: NSRect(x: 0, y: 0, width: 400, height: 400), + textContainer: container + ) + textView.allowsUndo = true + textView.isRichText = false + textView.isEditable = true + textView.isSelectable = true + + var sink = buffer + let binding = Binding(get: { sink }, set: { sink = $0 }) + let coordinator = NoteEditorView.Coordinator(text: binding) + textView.delegate = coordinator + coordinator.install(textView: textView, storage: storage) + coordinator.restyle() + + return Harness(storage: storage, textView: textView, coordinator: coordinator) + } + + @MainActor + private struct Harness { + let storage: NSTextStorage + let textView: NSTextView + let coordinator: NoteEditorView.Coordinator + + func setCaret(_ location: Int) { + textView.setSelectedRange(NSRange(location: location, length: 0)) + } + + func send(_ selector: Selector) -> Bool { + coordinator.textView(textView, doCommandBy: selector) + } + } +} From e9de408e806872ab88cd280b8c97e0c0b3c0cd11 Mon Sep 17 00:00:00 2001 From: luctst Date: Tue, 9 Jun 2026 10:44:22 +0200 Subject: [PATCH 9/9] style: apply swift-format to ordered list renumber files --- .../MarcdownStyling/OrderedListRenumber.swift | 3 +- .../OrderedListRenumberTests.swift | 77 ++++++++++--------- 2 files changed, 44 insertions(+), 36 deletions(-) diff --git a/Packages/MarcdownStyling/Sources/MarcdownStyling/OrderedListRenumber.swift b/Packages/MarcdownStyling/Sources/MarcdownStyling/OrderedListRenumber.swift index 4d0a68f..bbc965f 100644 --- a/Packages/MarcdownStyling/Sources/MarcdownStyling/OrderedListRenumber.swift +++ b/Packages/MarcdownStyling/Sources/MarcdownStyling/OrderedListRenumber.swift @@ -90,7 +90,8 @@ public enum OrderedListRenumber { // 4. Compute the new numbers. Preserve the first line's number. guard let firstStart = runLineStarts.first, - let firstMeta = orderedLineMetadata(units: units, lineStart: firstStart, lineEnd: scanLineEnd(units: units, startOffset: firstStart)) + let firstMeta = orderedLineMetadata( + units: units, lineStart: firstStart, lineEnd: scanLineEnd(units: units, startOffset: firstStart)) else { return .noOp } let firstNumber = firstMeta.number diff --git a/Packages/MarcdownStyling/Tests/MarcdownStylingTests/OrderedListRenumberTests.swift b/Packages/MarcdownStyling/Tests/MarcdownStylingTests/OrderedListRenumberTests.swift index e0e9ed0..dbf6378 100644 --- a/Packages/MarcdownStyling/Tests/MarcdownStylingTests/OrderedListRenumberTests.swift +++ b/Packages/MarcdownStyling/Tests/MarcdownStylingTests/OrderedListRenumberTests.swift @@ -86,14 +86,15 @@ struct OrderedListRenumberTests { let buffer = "1. a\n2. \n2. b" let outcome = OrderedListRenumber.renumberRun( buffer: buffer, - anchorOffset: 5, // start of the new "2. " line - cursorOffset: 8 // cursor at end of marker on the new line + anchorOffset: 5, // start of the new "2. " line + cursorOffset: 8 // cursor at end of marker on the new line ) #expect( - outcome == .rewrite( - newBuffer: "1. a\n2. \n3. b", - newCursorOffset: 8 - ) + outcome + == .rewrite( + newBuffer: "1. a\n2. \n3. b", + newCursorOffset: 8 + ) ) } @@ -102,14 +103,15 @@ struct OrderedListRenumberTests { let buffer = "preamble\n\n5. a\n7. b\n9. c" let outcome = OrderedListRenumber.renumberRun( buffer: buffer, - anchorOffset: 10, // anchor on "5. a" + anchorOffset: 10, // anchor on "5. a" cursorOffset: 14 ) #expect( - outcome == .rewrite( - newBuffer: "preamble\n\n5. a\n6. b\n7. c", - newCursorOffset: 14 - ) + outcome + == .rewrite( + newBuffer: "preamble\n\n5. a\n6. b\n7. c", + newCursorOffset: 14 + ) ) } @@ -121,14 +123,15 @@ struct OrderedListRenumberTests { let buffer = "1. a\n3. c" let outcome = OrderedListRenumber.renumberRun( buffer: buffer, - anchorOffset: 0, // anchor on the still-existing first item - cursorOffset: 4 // cursor at end of "1. a" + anchorOffset: 0, // anchor on the still-existing first item + cursorOffset: 4 // cursor at end of "1. a" ) #expect( - outcome == .rewrite( - newBuffer: "1. a\n2. c", - newCursorOffset: 4 - ) + outcome + == .rewrite( + newBuffer: "1. a\n2. c", + newCursorOffset: 4 + ) ) } @@ -141,14 +144,15 @@ struct OrderedListRenumberTests { let buffer = "1. a\n 1. b\n 2. c\n5. d" let outcome = OrderedListRenumber.renumberRun( buffer: buffer, - anchorOffset: 0, // anchor at depth 0 + anchorOffset: 0, // anchor at depth 0 cursorOffset: 0 ) #expect( - outcome == .rewrite( - newBuffer: "1. a\n 1. b\n 2. c\n2. d", - newCursorOffset: 0 - ) + outcome + == .rewrite( + newBuffer: "1. a\n 1. b\n 2. c\n2. d", + newCursorOffset: 0 + ) ) } @@ -158,14 +162,15 @@ struct OrderedListRenumberTests { let buffer = "1. a\n 1. b\n 3. c\n5. d" let outcome = OrderedListRenumber.renumberRun( buffer: buffer, - anchorOffset: 5, // anchor at depth 2 (the " 1. b" line) + anchorOffset: 5, // anchor at depth 2 (the " 1. b" line) cursorOffset: 5 ) #expect( - outcome == .rewrite( - newBuffer: "1. a\n 1. b\n 2. c\n5. d", - newCursorOffset: 5 - ) + outcome + == .rewrite( + newBuffer: "1. a\n 1. b\n 2. c\n5. d", + newCursorOffset: 5 + ) ) } @@ -183,10 +188,11 @@ struct OrderedListRenumberTests { cursorOffset: 14 ) #expect( - outcome == .rewrite( - newBuffer: "8. a\n9. b\n10. c", - newCursorOffset: 15 - ) + outcome + == .rewrite( + newBuffer: "8. a\n9. b\n10. c", + newCursorOffset: 15 + ) ) } @@ -202,10 +208,11 @@ struct OrderedListRenumberTests { cursorOffset: 16 ) #expect( - outcome == .rewrite( - newBuffer: "9. a\n10. b\n11. c", - newCursorOffset: 16 - ) + outcome + == .rewrite( + newBuffer: "9. a\n10. b\n11. c", + newCursorOffset: 16 + ) ) }