mirror of
https://github.com/nicoverbruggen/phpmon.git
synced 2026-03-29 16:10:08 +02:00
♻️ Refactor and annotate code blocks
This commit is contained in:
@@ -9,18 +9,31 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import AppKit
|
import AppKit
|
||||||
|
|
||||||
/// Note: Written with the help of an LLM.
|
/**
|
||||||
class CodeBlockTextView: NSTextView {
|
A text view that draws rounded backgrounds behind inline code spans.
|
||||||
private let codePaddingX: CGFloat = 4
|
|
||||||
private let codePaddingY: CGFloat = 0
|
|
||||||
private let codeCornerRadius: CGFloat = 2
|
|
||||||
|
|
||||||
|
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
|
private lazy var appColor: NSColor = NSColor(named: "AppColor") ?? .systemBlue
|
||||||
|
|
||||||
/**
|
// Padding
|
||||||
When we have selected text in a code block, we need to sanitize the output that will be copied to the pasteboard.
|
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?) {
|
override func copy(_ sender: Any?) {
|
||||||
guard let textStorage, selectedRange().length > 0 else {
|
guard let textStorage, selectedRange().length > 0 else {
|
||||||
@@ -29,17 +42,13 @@ class CodeBlockTextView: NSTextView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let selected = textStorage.attributedSubstring(from: selectedRange())
|
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()
|
let result = NSMutableString()
|
||||||
selected.enumerateAttribute(codeSpanKey, in: NSRange(location: 0, length: selected.length)) { value, range, _ in
|
selected.enumerateAttribute(.codeSpan, in: NSRange(location: 0, length: selected.length)) { value, range, _ in
|
||||||
var fragment = (selected.string as NSString).substring(with: range)
|
let fragment = (selected.string as NSString).substring(with: range)
|
||||||
fragment = fragment
|
.filter { $0 != .thinSpace && $0 != .nbThinSpace && $0 != .wordJoiner }
|
||||||
.replacingOccurrences(of: "\u{2009}", with: "") // Remove thin spaces (leading padding)
|
.map { $0 == .nbSpace ? " " : String($0) }
|
||||||
.replacingOccurrences(of: "\u{202F}", with: "") // Remove narrow no-break spaces (trailing padding)
|
.joined()
|
||||||
.replacingOccurrences(of: "\u{2060}", with: "") // Remove word joiners
|
|
||||||
.replacingOccurrences(of: "\u{00A0}", with: " ") // Restore regular spaces
|
|
||||||
|
|
||||||
if value != nil {
|
if value != nil {
|
||||||
result.append("`\(fragment)`")
|
result.append("`\(fragment)`")
|
||||||
@@ -52,6 +61,8 @@ class CodeBlockTextView: NSTextView {
|
|||||||
NSPasteboard.general.setString(result as String, forType: .string)
|
NSPasteboard.general.setString(result as String, forType: .string)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Drawing
|
||||||
|
|
||||||
override func draw(_ dirtyRect: NSRect) {
|
override func draw(_ dirtyRect: NSRect) {
|
||||||
drawCodeBackgrounds()
|
drawCodeBackgrounds()
|
||||||
super.draw(dirtyRect)
|
super.draw(dirtyRect)
|
||||||
@@ -66,17 +77,18 @@ class CodeBlockTextView: NSTextView {
|
|||||||
return NSSize(width: NSView.noIntrinsicMetric, height: ceil(rect.height))
|
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() {
|
private func drawCodeBackgrounds() {
|
||||||
guard let textStorage, let layoutManager, let textContainer else { return }
|
guard let textStorage, let layoutManager, let textContainer else { return }
|
||||||
|
|
||||||
let codeSpanKey = MarkdownTextViewRepresentable.codeSpanKey
|
textStorage.enumerateAttribute(.codeSpan, 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 }
|
||||||
|
|
||||||
let glyphRange = layoutManager.glyphRange(forCharacterRange: range, actualCharacterRange: nil)
|
let glyphRange = layoutManager.glyphRange(forCharacterRange: range, actualCharacterRange: nil)
|
||||||
|
|
||||||
// Enumerate per-line rects so wrapped code spans get individual backgrounds
|
|
||||||
layoutManager.enumerateEnclosingRects(
|
layoutManager.enumerateEnclosingRects(
|
||||||
forGlyphRange: glyphRange,
|
forGlyphRange: glyphRange,
|
||||||
withinSelectedGlyphRange: NSRange(location: NSNotFound, length: 0),
|
withinSelectedGlyphRange: NSRange(location: NSNotFound, length: 0),
|
||||||
|
|||||||
@@ -9,21 +9,16 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import AppKit
|
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 {
|
struct MarkdownTextViewRepresentable: NSViewRepresentable {
|
||||||
let string: String
|
let string: String
|
||||||
let fontSize: CGFloat
|
let fontSize: CGFloat
|
||||||
let textColor: NSColor
|
let textColor: NSColor
|
||||||
|
|
||||||
// MARK: - Static Properties
|
// MARK: - NSViewRepresentable
|
||||||
|
|
||||||
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 {
|
func makeCoordinator() -> Coordinator {
|
||||||
Coordinator()
|
Coordinator()
|
||||||
@@ -43,13 +38,19 @@ struct MarkdownTextViewRepresentable: NSViewRepresentable {
|
|||||||
return textView
|
return textView
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateNSView(_ textView: CodeBlockTextView, context: Context) {
|
func updateNSView(
|
||||||
|
_ textView: CodeBlockTextView,
|
||||||
|
context: Context
|
||||||
|
) {
|
||||||
let coordinator = context.coordinator
|
let coordinator = context.coordinator
|
||||||
guard string != coordinator.lastString || fontSize != coordinator.lastFontSize || textColor != coordinator.lastTextColor else { return }
|
guard string != coordinator.lastString || fontSize != coordinator.lastFontSize || textColor != coordinator.lastTextColor else { return }
|
||||||
configure(textView, coordinator: coordinator)
|
configure(textView, coordinator: coordinator)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func configure(_ textView: CodeBlockTextView, coordinator: Coordinator) {
|
private func configure(
|
||||||
|
_ textView: CodeBlockTextView,
|
||||||
|
coordinator: Coordinator
|
||||||
|
) {
|
||||||
coordinator.lastString = string
|
coordinator.lastString = string
|
||||||
coordinator.lastFontSize = fontSize
|
coordinator.lastFontSize = fontSize
|
||||||
coordinator.lastTextColor = textColor
|
coordinator.lastTextColor = textColor
|
||||||
@@ -66,7 +67,17 @@ struct MarkdownTextViewRepresentable: NSViewRepresentable {
|
|||||||
|
|
||||||
// MARK: - Attributed String Builder
|
// 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 result = NSMutableAttributedString()
|
||||||
let font = NSFont.systemFont(ofSize: fontSize)
|
let font = NSFont.systemFont(ofSize: fontSize)
|
||||||
|
|
||||||
@@ -80,7 +91,6 @@ struct MarkdownTextViewRepresentable: NSViewRepresentable {
|
|||||||
.paragraphStyle: paragraphStyle
|
.paragraphStyle: paragraphStyle
|
||||||
]
|
]
|
||||||
|
|
||||||
// Plain text first
|
|
||||||
result.append(NSAttributedString(string: string, attributes: defaultAttributes))
|
result.append(NSAttributedString(string: string, attributes: defaultAttributes))
|
||||||
|
|
||||||
// Apply markup passes (order matters: code first to avoid matching * inside code spans)
|
// 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
|
// Collect code span ranges once for bold and italic passes
|
||||||
let codeRanges = codeSpanRanges(in: result)
|
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
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Markup Handlers
|
// 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(
|
private static func handleCodeMarkup(
|
||||||
in result: NSMutableAttributedString,
|
in result: NSMutableAttributedString,
|
||||||
fontSize: CGFloat,
|
fontSize: CGFloat,
|
||||||
paragraphStyle: NSParagraphStyle
|
paragraphStyle: NSParagraphStyle
|
||||||
) {
|
) {
|
||||||
let codeFont = NSFont.monospacedSystemFont(ofSize: fontSize - 1, weight: .regular)
|
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 fullRange = NSRange(location: 0, length: result.length)
|
||||||
let matches = codeRegex.matches(in: result.string, range: fullRange).reversed()
|
let matches = codeRegex.matches(in: result.string, range: fullRange).reversed()
|
||||||
|
|
||||||
for match in matches {
|
for match in matches {
|
||||||
let innerRange = match.range(at: 1)
|
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)
|
let innerText = (result.string as NSString).substring(with: innerRange)
|
||||||
.replacingOccurrences(of: " ", with: "\u{00A0}")
|
.replacingOccurrences(of: " ", with: String(Character.nbSpace))
|
||||||
.replacingOccurrences(of: "-", with: "-\u{2060}")
|
.replacingOccurrences(of: "-", with: "-\(Character.wordJoiner)")
|
||||||
|
|
||||||
let spaceAttributes: [NSAttributedString.Key: Any] = [
|
let spaceAttributes: [NSAttributedString.Key: Any] = [
|
||||||
.font: codeFont,
|
.font: codeFont,
|
||||||
@@ -123,90 +156,56 @@ struct MarkdownTextViewRepresentable: NSViewRepresentable {
|
|||||||
.paragraphStyle: paragraphStyle
|
.paragraphStyle: paragraphStyle
|
||||||
]
|
]
|
||||||
|
|
||||||
// Build: leading space + code span (with marker) + trailing space
|
|
||||||
let replacement = NSMutableAttributedString()
|
let replacement = NSMutableAttributedString()
|
||||||
replacement.append(NSAttributedString(string: leadingSpace, attributes: spaceAttributes))
|
replacement.append(NSAttributedString(string: String(Character.thinSpace), attributes: spaceAttributes))
|
||||||
replacement.append(NSAttributedString(
|
replacement.append(NSAttributedString(
|
||||||
string: innerText,
|
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)
|
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] = []
|
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) }
|
if value != nil { ranges.append(range) }
|
||||||
}
|
}
|
||||||
return ranges
|
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,
|
in result: NSMutableAttributedString,
|
||||||
regex: NSRegularExpression,
|
regex: NSRegularExpression,
|
||||||
|
font: NSFont,
|
||||||
|
paragraphStyle: NSParagraphStyle,
|
||||||
|
textColor: NSColor,
|
||||||
codeRanges: [NSRange]
|
codeRanges: [NSRange]
|
||||||
) -> [NSTextCheckingResult] {
|
) {
|
||||||
let fullRange = NSRange(location: 0, length: result.length)
|
let fullRange = NSRange(location: 0, length: result.length)
|
||||||
return regex.matches(in: result.string, range: fullRange).filter { match in
|
let matches = regex.matches(in: result.string, range: fullRange).filter { match in
|
||||||
!codeRanges.contains { codeRange in
|
!codeRanges.contains { NSIntersectionRange(match.range, $0).length > 0 }
|
||||||
NSIntersectionRange(match.range, codeRange).length > 0
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/// Replaces `**bold**` with bold font.
|
for match in matches.reversed() {
|
||||||
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() {
|
|
||||||
let innerRange = match.range(at: 1)
|
let innerRange = match.range(at: 1)
|
||||||
let innerText = (result.string as NSString).substring(with: innerRange)
|
let innerText = (result.string as NSString).substring(with: innerRange)
|
||||||
|
|
||||||
let replacement = NSAttributedString(
|
let replacement = NSAttributedString(
|
||||||
string: innerText,
|
string: innerText,
|
||||||
attributes: [
|
attributes: [
|
||||||
.font: boldFont,
|
.font: font,
|
||||||
.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,
|
|
||||||
.foregroundColor: textColor,
|
.foregroundColor: textColor,
|
||||||
.paragraphStyle: paragraphStyle
|
.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}"
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user