From 2f64d9cd834dd58c1afe4ebb5d77bfda51da6865 Mon Sep 17 00:00:00 2001 From: Feror Date: Sun, 31 May 2026 22:21:03 +0200 Subject: [PATCH] fix(renderer): hide rendered markdown syntax --- .../SaplingEditor/HybridMarkdownEditor.swift | 107 ++++++++++++--- .../HybridMarkdownLineRenderer.swift | 44 ++++++ .../MarkdownTextStylerRenderingTests.swift | 126 ++++++++++++++++++ 3 files changed, 261 insertions(+), 16 deletions(-) create mode 100644 Tests/SaplingEditorTests/MarkdownTextStylerRenderingTests.swift diff --git a/Sources/SaplingEditor/HybridMarkdownEditor.swift b/Sources/SaplingEditor/HybridMarkdownEditor.swift index 3ccd70a..d508070 100644 --- a/Sources/SaplingEditor/HybridMarkdownEditor.swift +++ b/Sources/SaplingEditor/HybridMarkdownEditor.swift @@ -666,9 +666,14 @@ enum MarkdownTextStyler { let lines = invalidationPlan.isFullRender ? lineIndex.editorLines(activeLineIndex: activeLineIndex) : lineIndex.editorLines(for: invalidationPlan.dirtyLineIndexes, activeLineIndex: activeLineIndex) - let renderPlansByLine = Dictionary( - uniqueKeysWithValues: renderer.renderPlans(for: lines).map { ($0.line.index, $0) } - ) + let renderPlans = invalidationPlan.isFullRender || !linesNeedCodeBlockContext(lines, lineIndex: lineIndex) + ? renderer.renderPlans(for: lines) + : renderer.renderPlans( + for: lines, + resolvingCodeBlockContextWith: lineIndex, + activeLineIndex: activeLineIndex + ) + let renderPlansByLine = Dictionary(uniqueKeysWithValues: renderPlans.map { ($0.line.index, $0) }) var styledLineCount = 0 for line in lines { resetAttributes(in: textStorage, line: line, textColor: textColor) @@ -697,6 +702,27 @@ enum MarkdownTextStyler { return MarkdownTextStylingResult(totalLineCount: lineIndex.lineCount, styledLineCount: styledLineCount) } + private static func linesNeedCodeBlockContext(_ lines: [EditorLine], lineIndex: DocumentLineIndex) -> Bool { + for line in lines { + let lowerBound = max(0, line.index - 2) + let upperBound = min(lineIndex.lineCount - 1, line.index + 2) + for nearbyLineIndex in lowerBound...upperBound { + guard let nearbyLine = lineIndex.editorLine(at: nearbyLineIndex, activeLineIndex: -1) else { continue } + if isFenceLine(nearbyLine.source) { + return true + } + } + } + return false + } + + private static func isFenceLine(_ source: String) -> Bool { + let trimmedPrefix = source.prefix { $0 == " " || $0 == "\t" } + guard trimmedPrefix.count <= 3 else { return false } + let content = source.dropFirst(trimmedPrefix.count) + return content.hasPrefix("```") || content.hasPrefix("~~~") + } + private static func resetAttributes( in textStorage: NSTextStorage, line: EditorLine, @@ -719,10 +745,10 @@ enum MarkdownTextStyler { switch renderPlan.kind { case .heading(let level, let markerRange, let textRange): - textStorage.addAttributes([ - .foregroundColor: secondaryTextColor, - .font: monospacedFont(size: 13, weight: .regular) - ], range: markerRange) + hideSyntax( + in: textStorage, + range: NSRange(location: markerRange.location, length: textRange.location - markerRange.location) + ) textStorage.addAttributes([ .font: systemFont(size: headingFontSize(level: level), weight: .semibold), .paragraphStyle: headingParagraphStyle(level: level) @@ -765,19 +791,18 @@ enum MarkdownTextStyler { secondaryTextColor: secondaryTextColor ) case .taskList(let markerRange, let checkboxRange, let contentRange, let checked, let nestingLevel): - styleListLine( + styleTaskListLine( in: textStorage, lineRange: line.range, markerRange: markerRange, + checkboxRange: checkboxRange, contentRange: contentRange, + checked: checked, nestingLevel: nestingLevel, - secondaryTextColor: secondaryTextColor + secondaryTextColor: secondaryTextColor, + accentColor: accentColor, + backgroundColor: backgroundColor ) - textStorage.addAttributes([ - .foregroundColor: checked ? accentColor : secondaryTextColor, - .font: monospacedFont(size: 15, weight: .semibold), - .backgroundColor: checked ? accentColor.withAlphaComponent(0.16) : backgroundColor - ], range: checkboxRange) if checked { textStorage.addAttributes([ .strikethroughStyle: NSUnderlineStyle.single.rawValue, @@ -786,12 +811,17 @@ enum MarkdownTextStyler { } case .fencedCodeFence(let markerRange, let languageRange): textStorage.addAttributes(codeBlockAttributes(accentColor: accentColor), range: line.range) - textStorage.addAttributes([.foregroundColor: secondaryTextColor], range: markerRange) if let languageRange { + hideSyntax( + in: textStorage, + range: NSRange(location: markerRange.location, length: languageRange.location - markerRange.location) + ) textStorage.addAttributes([ .foregroundColor: accentColor, .font: monospacedFont(size: 13, weight: .semibold) ], range: languageRange) + } else { + hideSyntax(in: textStorage, range: line.range) } case .codeBlockContent: textStorage.addAttributes(codeBlockAttributes(accentColor: accentColor), range: line.range) @@ -845,7 +875,7 @@ enum MarkdownTextStyler { .underlineStyle: NSUnderlineStyle.single.rawValue ], range: span.range) case .markdownDelimiter: - textStorage.addAttributes([.foregroundColor: secondaryTextColor], range: span.range) + hideSyntax(in: textStorage, range: span.range) } } } @@ -970,6 +1000,43 @@ enum MarkdownTextStyler { ], range: contentRange) } + private static func styleTaskListLine( + in textStorage: NSTextStorage, + lineRange: NSRange, + markerRange: NSRange, + checkboxRange: NSRange, + contentRange: NSRange, + checked: Bool, + nestingLevel: Int, + secondaryTextColor: PlatformColor, + accentColor: PlatformColor, + backgroundColor: PlatformColor + ) { + textStorage.addAttributes([ + .paragraphStyle: listParagraphStyle(nestingLevel: nestingLevel) + ], range: lineRange) + hideSyntax( + in: textStorage, + range: NSRange(location: markerRange.location, length: checkboxRange.location - markerRange.location) + ) + textStorage.addAttributes([ + .foregroundColor: checked ? accentColor : secondaryTextColor, + .font: monospacedFont(size: 15, weight: .semibold), + .backgroundColor: checked ? accentColor.withAlphaComponent(0.16) : backgroundColor + ], range: checkboxRange) + textStorage.addAttributes([ + .font: systemFont(size: 16, weight: .regular) + ], range: contentRange) + } + + private static func hideSyntax(in textStorage: NSTextStorage, range: NSRange) { + guard range.length > 0 else { return } + textStorage.addAttributes([ + .foregroundColor: clearColor(), + .font: monospacedFont(size: 0.1, weight: .regular) + ], range: range) + } + #if os(macOS) private static func systemFont(size: CGFloat, weight: NSFont.Weight) -> NSFont { NSFont.systemFont(ofSize: size, weight: weight) @@ -982,6 +1049,10 @@ enum MarkdownTextStyler { private static func monospacedFont(size: CGFloat, weight: NSFont.Weight) -> NSFont { NSFont.monospacedSystemFont(ofSize: size, weight: weight) } + + private static func clearColor() -> NSColor { + .clear + } #elseif os(iOS) private static func systemFont(size: CGFloat, weight: UIFont.Weight) -> UIFont { UIFont.systemFont(ofSize: size, weight: weight) @@ -994,6 +1065,10 @@ enum MarkdownTextStyler { private static func monospacedFont(size: CGFloat, weight: UIFont.Weight) -> UIFont { UIFont.monospacedSystemFont(ofSize: size, weight: weight) } + + private static func clearColor() -> UIColor { + .clear + } #endif } diff --git a/Sources/SaplingEditor/HybridMarkdownLineRenderer.swift b/Sources/SaplingEditor/HybridMarkdownLineRenderer.swift index d0198a1..974289f 100644 --- a/Sources/SaplingEditor/HybridMarkdownLineRenderer.swift +++ b/Sources/SaplingEditor/HybridMarkdownLineRenderer.swift @@ -86,6 +86,31 @@ public struct HybridMarkdownLineRenderer: Sendable { return plans } + public func renderPlans( + for lines: [EditorLine], + resolvingCodeBlockContextWith lineIndex: DocumentLineIndex, + activeLineIndex: Int + ) -> [HybridMarkdownLineRenderPlan] { + lines.map { line in + let nonCodeKind = lineKind(for: line) + let kind: HybridMarkdownLineKind + + if case .fencedCodeFence = nonCodeKind { + kind = nonCodeKind + } else if isInsideFencedCodeBlock(line.index, in: lineIndex, activeLineIndex: activeLineIndex) { + kind = .codeBlockContent + } else { + kind = nonCodeKind + } + + return HybridMarkdownLineRenderPlan( + line: line, + kind: kind, + spans: inlineSpans(in: line, kind: kind) + ) + } + } + private func lineKind(for line: EditorLine) -> HybridMarkdownLineKind { let firstToken = firstNonWhitespaceCharacter(in: line.source) @@ -480,6 +505,25 @@ public struct HybridMarkdownLineRenderer: Sendable { private func firstNonWhitespaceCharacter(in source: String) -> Character? { source.first { $0 != " " && $0 != "\t" } } + + private func isInsideFencedCodeBlock( + _ lineNumber: Int, + in lineIndex: DocumentLineIndex, + activeLineIndex: Int + ) -> Bool { + guard lineNumber != activeLineIndex, lineNumber > 0 else { return false } + + var isInside = false + var lineCursor = 0 + while lineCursor < lineNumber { + if let line = lineIndex.editorLine(at: lineCursor, activeLineIndex: activeLineIndex), + case .fencedCodeFence = lineKind(for: line) { + isInside.toggle() + } + lineCursor += 1 + } + return isInside + } } private struct InlineMatch { diff --git a/Tests/SaplingEditorTests/MarkdownTextStylerRenderingTests.swift b/Tests/SaplingEditorTests/MarkdownTextStylerRenderingTests.swift new file mode 100644 index 0000000..21f6e36 --- /dev/null +++ b/Tests/SaplingEditorTests/MarkdownTextStylerRenderingTests.swift @@ -0,0 +1,126 @@ +import XCTest +@testable import SaplingEditor + +#if os(macOS) +import AppKit + +final class MarkdownTextStylerRenderingTests: XCTestCase { + func testRenderedHeadingHidesMarkerWhileActiveHeadingKeepsSourceVisible() { + let source = "Intro\n# Heading" + let renderedStorage = styledStorage(source: source, activeLineIndex: 0) + let activeStorage = styledStorage(source: source, activeLineIndex: 1) + let headingMarker = (source as NSString).range(of: "#") + let headingText = (source as NSString).range(of: "Heading") + + XCTAssertTrue(isHidden(renderedStorage, at: headingMarker.location)) + XCTAssertGreaterThan(font(in: renderedStorage, at: headingText.location).pointSize, 20) + XCTAssertFalse(isHidden(activeStorage, at: headingMarker.location)) + } + + func testRenderedInlineMarkdownHidesDelimitersOnly() { + let source = "Intro\nThis has **bold**, *italic*, and `code`." + let storage = styledStorage(source: source, activeLineIndex: 0) + let nsSource = source as NSString + + XCTAssertTrue(isHidden(storage, at: nsSource.range(of: "**").location)) + XCTAssertFalse(isHidden(storage, at: nsSource.range(of: "bold").location)) + XCTAssertTrue(isHidden(storage, at: nsSource.range(of: "*italic*").location)) + XCTAssertFalse(isHidden(storage, at: nsSource.range(of: "italic").location)) + XCTAssertTrue(isHidden(storage, at: nsSource.range(of: "`").location)) + XCTAssertNotNil(storage.attribute(.backgroundColor, at: nsSource.range(of: "code").location, effectiveRange: nil)) + } + + func testRenderedTaskListHidesListMarkerAndShowsTaskState() { + let source = "Intro\n* [x] Done\n* [ ] Todo" + let storage = styledStorage(source: source, activeLineIndex: 0) + let nsSource = source as NSString + let checkedMarker = nsSource.range(of: "* [x]") + let uncheckedMarker = nsSource.range(of: "* [ ]") + let doneRange = nsSource.range(of: "Done") + + XCTAssertTrue(isHidden(storage, at: checkedMarker.location)) + XCTAssertFalse(isHidden(storage, at: checkedMarker.location + 2)) + XCTAssertTrue(isHidden(storage, at: uncheckedMarker.location)) + XCTAssertFalse(isHidden(storage, at: uncheckedMarker.location + 2)) + XCTAssertNotNil(storage.attribute(.strikethroughStyle, at: doneRange.location, effectiveRange: nil)) + } + + func testRenderedCodeBlockHidesFencesAndStylesCodeContent() { + let source = "Intro\n```swift\nlet value = 42\n```" + let storage = styledStorage(source: source, activeLineIndex: 0) + let nsSource = source as NSString + let openingFence = nsSource.range(of: "```swift") + let language = nsSource.range(of: "swift") + let code = nsSource.range(of: "let value = 42") + let closingFence = nsSource.range(of: "```", options: .backwards) + + XCTAssertTrue(isHidden(storage, at: openingFence.location)) + XCTAssertFalse(isHidden(storage, at: language.location)) + XCTAssertNotNil(storage.attribute(.backgroundColor, at: code.location, effectiveRange: nil)) + XCTAssertEqual(font(in: storage, at: code.location).fontDescriptor.symbolicTraits.contains(.monoSpace), true) + XCTAssertTrue(isHidden(storage, at: closingFence.location)) + } + + func testDirtyCodeBlockRenderingResolvesBlockContext() { + let source = "Intro\n```swift\nlet value = 42\n```" + let storage = NSTextStorage(string: source) + let lineIndex = DocumentLineIndex(source: source) + let codeLine = 2 + + MarkdownTextStyler.apply( + to: storage, + lineIndex: lineIndex, + invalidationPlan: EditorDirtyLineInvalidationPlan( + reason: .activeLineChange, + isFullRender: false, + dirtyLineIndexes: [codeLine] + ), + activeLineIndex: 0, + backgroundColor: .textBackgroundColor, + activeLineBackgroundColor: .controlAccentColor.withAlphaComponent(0.10), + textColor: .labelColor, + secondaryTextColor: .secondaryLabelColor, + accentColor: .controlAccentColor + ) + + let code = (source as NSString).range(of: "let value = 42") + XCTAssertNotNil(storage.attribute(.backgroundColor, at: code.location, effectiveRange: nil)) + XCTAssertEqual(font(in: storage, at: code.location).fontDescriptor.symbolicTraits.contains(.monoSpace), true) + } + + private func styledStorage(source: String, activeLineIndex: Int) -> NSTextStorage { + let storage = NSTextStorage(string: source) + let lineIndex = DocumentLineIndex(source: source) + MarkdownTextStyler.apply( + to: storage, + lineIndex: lineIndex, + invalidationPlan: EditorDirtyLineInvalidator.plan( + previousText: nil, + currentLineIndex: lineIndex, + edit: nil, + previousActiveLineIndex: nil, + currentActiveLineIndex: activeLineIndex + ), + activeLineIndex: activeLineIndex, + backgroundColor: .textBackgroundColor, + activeLineBackgroundColor: .controlAccentColor.withAlphaComponent(0.10), + textColor: .labelColor, + secondaryTextColor: .secondaryLabelColor, + accentColor: .controlAccentColor + ) + return storage + } + + private func isHidden(_ storage: NSTextStorage, at location: Int) -> Bool { + let color = storage.attribute(.foregroundColor, at: location, effectiveRange: nil) as? NSColor + return color?.alphaComponent == 0 && font(in: storage, at: location).pointSize < 1 + } + + private func font(in storage: NSTextStorage, at location: Int) -> NSFont { + guard let font = storage.attribute(.font, at: location, effectiveRange: nil) as? NSFont else { + return NSFont.systemFont(ofSize: 0) + } + return font + } +} +#endif