1
0
mirror of https://github.com/nicoverbruggen/phpmon.git synced 2026-03-25 13:40:08 +01:00

♻️ Refactor and annotate code blocks

This commit is contained in:
2026-03-03 13:57:08 +01:00
parent 818246b0e5
commit 6bfde538b7
2 changed files with 126 additions and 101 deletions

View File

@@ -9,18 +9,31 @@
import SwiftUI
import AppKit
/// Note: Written with the help of an LLM.
class CodeBlockTextView: NSTextView {
private let codePaddingX: CGFloat = 4
private let codePaddingY: CGFloat = 0
private let codeCornerRadius: CGFloat = 2
/**
A text view that draws rounded backgrounds behind inline code spans.
Code spans are identified by the `.codeSpan` attributed string key,
which is set by `MarkdownTextViewRepresentable` during string building.
*/
class CodeBlockTextView: NSTextView {
// MARK: - Appearance
// Color
private lazy var appColor: NSColor = NSColor(named: "AppColor") ?? .systemBlue
/**
When we have selected text in a code block, we need to sanitize the output that will be copied to the pasteboard.
// Padding
private let codePaddingX: CGFloat = 4
private let codePaddingY: CGFloat = 0
This means stripping thin spaces, no-break spaces and using Markdown's annotation for code blocks.
// Corner radius
private let codeCornerRadius: CGFloat = 2
// MARK: - Copy
/**
When copying selected text, we sanitize it so special layout characters
are stripped and code spans are wrapped in backticks again.
*/
override func copy(_ sender: Any?) {
guard let textStorage, selectedRange().length > 0 else {
@@ -29,17 +42,13 @@ class CodeBlockTextView: NSTextView {
}
let selected = textStorage.attributedSubstring(from: selectedRange())
let codeSpanKey = MarkdownTextViewRepresentable.codeSpanKey
// Rebuild the string, wrapping code spans in backticks and cleaning up special characters
let result = NSMutableString()
selected.enumerateAttribute(codeSpanKey, in: NSRange(location: 0, length: selected.length)) { value, range, _ in
var fragment = (selected.string as NSString).substring(with: range)
fragment = fragment
.replacingOccurrences(of: "\u{2009}", with: "") // Remove thin spaces (leading padding)
.replacingOccurrences(of: "\u{202F}", with: "") // Remove narrow no-break spaces (trailing padding)
.replacingOccurrences(of: "\u{2060}", with: "") // Remove word joiners
.replacingOccurrences(of: "\u{00A0}", with: " ") // Restore regular spaces
selected.enumerateAttribute(.codeSpan, in: NSRange(location: 0, length: selected.length)) { value, range, _ in
let fragment = (selected.string as NSString).substring(with: range)
.filter { $0 != .thinSpace && $0 != .nbThinSpace && $0 != .wordJoiner }
.map { $0 == .nbSpace ? " " : String($0) }
.joined()
if value != nil {
result.append("`\(fragment)`")
@@ -52,6 +61,8 @@ class CodeBlockTextView: NSTextView {
NSPasteboard.general.setString(result as String, forType: .string)
}
// MARK: - Drawing
override func draw(_ dirtyRect: NSRect) {
drawCodeBackgrounds()
super.draw(dirtyRect)
@@ -66,17 +77,18 @@ class CodeBlockTextView: NSTextView {
return NSSize(width: NSView.noIntrinsicMetric, height: ceil(rect.height))
}
/**
Draws a rounded background rect behind each code span, using per-line
rects so a wrapped span still gets tight individual backgrounds.
*/
private func drawCodeBackgrounds() {
guard let textStorage, let layoutManager, let textContainer else { return }
let codeSpanKey = MarkdownTextViewRepresentable.codeSpanKey
textStorage.enumerateAttribute(codeSpanKey, in: NSRange(location: 0, length: textStorage.length)) { value, range, _ in
textStorage.enumerateAttribute(.codeSpan, in: NSRange(location: 0, length: textStorage.length)) { value, range, _ in
guard value != nil else { return }
let glyphRange = layoutManager.glyphRange(forCharacterRange: range, actualCharacterRange: nil)
// Enumerate per-line rects so wrapped code spans get individual backgrounds
layoutManager.enumerateEnclosingRects(
forGlyphRange: glyphRange,
withinSelectedGlyphRange: NSRange(location: NSNotFound, length: 0),

View File

@@ -9,21 +9,16 @@
import SwiftUI
import AppKit
/// Note: Written with the help of an LLM.
/**
Bridges a `CodeBlockTextView` into SwiftUI and builds an attributed string
from a simplified Markdown subset (inline code, bold and italic).
*/
struct MarkdownTextViewRepresentable: NSViewRepresentable {
let string: String
let fontSize: CGFloat
let textColor: NSColor
// 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
// MARK: - NSViewRepresentable
func makeCoordinator() -> Coordinator {
Coordinator()
@@ -43,13 +38,19 @@ struct MarkdownTextViewRepresentable: NSViewRepresentable {
return textView
}
func updateNSView(_ textView: CodeBlockTextView, context: Context) {
func updateNSView(
_ textView: CodeBlockTextView,
context: Context
) {
let coordinator = context.coordinator
guard string != coordinator.lastString || fontSize != coordinator.lastFontSize || textColor != coordinator.lastTextColor else { return }
configure(textView, coordinator: coordinator)
}
private func configure(_ textView: CodeBlockTextView, coordinator: Coordinator) {
private func configure(
_ textView: CodeBlockTextView,
coordinator: Coordinator
) {
coordinator.lastString = string
coordinator.lastFontSize = fontSize
coordinator.lastTextColor = textColor
@@ -66,7 +67,17 @@ struct MarkdownTextViewRepresentable: NSViewRepresentable {
// MARK: - Attributed String Builder
static func buildAttributedString(from string: String, fontSize: CGFloat, textColor: NSColor = .labelColor) -> NSAttributedString {
// 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
static func buildAttributedString(
from string: String,
fontSize: CGFloat,
textColor: NSColor = .labelColor
) -> NSAttributedString {
let result = NSMutableAttributedString()
let font = NSFont.systemFont(ofSize: fontSize)
@@ -80,7 +91,6 @@ struct MarkdownTextViewRepresentable: NSViewRepresentable {
.paragraphStyle: paragraphStyle
]
// Plain text first
result.append(NSAttributedString(string: string, attributes: defaultAttributes))
// Apply markup passes (order matters: code first to avoid matching * inside code spans)
@@ -88,34 +98,57 @@ struct MarkdownTextViewRepresentable: NSViewRepresentable {
// Collect code span ranges once for bold and italic passes
let codeRanges = codeSpanRanges(in: result)
handleBoldMarkup(in: result, fontSize: fontSize, paragraphStyle: paragraphStyle, textColor: textColor, codeRanges: codeRanges)
handleItalicMarkup(in: result, fontSize: fontSize, paragraphStyle: paragraphStyle, textColor: textColor, codeRanges: codeRanges)
// Handle bold markup
handleStyledMarkup(
in: result,
regex: boldRegex,
font: NSFont.boldSystemFont(ofSize: fontSize),
paragraphStyle: paragraphStyle,
textColor: textColor,
codeRanges: codeRanges
)
// Handle italic markup
handleStyledMarkup(
in: result,
regex: italicRegex,
font: NSFontManager.shared.convert(font, toHaveTrait: .italicFontMask),
paragraphStyle: paragraphStyle,
textColor: textColor,
codeRanges: codeRanges
)
return result
}
// MARK: - Markup Handlers
/// Replaces `` `code` `` with monospaced font and kern-based padding.
/**
Replaces `` `code` `` with monospaced font and padding spaces.
The leading thin space is breakable so the code span can shift to the next line
as a whole, while the trailing narrow no-break space stays glued to the span.
Spaces and hyphens inside the code span are made non-breaking so the layout
engine never splits the code span across lines.
*/
private static func handleCodeMarkup(
in result: NSMutableAttributedString,
fontSize: CGFloat,
paragraphStyle: NSParagraphStyle
) {
let codeFont = NSFont.monospacedSystemFont(ofSize: fontSize - 1, weight: .regular)
let leadingSpace = "\u{2009}" // Thin space (breakable) so the code span can shift to the next line
let trailingSpace = "\u{202F}" // Narrow no-break space to stay attached to the code span
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)
// Replace spaces with non-breaking spaces and insert word joiners after hyphens
// to prevent line breaks anywhere inside code spans
// Make the code span non-breaking by replacing spaces and joining hyphens
let innerText = (result.string as NSString).substring(with: innerRange)
.replacingOccurrences(of: " ", with: "\u{00A0}")
.replacingOccurrences(of: "-", with: "-\u{2060}")
.replacingOccurrences(of: " ", with: String(Character.nbSpace))
.replacingOccurrences(of: "-", with: "-\(Character.wordJoiner)")
let spaceAttributes: [NSAttributedString.Key: Any] = [
.font: codeFont,
@@ -123,90 +156,56 @@ struct MarkdownTextViewRepresentable: NSViewRepresentable {
.paragraphStyle: paragraphStyle
]
// Build: leading space + code span (with marker) + trailing space
let replacement = NSMutableAttributedString()
replacement.append(NSAttributedString(string: leadingSpace, attributes: spaceAttributes))
replacement.append(NSAttributedString(string: String(Character.thinSpace), attributes: spaceAttributes))
replacement.append(NSAttributedString(
string: innerText,
attributes: spaceAttributes.merging([Self.codeSpanKey: true]) { _, new in new }
attributes: spaceAttributes.merging([.codeSpan: true]) { _, new in new }
))
replacement.append(NSAttributedString(string: trailingSpace, attributes: spaceAttributes))
replacement.append(NSAttributedString(string: String(Character.nbThinSpace), attributes: spaceAttributes))
result.replaceCharacters(in: match.range, with: replacement)
}
}
/// Collects all ranges marked as code spans.
private static func codeSpanRanges(in result: NSMutableAttributedString) -> [NSRange] {
/**
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
result.enumerateAttribute(.codeSpan, 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(
/**
Replaces markup like `**bold**` or `*italic*` with the appropriate font,
skipping any matches that overlap with an already-processed code span.
*/
private static func handleStyledMarkup(
in result: NSMutableAttributedString,
regex: NSRegularExpression,
font: NSFont,
paragraphStyle: NSParagraphStyle,
textColor: NSColor,
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
}
let matches = regex.matches(in: result.string, range: fullRange).filter { match in
!codeRanges.contains { NSIntersectionRange(match.range, $0).length > 0 }
}
}
/// Replaces `**bold**` with bold font.
private static func handleBoldMarkup(
in result: NSMutableAttributedString,
fontSize: CGFloat,
paragraphStyle: NSParagraphStyle,
textColor: NSColor,
codeRanges: [NSRange]
) {
let boldFont = NSFont.boldSystemFont(ofSize: fontSize)
for match in nonCodeSpanMatches(in: result, regex: boldRegex, codeRanges: codeRanges).reversed() {
for match in matches.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: textColor,
.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,
textColor: NSColor,
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,
.font: font,
.foregroundColor: textColor,
.paragraphStyle: paragraphStyle
]
@@ -216,3 +215,17 @@ struct MarkdownTextViewRepresentable: NSViewRepresentable {
}
}
}
// MARK: - Shared Constants
extension NSAttributedString.Key {
/// Marks a range as being part of an inline code span, used by `CodeBlockTextView` to draw backgrounds.
static let codeSpan = NSAttributedString.Key("PHPMonitorCodeSpan")
}
extension Character {
static let thinSpace: Character = "\u{2009}"
static let nbThinSpace: Character = "\u{202F}"
static let nbSpace: Character = "\u{00A0}"
static let wordJoiner: Character = "\u{2060}"
}