Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Supports commonly known IDE invisible characters. #59

Merged
merged 4 commits into from
Aug 11, 2024
Merged
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 46 additions & 22 deletions Sources/STTextViewAppKit/STTextLayoutFragment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,46 +7,46 @@ import STObjCLandShim
final class STTextLayoutFragment: NSTextLayoutFragment {
private let paragraphStyle: NSParagraphStyle
var showsInvisibleCharacters: Bool = false

init(textElement: NSTextElement, range rangeInElement: NSTextRange?, paragraphStyle: NSParagraphStyle) {
self.paragraphStyle = paragraphStyle
super.init(textElement: textElement, range: rangeInElement)
}

required init?(coder: NSCoder) {
self.paragraphStyle = NSParagraphStyle.default
self.showsInvisibleCharacters = false
super.init(coder: coder)
}

// Provide default line height based on the typingattributed. By default return (0, 0, 10, 14)
//
// override var layoutFragmentFrame: CGRect {
// super.layoutFragmentFrame
// }

override func draw(at point: CGPoint, in context: CGContext) {
// Layout fragment draw text at the bottom (after apply baselineOffset) but ignore the paragraph line height
// This is a workaround/patch to position text nicely in the line
//
// Center vertically after applying lineHeightMultiple value
// super.draw(at: point.moved(dx: 0, dy: offset), in: context)

context.saveGState()

#if USE_FONT_SMOOTHING_STYLE
// This seems to be available at least on 10.8 and later. The only reference to it is in
// WebKit. This causes text to render just a little lighter, which looks nicer.
let useThinStrokes = true // shouldSmooth
var savedFontSmoothingStyle: Int32 = 0

if useThinStrokes {
context.setShouldSmoothFonts(true)
savedFontSmoothingStyle = STContextGetFontSmoothingStyle(context)
STContextSetFontSmoothingStyle(context, 16)
}
#endif

for lineFragment in textLineFragments {
// Determine paragraph style. Either from the fragment string or default for the text view
// the ExtraLineFragment doesn't have information about typing attributes hence layout manager uses a default values - not from text view
Expand All @@ -58,36 +58,37 @@ final class STTextLayoutFragment: NSTextLayoutFragment {
} else {
paragraphStyle = self.paragraphStyle
}

if !paragraphStyle.lineHeightMultiple.isAlmostZero() {
let offset = -(lineFragment.typographicBounds.height * (paragraphStyle.lineHeightMultiple - 1.0) / 2)
lineFragment.draw(at: point.moved(dx: lineFragment.typographicBounds.origin.x, dy: lineFragment.typographicBounds.origin.y + offset), in: context)
} else {
lineFragment.draw(at: lineFragment.typographicBounds.origin, in: context)
}
}

#if USE_FONT_SMOOTHING_STYLE
if (useThinStrokes) {
STContextSetFontSmoothingStyle(context, savedFontSmoothingStyle);
}
#endif

if showsInvisibleCharacters {
drawInvisibles(at: point, in: context)
}

context.restoreGState()
}

private func drawInvisibles(at point: CGPoint, in context: CGContext) {
guard let textLayoutManager = textLayoutManager else {
return
}

context.saveGState()

for lineFragment in textLineFragments where !lineFragment.isExtraLineFragment {

let string = lineFragment.attributedString.string
if let textLineTextRange = lineFragment.textRange(in: self) {
for (offset, character) in string.utf16.enumerated() where Unicode.Scalar(character)?.properties.isWhitespace == true {
Expand All @@ -96,21 +97,44 @@ final class STTextLayoutFragment: NSTextLayoutFragment {
guard let segmentLocation = textLayoutManager.location(textLineTextRange.location, offsetBy: offset),
let segmentEndLocation = textLayoutManager.location(textLineTextRange.location, offsetBy: offset + (writingDirection == .leftToRight ? 1 : 0)),
let segmentRange = NSTextRange(location: segmentLocation, end: segmentEndLocation),
let segmentFrame = textLayoutManager.textSegmentFrame(in: segmentRange, type: .standard)
let segmentFrame = textLayoutManager.textSegmentFrame(in: segmentRange, type: .standard),
let font = lineFragment.attributedString.attribute(.font, at: offset, effectiveRange: nil) as? NSFont
else {
// assertionFailure()
continue
}

let frameRect = CGRect(origin: CGPoint(x: segmentFrame.origin.x - layoutFragmentFrame.origin.x, y: segmentFrame.origin.y - layoutFragmentFrame.origin.y), size: CGSize(width: segmentFrame.size.width, height: segmentFrame.size.height))
context.setFillColor(NSColor.placeholderTextColor.cgColor)
let rect = CGRect(x: frameRect.midX, y: frameRect.midY, width: frameRect.width / 4, height: frameRect.width / 4)
context.addEllipse(in: rect)
context.drawPath(using: .fill)

let symbol: Character = switch character {
case 0x0020: "\u{00B7}" // • Space
case 0x0009: "\u{00BB}" // » Tab
case 0x000A: "\u{00AC}" // ¬ Line Feed
case 0x000D: "\u{21A9}" // ↩ Carriage Return
case 0x00A0: "\u{235F}" // ⎵ Non-Breaking Space
case 0x200B: "\u{205F}" // ⸱ Zero Width Space
case 0x200C: "\u{200C}" // ‌ Zero Width Non-Joiner
case 0x200D: "\u{200D}" // ‍ Zero Width Joiner
case 0x2060: "\u{205F}" //   Word Joiner
case 0x2028: "\u{23CE}" // ⏎ Line Separator
case 0x2029: "\u{00B6}" // ¶ Paragraph Separator
default: "\u{00B7}" // • Default symbol for unspecified whitespace
}

let symbolString = String(symbol)
let attributes: [NSAttributedString.Key: Any] = [
.font: font,
.foregroundColor: NSColor.placeholderTextColor
]

let frameRect = CGRect(origin: CGPoint(x: segmentFrame.origin.x - layoutFragmentFrame.origin.x, y: segmentFrame.origin.y - layoutFragmentFrame.origin.y), size: CGSize(width: segmentFrame.size.width, height: segmentFrame.size.height))
HassanTaleb90 marked this conversation as resolved.
Show resolved Hide resolved

let charSize = symbolString.size(withAttributes: attributes)
let point = CGPoint(x: frameRect.origin.x, y: frameRect.height / 2 - charSize.height / 2)

symbolString.draw(at: point, withAttributes: attributes)
}
}
}

context.restoreGState()
}
}