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
|
let lines = invalidationPlan.isFullRender
|
||||||
? lineIndex.editorLines(activeLineIndex: activeLineIndex)
|
? lineIndex.editorLines(activeLineIndex: activeLineIndex)
|
||||||
: lineIndex.editorLines(for: invalidationPlan.dirtyLineIndexes, activeLineIndex: activeLineIndex)
|
: lineIndex.editorLines(for: invalidationPlan.dirtyLineIndexes, activeLineIndex: activeLineIndex)
|
||||||
let renderPlansByLine = Dictionary(
|
let renderPlans = invalidationPlan.isFullRender || !linesNeedCodeBlockContext(lines, lineIndex: lineIndex)
|
||||||
uniqueKeysWithValues: renderer.renderPlans(for: lines).map { ($0.line.index, $0) }
|
? 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
|
var styledLineCount = 0
|
||||||
for line in lines {
|
for line in lines {
|
||||||
resetAttributes(in: textStorage, line: line, textColor: textColor)
|
resetAttributes(in: textStorage, line: line, textColor: textColor)
|
||||||
|
|
@ -697,6 +702,27 @@ enum MarkdownTextStyler {
|
||||||
return MarkdownTextStylingResult(totalLineCount: lineIndex.lineCount, styledLineCount: styledLineCount)
|
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(
|
private static func resetAttributes(
|
||||||
in textStorage: NSTextStorage,
|
in textStorage: NSTextStorage,
|
||||||
line: EditorLine,
|
line: EditorLine,
|
||||||
|
|
@ -719,10 +745,10 @@ enum MarkdownTextStyler {
|
||||||
|
|
||||||
switch renderPlan.kind {
|
switch renderPlan.kind {
|
||||||
case .heading(let level, let markerRange, let textRange):
|
case .heading(let level, let markerRange, let textRange):
|
||||||
textStorage.addAttributes([
|
hideSyntax(
|
||||||
.foregroundColor: secondaryTextColor,
|
in: textStorage,
|
||||||
.font: monospacedFont(size: 13, weight: .regular)
|
range: NSRange(location: markerRange.location, length: textRange.location - markerRange.location)
|
||||||
], range: markerRange)
|
)
|
||||||
textStorage.addAttributes([
|
textStorage.addAttributes([
|
||||||
.font: systemFont(size: headingFontSize(level: level), weight: .semibold),
|
.font: systemFont(size: headingFontSize(level: level), weight: .semibold),
|
||||||
.paragraphStyle: headingParagraphStyle(level: level)
|
.paragraphStyle: headingParagraphStyle(level: level)
|
||||||
|
|
@ -765,19 +791,18 @@ enum MarkdownTextStyler {
|
||||||
secondaryTextColor: secondaryTextColor
|
secondaryTextColor: secondaryTextColor
|
||||||
)
|
)
|
||||||
case .taskList(let markerRange, let checkboxRange, let contentRange, let checked, let nestingLevel):
|
case .taskList(let markerRange, let checkboxRange, let contentRange, let checked, let nestingLevel):
|
||||||
styleListLine(
|
styleTaskListLine(
|
||||||
in: textStorage,
|
in: textStorage,
|
||||||
lineRange: line.range,
|
lineRange: line.range,
|
||||||
markerRange: markerRange,
|
markerRange: markerRange,
|
||||||
|
checkboxRange: checkboxRange,
|
||||||
contentRange: contentRange,
|
contentRange: contentRange,
|
||||||
|
checked: checked,
|
||||||
nestingLevel: nestingLevel,
|
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 {
|
if checked {
|
||||||
textStorage.addAttributes([
|
textStorage.addAttributes([
|
||||||
.strikethroughStyle: NSUnderlineStyle.single.rawValue,
|
.strikethroughStyle: NSUnderlineStyle.single.rawValue,
|
||||||
|
|
@ -786,12 +811,17 @@ enum MarkdownTextStyler {
|
||||||
}
|
}
|
||||||
case .fencedCodeFence(let markerRange, let languageRange):
|
case .fencedCodeFence(let markerRange, let languageRange):
|
||||||
textStorage.addAttributes(codeBlockAttributes(accentColor: accentColor), range: line.range)
|
textStorage.addAttributes(codeBlockAttributes(accentColor: accentColor), range: line.range)
|
||||||
textStorage.addAttributes([.foregroundColor: secondaryTextColor], range: markerRange)
|
|
||||||
if let languageRange {
|
if let languageRange {
|
||||||
|
hideSyntax(
|
||||||
|
in: textStorage,
|
||||||
|
range: NSRange(location: markerRange.location, length: languageRange.location - markerRange.location)
|
||||||
|
)
|
||||||
textStorage.addAttributes([
|
textStorage.addAttributes([
|
||||||
.foregroundColor: accentColor,
|
.foregroundColor: accentColor,
|
||||||
.font: monospacedFont(size: 13, weight: .semibold)
|
.font: monospacedFont(size: 13, weight: .semibold)
|
||||||
], range: languageRange)
|
], range: languageRange)
|
||||||
|
} else {
|
||||||
|
hideSyntax(in: textStorage, range: line.range)
|
||||||
}
|
}
|
||||||
case .codeBlockContent:
|
case .codeBlockContent:
|
||||||
textStorage.addAttributes(codeBlockAttributes(accentColor: accentColor), range: line.range)
|
textStorage.addAttributes(codeBlockAttributes(accentColor: accentColor), range: line.range)
|
||||||
|
|
@ -845,7 +875,7 @@ enum MarkdownTextStyler {
|
||||||
.underlineStyle: NSUnderlineStyle.single.rawValue
|
.underlineStyle: NSUnderlineStyle.single.rawValue
|
||||||
], range: span.range)
|
], range: span.range)
|
||||||
case .markdownDelimiter:
|
case .markdownDelimiter:
|
||||||
textStorage.addAttributes([.foregroundColor: secondaryTextColor], range: span.range)
|
hideSyntax(in: textStorage, range: span.range)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -970,6 +1000,43 @@ enum MarkdownTextStyler {
|
||||||
], range: contentRange)
|
], 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)
|
#if os(macOS)
|
||||||
private static func systemFont(size: CGFloat, weight: NSFont.Weight) -> NSFont {
|
private static func systemFont(size: CGFloat, weight: NSFont.Weight) -> NSFont {
|
||||||
NSFont.systemFont(ofSize: size, weight: weight)
|
NSFont.systemFont(ofSize: size, weight: weight)
|
||||||
|
|
@ -982,6 +1049,10 @@ enum MarkdownTextStyler {
|
||||||
private static func monospacedFont(size: CGFloat, weight: NSFont.Weight) -> NSFont {
|
private static func monospacedFont(size: CGFloat, weight: NSFont.Weight) -> NSFont {
|
||||||
NSFont.monospacedSystemFont(ofSize: size, weight: weight)
|
NSFont.monospacedSystemFont(ofSize: size, weight: weight)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func clearColor() -> NSColor {
|
||||||
|
.clear
|
||||||
|
}
|
||||||
#elseif os(iOS)
|
#elseif os(iOS)
|
||||||
private static func systemFont(size: CGFloat, weight: UIFont.Weight) -> UIFont {
|
private static func systemFont(size: CGFloat, weight: UIFont.Weight) -> UIFont {
|
||||||
UIFont.systemFont(ofSize: size, weight: weight)
|
UIFont.systemFont(ofSize: size, weight: weight)
|
||||||
|
|
@ -994,6 +1065,10 @@ enum MarkdownTextStyler {
|
||||||
private static func monospacedFont(size: CGFloat, weight: UIFont.Weight) -> UIFont {
|
private static func monospacedFont(size: CGFloat, weight: UIFont.Weight) -> UIFont {
|
||||||
UIFont.monospacedSystemFont(ofSize: size, weight: weight)
|
UIFont.monospacedSystemFont(ofSize: size, weight: weight)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func clearColor() -> UIColor {
|
||||||
|
.clear
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,31 @@ public struct HybridMarkdownLineRenderer: Sendable {
|
||||||
return plans
|
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 {
|
private func lineKind(for line: EditorLine) -> HybridMarkdownLineKind {
|
||||||
let firstToken = firstNonWhitespaceCharacter(in: line.source)
|
let firstToken = firstNonWhitespaceCharacter(in: line.source)
|
||||||
|
|
||||||
|
|
@ -480,6 +505,25 @@ public struct HybridMarkdownLineRenderer: Sendable {
|
||||||
private func firstNonWhitespaceCharacter(in source: String) -> Character? {
|
private func firstNonWhitespaceCharacter(in source: String) -> Character? {
|
||||||
source.first { $0 != " " && $0 != "\t" }
|
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 {
|
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