diff --git a/phpmon/Domain/SwiftUI/Common/Markdown/CodeBlockTextView.swift b/phpmon/Domain/SwiftUI/Common/Markdown/CodeBlockTextView.swift index 9d230df2..e41bb90e 100644 --- a/phpmon/Domain/SwiftUI/Common/Markdown/CodeBlockTextView.swift +++ b/phpmon/Domain/SwiftUI/Common/Markdown/CodeBlockTextView.swift @@ -12,11 +12,46 @@ import AppKit /// Note: Written with the help of an LLM. class CodeBlockTextView: NSTextView { private let codePaddingX: CGFloat = 4 - private let codePaddingY: CGFloat = 1 - private let codeCornerRadius: CGFloat = 4 + private let codePaddingY: CGFloat = 0 + private let codeCornerRadius: CGFloat = 2 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. + + This means stripping thin spaces, no-break spaces and using Markdown's annotation for code blocks. + */ + override func copy(_ sender: Any?) { + guard let textStorage, selectedRange().length > 0 else { + super.copy(sender) + return + } + + 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 + + if value != nil { + result.append("`\(fragment)`") + } else { + result.append(fragment) + } + } + + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(result as String, forType: .string) + } + override func draw(_ dirtyRect: NSRect) { drawCodeBackgrounds() super.draw(dirtyRect) @@ -39,25 +74,21 @@ class CodeBlockTextView: NSTextView { 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) - 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 + // Enumerate per-line rects so wrapped code spans get individual backgrounds + layoutManager.enumerateEnclosingRects( + forGlyphRange: glyphRange, + withinSelectedGlyphRange: NSRange(location: NSNotFound, length: 0), + in: textContainer + ) { lineRect, _ in + let rect = lineRect.offsetBy(dx: self.textContainerInset.width, dy: self.textContainerInset.height) + let paddedRect = rect.insetBy(dx: -self.codePaddingX, dy: -self.codePaddingY) + let path = NSBezierPath(roundedRect: paddedRect, xRadius: self.codeCornerRadius, yRadius: self.codeCornerRadius) + + self.appColor.withAlphaComponent(0.15).setFill() + path.fill() } - - // Offset by text container inset - let rect = textRect.offsetBy(dx: textContainerInset.width, dy: textContainerInset.height) - let paddedRect = rect.insetBy(dx: -codePaddingX, dy: -codePaddingY) - let path = NSBezierPath(roundedRect: paddedRect, xRadius: codeCornerRadius, yRadius: codeCornerRadius) - - // Fill - appColor.withAlphaComponent(0.15).setFill() - path.fill() } } } diff --git a/phpmon/Domain/SwiftUI/Common/Markdown/MarkdownTextViewRepresentable.swift b/phpmon/Domain/SwiftUI/Common/Markdown/MarkdownTextViewRepresentable.swift index e438c32e..3c37ed0a 100644 --- a/phpmon/Domain/SwiftUI/Common/Markdown/MarkdownTextViewRepresentable.swift +++ b/phpmon/Domain/SwiftUI/Common/Markdown/MarkdownTextViewRepresentable.swift @@ -71,7 +71,7 @@ struct MarkdownTextViewRepresentable: NSViewRepresentable { let font = NSFont.systemFont(ofSize: fontSize) let paragraphStyle = NSMutableParagraphStyle() - paragraphStyle.lineSpacing = 3 + paragraphStyle.lineSpacing = 2 paragraphStyle.paragraphSpacing = -4 let defaultAttributes: [NSAttributedString.Key: Any] = [ @@ -103,14 +103,19 @@ struct MarkdownTextViewRepresentable: NSViewRepresentable { paragraphStyle: NSParagraphStyle ) { let codeFont = NSFont.monospacedSystemFont(ofSize: fontSize - 1, weight: .regular) - let thinSpace = "\u{2009}" // Thin space for visual padding around code spans + 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 let innerText = (result.string as NSString).substring(with: innerRange) + .replacingOccurrences(of: " ", with: "\u{00A0}") + .replacingOccurrences(of: "-", with: "-\u{2060}") let spaceAttributes: [NSAttributedString.Key: Any] = [ .font: codeFont, @@ -118,14 +123,14 @@ struct MarkdownTextViewRepresentable: NSViewRepresentable { .paragraphStyle: paragraphStyle ] - // Build: thin space + code span (with marker) + thin space + // Build: leading space + code span (with marker) + trailing space let replacement = NSMutableAttributedString() - replacement.append(NSAttributedString(string: thinSpace, attributes: spaceAttributes)) + replacement.append(NSAttributedString(string: leadingSpace, attributes: spaceAttributes)) replacement.append(NSAttributedString( string: innerText, attributes: spaceAttributes.merging([Self.codeSpanKey: true]) { _, new in new } )) - replacement.append(NSAttributedString(string: thinSpace, attributes: spaceAttributes)) + replacement.append(NSAttributedString(string: trailingSpace, attributes: spaceAttributes)) result.replaceCharacters(in: match.range, with: replacement) } diff --git a/phpmon/Domain/SwiftUI/Startup/StartupAlertView.swift b/phpmon/Domain/SwiftUI/Startup/StartupAlertView.swift index 1de5faa7..1d6f71cf 100644 --- a/phpmon/Domain/SwiftUI/Startup/StartupAlertView.swift +++ b/phpmon/Domain/SwiftUI/Startup/StartupAlertView.swift @@ -23,7 +23,7 @@ struct StartupAlertView: View { titleText: viewModel.check.titleText, subtitleText: viewModel.check.subtitleText ) - .padding(.top, 5) + .padding(.top, 4) .padding(.bottom, 8) // Fix command description: only shown in idle state when a fix is available @@ -66,7 +66,7 @@ struct StartupAlertView: View { onFix: { viewModel.runFix() } ) } - .frame(width: 550) + .frame(width: 520) } } diff --git a/phpmon/en.lproj/Localizable.strings b/phpmon/en.lproj/Localizable.strings index 9ef84053..82a7e535 100644 --- a/phpmon/en.lproj/Localizable.strings +++ b/phpmon/en.lproj/Localizable.strings @@ -712,7 +712,7 @@ If you are seeing this message but are confused why this folder has gone missing // Valet version too new or old "startup.errors.valet_version_not_supported.title" = "This version of Valet is not supported"; "startup.errors.valet_version_not_supported.subtitle" = "You are running a version of Valet that is currently not supported. In order to avoid causing issues on your system, PHP Monitor cannot start."; -"startup.errors.valet_version_not_supported.desc" = "You can find out what version you are running by running `valet --version`. PHP Monitor will start if a compatible version of Valet is installed. +"startup.errors.valet_version_not_supported.desc" = "Run `valet --version` to find out what version is currently installed. PHP Monitor will start if a compatible version of Valet is installed. **Note:** If this message is unexpected, you may also need to upgrade to a newer version of PHP Monitor. A newer version of PHP Monitor may include compatibility for newer versions of Valet.";