Skip to content

Commit

Permalink
Supports commonly known IDE invisible characters. (#59)
Browse files Browse the repository at this point in the history
<img width="500" alt="Screenshot 2024-08-11 at 5 31 57 PM"
src="https://github.com/user-attachments/assets/ab29258b-924d-4796-bf1d-c41e9d0f1705">

---------

Signed-off-by: Hassan Taleb <[email protected]>
Co-authored-by: Marcin Krzyzanowski <[email protected]>
  • Loading branch information
HassanTaleb90 and krzyzanowskim authored Aug 11, 2024
1 parent 52002a6 commit f369db5
Showing 1 changed file with 46 additions and 22 deletions.
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)).pixelAligned

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()
}
}

0 comments on commit f369db5

Please sign in to comment.