fix(renderer): hide rendered markdown syntax
This commit is contained in:
parent
c9afdcaa84
commit
2f64d9cd83
3 changed files with 261 additions and 16 deletions
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
126
Tests/SaplingEditorTests/MarkdownTextStylerRenderingTests.swift
Normal file
126
Tests/SaplingEditorTests/MarkdownTextStylerRenderingTests.swift
Normal 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
|
||||
Loading…
Add table
Reference in a new issue