1
0
mirror of https://github.com/nicoverbruggen/phpmon.git synced 2026-03-28 15:00:06 +01:00

Improved Markdown handling

This commit is contained in:
2026-03-03 12:21:50 +01:00
parent edc31496d7
commit 69c8483725
2 changed files with 163 additions and 54 deletions

View File

@@ -15,6 +15,8 @@ class CodeBlockTextView: NSTextView {
private let codePaddingY: CGFloat = 1 private let codePaddingY: CGFloat = 1
private let codeCornerRadius: CGFloat = 4 private let codeCornerRadius: CGFloat = 4
private lazy var appColor: NSColor = NSColor(named: "AppColor") ?? .systemBlue
override func draw(_ dirtyRect: NSRect) { override func draw(_ dirtyRect: NSRect) {
drawCodeBackgrounds() drawCodeBackgrounds()
super.draw(dirtyRect) super.draw(dirtyRect)
@@ -33,14 +35,20 @@ class CodeBlockTextView: NSTextView {
guard let textStorage, let layoutManager, let textContainer else { return } guard let textStorage, let layoutManager, let textContainer else { return }
let codeSpanKey = MarkdownTextViewRepresentable.codeSpanKey let codeSpanKey = MarkdownTextViewRepresentable.codeSpanKey
let appColor = NSColor(named: "AppColor") ?? .systemBlue
textStorage.enumerateAttribute(codeSpanKey, in: NSRange(location: 0, length: textStorage.length)) { value, range, _ in textStorage.enumerateAttribute(codeSpanKey, in: NSRange(location: 0, length: textStorage.length)) { value, range, _ in
guard value != nil else { return } guard value != nil else { return }
// Get the glyph range and bounding rect for this code span // Get the glyph range and bounding rect for this code span
let glyphRange = layoutManager.glyphRange(forCharacterRange: range, actualCharacterRange: nil) let glyphRange = layoutManager.glyphRange(forCharacterRange: range, actualCharacterRange: nil)
let textRect = layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer) var textRect = layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer)
// Trim line spacing from the rect height so the background fits the text tightly
let font = textStorage.attribute(.font, at: range.location, effectiveRange: nil) as? NSFont
let lineHeight = font?.ascender ?? 0 + abs(font?.descender ?? 0) + (font?.leading ?? 0)
if lineHeight > 0 && textRect.height > lineHeight {
textRect.size.height = lineHeight + 5 // added 5px for optimal size
}
// Offset by text container inset // Offset by text container inset
let rect = textRect.offsetBy(dx: textContainerInset.width, dy: textContainerInset.height) let rect = textRect.offsetBy(dx: textContainerInset.width, dy: textContainerInset.height)

View File

@@ -14,6 +14,20 @@ struct MarkdownTextViewRepresentable: NSViewRepresentable {
let string: String let string: String
let fontSize: CGFloat let fontSize: CGFloat
// MARK: - Static Properties
static let codeSpanKey = NSAttributedString.Key("PHPMonitorCodeSpan")
// swiftlint:disable force_try
private static let codeRegex = try! NSRegularExpression(pattern: "`([^`]+)`")
private static let boldRegex = try! NSRegularExpression(pattern: "\\*\\*([^*]+)\\*\\*")
private static let italicRegex = try! NSRegularExpression(pattern: "(?<!\\*)\\*([^*]+)\\*(?!\\*)")
// swiftlint:enable force_try
func makeCoordinator() -> Coordinator {
Coordinator()
}
func makeNSView(context: Context) -> CodeBlockTextView { func makeNSView(context: Context) -> CodeBlockTextView {
let textView = CodeBlockTextView() let textView = CodeBlockTextView()
textView.isEditable = false textView.isEditable = false
@@ -24,84 +38,171 @@ struct MarkdownTextViewRepresentable: NSViewRepresentable {
textView.setContentHuggingPriority(.defaultHigh, for: .vertical) textView.setContentHuggingPriority(.defaultHigh, for: .vertical)
textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
textView.setContentCompressionResistancePriority(.required, for: .vertical) textView.setContentCompressionResistancePriority(.required, for: .vertical)
configure(textView) configure(textView, coordinator: context.coordinator)
return textView return textView
} }
func updateNSView(_ textView: CodeBlockTextView, context: Context) { func updateNSView(_ textView: CodeBlockTextView, context: Context) {
configure(textView) let coordinator = context.coordinator
guard string != coordinator.lastString || fontSize != coordinator.lastFontSize else { return }
configure(textView, coordinator: coordinator)
} }
private func configure(_ textView: CodeBlockTextView) { private func configure(_ textView: CodeBlockTextView, coordinator: Coordinator) {
coordinator.lastString = string
coordinator.lastFontSize = fontSize
let attributed = Self.buildAttributedString(from: string, fontSize: fontSize) let attributed = Self.buildAttributedString(from: string, fontSize: fontSize)
textView.textStorage?.setAttributedString(attributed) textView.textStorage?.setAttributedString(attributed)
textView.invalidateIntrinsicContentSize() textView.invalidateIntrinsicContentSize()
} }
class Coordinator {
var lastString: String?
var lastFontSize: CGFloat?
}
// MARK: - Attributed String Builder // MARK: - Attributed String Builder
static func buildAttributedString(from string: String, fontSize: CGFloat) -> NSAttributedString { static func buildAttributedString(from string: String, fontSize: CGFloat) -> NSAttributedString {
let result = NSMutableAttributedString() let result = NSMutableAttributedString()
let font = NSFont.systemFont(ofSize: fontSize) let font = NSFont.systemFont(ofSize: fontSize)
let codeFont = NSFont.monospacedSystemFont(ofSize: fontSize - 1, weight: .regular)
// Add additional spacing for code blocks w/ thin spaces let paragraphStyle = NSMutableParagraphStyle()
let thinSpace = "\u{2009}\u{2009}\u{2009}" paragraphStyle.lineSpacing = 3
paragraphStyle.paragraphSpacing = -4
let defaultAttributes: [NSAttributedString.Key: Any] = [ let defaultAttributes: [NSAttributedString.Key: Any] = [
.font: font, .font: font,
.foregroundColor: NSColor.labelColor
]
var current = string.startIndex
while let backtickStart = string[current...].firstIndex(of: "`") {
if current < backtickStart {
result.append(NSAttributedString(
string: String(string[current..<backtickStart]),
attributes: defaultAttributes
))
}
let afterBacktick = string.index(after: backtickStart)
if afterBacktick < string.endIndex,
let backtickEnd = string[afterBacktick...].firstIndex(of: "`") {
// Thin space before
result.append(NSAttributedString(string: thinSpace, attributes: defaultAttributes))
// Code span with marker attribute
let codeAttributes: [NSAttributedString.Key: Any] = [
.font: codeFont,
.foregroundColor: NSColor.labelColor, .foregroundColor: NSColor.labelColor,
Self.codeSpanKey: true .paragraphStyle: paragraphStyle
] ]
result.append(NSAttributedString(
string: String(string[afterBacktick..<backtickEnd]),
attributes: codeAttributes
))
// Thin space after // Plain text first
result.append(NSAttributedString(string: thinSpace, attributes: defaultAttributes)) result.append(NSAttributedString(string: string, attributes: defaultAttributes))
current = string.index(after: backtickEnd) // Apply markup passes (order matters: code first to avoid matching * inside code spans)
} else { handleCodeMarkup(in: result, fontSize: fontSize, paragraphStyle: paragraphStyle)
result.append(NSAttributedString(
string: String(string[backtickStart...]),
attributes: defaultAttributes
))
current = string.endIndex
}
}
if current < string.endIndex { // Collect code span ranges once for bold and italic passes
result.append(NSAttributedString( let codeRanges = codeSpanRanges(in: result)
string: String(string[current...]), handleBoldMarkup(in: result, fontSize: fontSize, paragraphStyle: paragraphStyle, codeRanges: codeRanges)
attributes: defaultAttributes handleItalicMarkup(in: result, fontSize: fontSize, paragraphStyle: paragraphStyle, codeRanges: codeRanges)
))
}
return result return result
} }
static let codeSpanKey = NSAttributedString.Key("PHPMonitorCodeSpan") // MARK: - Markup Handlers
/// Replaces `` `code` `` with monospaced font and kern-based padding.
private static func handleCodeMarkup(
in result: NSMutableAttributedString,
fontSize: CGFloat,
paragraphStyle: NSParagraphStyle
) {
let codeFont = NSFont.monospacedSystemFont(ofSize: fontSize - 1, weight: .regular)
let thinSpace = "\u{2009}" // Thin space for visual padding around code spans
let fullRange = NSRange(location: 0, length: result.length)
let matches = codeRegex.matches(in: result.string, range: fullRange).reversed()
for match in matches {
let innerRange = match.range(at: 1)
let innerText = (result.string as NSString).substring(with: innerRange)
let spaceAttributes: [NSAttributedString.Key: Any] = [
.font: codeFont,
.foregroundColor: NSColor.labelColor,
.paragraphStyle: paragraphStyle
]
// Build: thin space + code span (with marker) + thin space
let replacement = NSMutableAttributedString()
replacement.append(NSAttributedString(string: thinSpace, attributes: spaceAttributes))
replacement.append(NSAttributedString(
string: innerText,
attributes: spaceAttributes.merging([Self.codeSpanKey: true]) { _, new in new }
))
replacement.append(NSAttributedString(string: thinSpace, attributes: spaceAttributes))
result.replaceCharacters(in: match.range, with: replacement)
}
}
/// Collects all ranges marked as code spans.
private static func codeSpanRanges(in result: NSMutableAttributedString) -> [NSRange] {
var ranges: [NSRange] = []
result.enumerateAttribute(codeSpanKey, in: NSRange(location: 0, length: result.length)) { value, range, _ in
if value != nil { ranges.append(range) }
}
return ranges
}
/// Returns matches from the regex that don't overlap with any of the provided code span ranges.
private static func nonCodeSpanMatches(
in result: NSMutableAttributedString,
regex: NSRegularExpression,
codeRanges: [NSRange]
) -> [NSTextCheckingResult] {
let fullRange = NSRange(location: 0, length: result.length)
return regex.matches(in: result.string, range: fullRange).filter { match in
!codeRanges.contains { codeRange in
NSIntersectionRange(match.range, codeRange).length > 0
}
}
}
/// Replaces `**bold**` with bold font.
private static func handleBoldMarkup(
in result: NSMutableAttributedString,
fontSize: CGFloat,
paragraphStyle: NSParagraphStyle,
codeRanges: [NSRange]
) {
let boldFont = NSFont.boldSystemFont(ofSize: fontSize)
for match in nonCodeSpanMatches(in: result, regex: boldRegex, codeRanges: codeRanges).reversed() {
let innerRange = match.range(at: 1)
let innerText = (result.string as NSString).substring(with: innerRange)
let replacement = NSAttributedString(
string: innerText,
attributes: [
.font: boldFont,
.foregroundColor: NSColor.labelColor,
.paragraphStyle: paragraphStyle
]
)
result.replaceCharacters(in: match.range, with: replacement)
}
}
/// Replaces `*italic*` with italic font.
private static func handleItalicMarkup(
in result: NSMutableAttributedString,
fontSize: CGFloat,
paragraphStyle: NSParagraphStyle,
codeRanges: [NSRange]
) {
let italicFont = NSFontManager.shared.convert(
NSFont.systemFont(ofSize: fontSize),
toHaveTrait: .italicFontMask
)
for match in nonCodeSpanMatches(in: result, regex: italicRegex, codeRanges: codeRanges).reversed() {
let innerRange = match.range(at: 1)
let innerText = (result.string as NSString).substring(with: innerRange)
let replacement = NSAttributedString(
string: innerText,
attributes: [
.font: italicFont,
.foregroundColor: NSColor.labelColor,
.paragraphStyle: paragraphStyle
]
)
result.replaceCharacters(in: match.range, with: replacement)
}
}
} }