mirror of
https://github.com/nicoverbruggen/phpmon.git
synced 2026-03-28 06:50:08 +01:00
✨ Improved Markdown handling
This commit is contained in:
@@ -15,6 +15,8 @@ class CodeBlockTextView: NSTextView {
|
||||
private let codePaddingY: CGFloat = 1
|
||||
private let codeCornerRadius: CGFloat = 4
|
||||
|
||||
private lazy var appColor: NSColor = NSColor(named: "AppColor") ?? .systemBlue
|
||||
|
||||
override func draw(_ dirtyRect: NSRect) {
|
||||
drawCodeBackgrounds()
|
||||
super.draw(dirtyRect)
|
||||
@@ -33,14 +35,20 @@ class CodeBlockTextView: NSTextView {
|
||||
guard let textStorage, let layoutManager, let textContainer else { return }
|
||||
|
||||
let codeSpanKey = MarkdownTextViewRepresentable.codeSpanKey
|
||||
let appColor = NSColor(named: "AppColor") ?? .systemBlue
|
||||
|
||||
textStorage.enumerateAttribute(codeSpanKey, in: NSRange(location: 0, length: textStorage.length)) { value, range, _ in
|
||||
guard value != nil else { return }
|
||||
|
||||
// Get the glyph range and bounding rect for this code span
|
||||
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
|
||||
let rect = textRect.offsetBy(dx: textContainerInset.width, dy: textContainerInset.height)
|
||||
|
||||
@@ -14,6 +14,20 @@ struct MarkdownTextViewRepresentable: NSViewRepresentable {
|
||||
let string: String
|
||||
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 {
|
||||
let textView = CodeBlockTextView()
|
||||
textView.isEditable = false
|
||||
@@ -24,84 +38,171 @@ struct MarkdownTextViewRepresentable: NSViewRepresentable {
|
||||
textView.setContentHuggingPriority(.defaultHigh, for: .vertical)
|
||||
textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||
textView.setContentCompressionResistancePriority(.required, for: .vertical)
|
||||
configure(textView)
|
||||
configure(textView, coordinator: context.coordinator)
|
||||
return textView
|
||||
}
|
||||
|
||||
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)
|
||||
textView.textStorage?.setAttributedString(attributed)
|
||||
textView.invalidateIntrinsicContentSize()
|
||||
}
|
||||
|
||||
class Coordinator {
|
||||
var lastString: String?
|
||||
var lastFontSize: CGFloat?
|
||||
}
|
||||
|
||||
// MARK: - Attributed String Builder
|
||||
|
||||
static func buildAttributedString(from string: String, fontSize: CGFloat) -> NSAttributedString {
|
||||
let result = NSMutableAttributedString()
|
||||
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 thinSpace = "\u{2009}\u{2009}\u{2009}"
|
||||
let paragraphStyle = NSMutableParagraphStyle()
|
||||
paragraphStyle.lineSpacing = 3
|
||||
paragraphStyle.paragraphSpacing = -4
|
||||
|
||||
let defaultAttributes: [NSAttributedString.Key: Any] = [
|
||||
.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,
|
||||
Self.codeSpanKey: true
|
||||
.paragraphStyle: paragraphStyle
|
||||
]
|
||||
result.append(NSAttributedString(
|
||||
string: String(string[afterBacktick..<backtickEnd]),
|
||||
attributes: codeAttributes
|
||||
))
|
||||
|
||||
// Thin space after
|
||||
result.append(NSAttributedString(string: thinSpace, attributes: defaultAttributes))
|
||||
// Plain text first
|
||||
result.append(NSAttributedString(string: string, attributes: defaultAttributes))
|
||||
|
||||
current = string.index(after: backtickEnd)
|
||||
} else {
|
||||
result.append(NSAttributedString(
|
||||
string: String(string[backtickStart...]),
|
||||
attributes: defaultAttributes
|
||||
))
|
||||
current = string.endIndex
|
||||
}
|
||||
}
|
||||
// Apply markup passes (order matters: code first to avoid matching * inside code spans)
|
||||
handleCodeMarkup(in: result, fontSize: fontSize, paragraphStyle: paragraphStyle)
|
||||
|
||||
if current < string.endIndex {
|
||||
result.append(NSAttributedString(
|
||||
string: String(string[current...]),
|
||||
attributes: defaultAttributes
|
||||
))
|
||||
}
|
||||
// Collect code span ranges once for bold and italic passes
|
||||
let codeRanges = codeSpanRanges(in: result)
|
||||
handleBoldMarkup(in: result, fontSize: fontSize, paragraphStyle: paragraphStyle, codeRanges: codeRanges)
|
||||
handleItalicMarkup(in: result, fontSize: fontSize, paragraphStyle: paragraphStyle, codeRanges: codeRanges)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user