fix(renderer): hide rendered markdown syntax

This commit is contained in:
Feror 2026-05-31 22:21:03 +02:00
parent c9afdcaa84
commit 2f64d9cd83
3 changed files with 261 additions and 16 deletions

View file

@ -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
}

View file

@ -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 {

View file

@ -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