Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ struct CodeEditTextViewExampleDocument: FileDocument {
guard let data = configuration.file.regularFileContents else {
throw CocoaError(.fileReadCorruptFile)
}
text = String(bytes: data, encoding: .utf8)
text = String(bytes: data, encoding: .utf8) ?? ""
}

func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
import AppKit

extension TextLayoutManager {
// MARK: - Estimate

public func estimatedHeight() -> CGFloat {
max(lineStorage.height, estimateLineHeight())
}
Expand All @@ -16,6 +18,8 @@ extension TextLayoutManager {
maxLineWidth + edgeInsets.horizontal
}

// MARK: - Text Lines

/// Finds a text line for the given y position relative to the text view.
///
/// Y values begin at the top of the view and extend down. Eg, a `0` y value would return the first line in
Expand Down Expand Up @@ -101,6 +105,8 @@ extension TextLayoutManager {
}
}

// MARK: - Rect For Offset

/// Find a position for the character at a given offset.
/// Returns the rect of the character at the given offset.
/// The rect may represent more than one unicode unit, for instance if the offset is at the beginning of an
Expand Down Expand Up @@ -263,6 +269,8 @@ extension TextLayoutManager {
return nil
}

// MARK: - Ensure Layout

/// Forces layout calculation for all lines up to and including the given offset.
/// - Parameter offset: The offset to ensure layout until.
public func ensureLayoutUntil(_ offset: Int) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
//
// TextLayoutManger+ensureLayout.swift
// TextLayoutManager+ensureLayout.swift
// CodeEditTextView
//
// Created by Khan Winter on 4/7/25.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ public class TextLayoutManager: NSObject {
weak var textStorage: NSTextStorage?
var lineStorage: TextLineStorage<TextLine> = TextLineStorage()
var markedTextManager: MarkedTextManager = MarkedTextManager()
private let viewReuseQueue: ViewReuseQueue<LineFragmentView, UUID> = ViewReuseQueue()
let viewReuseQueue: ViewReuseQueue<LineFragmentView, UUID> = ViewReuseQueue()
package var visibleLineIds: Set<TextLine.ID> = []
/// Used to force a complete re-layout using `setNeedsLayout`
package var needsLayout: Bool = false
Expand Down
38 changes: 38 additions & 0 deletions Sources/CodeEditTextView/TextLine/LineFragment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
//

import AppKit
import CodeEditTextViewObjC

/// A ``LineFragment`` represents a subrange of characters in a line. Every text line contains at least one line
/// fragments, and any lines that need to be broken due to width constraints will contain more than one fragment.
Expand Down Expand Up @@ -40,6 +41,43 @@ public final class LineFragment: Identifiable, Equatable {
lhs.id == rhs.id
}

/// Finds the x position of the offset in the string the fragment represents.
/// - Parameter offset: The offset, relative to the start of the *line*.
/// - Returns: The x position of the character in the drawn line, from the left.
public func xPos(for offset: Int) -> CGFloat {
return CTLineGetOffsetForStringIndex(ctLine, offset, nil)
}

public func draw(in context: CGContext, yPos: CGFloat) {
context.saveGState()

// Removes jagged edges
context.setAllowsAntialiasing(true)
context.setShouldAntialias(true)

// Effectively increases the screen resolution by drawing text in each LED color pixel (R, G, or B), rather than
// the triplet of pixels (RGB) for a regular pixel. This can increase text clarity, but loses effectiveness
// in low-contrast settings.
context.setAllowsFontSubpixelPositioning(true)
context.setShouldSubpixelPositionFonts(true)

// Quantizes the position of each glyph, resulting in slightly less accurate positioning, and gaining higher
// quality bitmaps and performance.
context.setAllowsFontSubpixelQuantization(true)
context.setShouldSubpixelQuantizeFonts(true)

ContextSetHiddenSmoothingStyle(context, 16)

context.textMatrix = .init(scaleX: 1, y: -1)
context.textPosition = CGPoint(
x: 0,
y: yPos + height - descent + (heightDifference/2)
).pixelAligned

CTLineDraw(ctLine, context)
context.restoreGState()
}

/// Calculates the drawing rect for a given range.
/// - Parameter range: The range to calculate the bounds for, relative to the line.
/// - Returns: A rect that contains the text contents in the given range.
Expand Down
29 changes: 1 addition & 28 deletions Sources/CodeEditTextView/TextLine/LineFragmentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
//

import AppKit
import CodeEditTextViewObjC

/// Displays a line fragment.
final class LineFragmentView: NSView {
Expand Down Expand Up @@ -40,32 +39,6 @@ final class LineFragmentView: NSView {
guard let lineFragment, let context = NSGraphicsContext.current?.cgContext else {
return
}
context.saveGState()

// Removes jagged edges
context.setAllowsAntialiasing(true)
context.setShouldAntialias(true)

// Effectively increases the screen resolution by drawing text in each LED color pixel (R, G, or B), rather than
// the triplet of pixels (RGB) for a regular pixel. This can increase text clarity, but loses effectiveness
// in low-contrast settings.
context.setAllowsFontSubpixelPositioning(true)
context.setShouldSubpixelPositionFonts(true)

// Quantizes the position of each glyph, resulting in slightly less accurate positioning, and gaining higher
// quality bitmaps and performance.
context.setAllowsFontSubpixelQuantization(true)
context.setShouldSubpixelQuantizeFonts(true)

ContextSetHiddenSmoothingStyle(context, 16)

context.textMatrix = .init(scaleX: 1, y: -1)
context.textPosition = CGPoint(
x: 0,
y: lineFragment.height - lineFragment.descent + (lineFragment.heightDifference/2)
).pixelAligned

CTLineDraw(lineFragment.ctLine, context)
context.restoreGState()
lineFragment.draw(in: context, yPos: 0.0)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -139,17 +139,19 @@ public class TextSelectionManager: NSObject {

for textSelection in textSelections {
if textSelection.range.isEmpty {
let cursorOrigin = (layoutManager?.rectForOffset(textSelection.range.location) ?? .zero).origin
guard let cursorRect = layoutManager?.rectForOffset(textSelection.range.location) else {
continue
}

var doesViewNeedReposition: Bool

// If using the system cursor, macOS will change the origin and height by about 0.5, so we do an
// approximate equals in that case to avoid extra updates.
if useSystemCursor, #available(macOS 14.0, *) {
doesViewNeedReposition = !textSelection.boundingRect.origin.approxEqual(cursorOrigin)
doesViewNeedReposition = !textSelection.boundingRect.origin.approxEqual(cursorRect.origin)
|| !textSelection.boundingRect.height.approxEqual(layoutManager?.estimateLineHeight() ?? 0)
} else {
doesViewNeedReposition = textSelection.boundingRect.origin != cursorOrigin
doesViewNeedReposition = textSelection.boundingRect.origin != cursorRect.origin
|| textSelection.boundingRect.height != layoutManager?.estimateLineHeight() ?? 0
}

Expand All @@ -175,8 +177,8 @@ public class TextSelectionManager: NSObject {
textView?.addSubview(cursorView)
}

cursorView.frame.origin = cursorOrigin
cursorView.frame.size.height = heightForCursorAt(textSelection.range) ?? 0
cursorView.frame.origin = cursorRect.origin
cursorView.frame.size.height = cursorRect.height

textSelection.view = cursorView
textSelection.boundingRect = cursorView.frame
Expand Down
109 changes: 109 additions & 0 deletions Sources/CodeEditTextView/TextView/DraggingTextRenderer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
//
// DraggingTextRenderer.swift
// CodeEditTextView
//
// Created by Khan Winter on 11/24/24.
//

import AppKit

class DraggingTextRenderer: NSView {
let ranges: [NSRange]
let layoutManager: TextLayoutManager

override var isFlipped: Bool {
true
}

override var intrinsicContentSize: NSSize {
self.frame.size
}

init?(ranges: [NSRange], layoutManager: TextLayoutManager) {
self.ranges = ranges
self.layoutManager = layoutManager

assert(!ranges.isEmpty, "Empty ranges not allowed")

var minY: CGFloat = .infinity
var maxY: CGFloat = 0.0

for range in ranges {
for line in layoutManager.lineStorage.linesInRange(range) {
minY = min(minY, line.yPos)
maxY = max(maxY, line.yPos + line.height)
}
}

let frame = CGRect(
x: layoutManager.edgeInsets.left,
y: minY,
width: layoutManager.maxLineWidth,
height: maxY - minY
)

super.init(frame: frame)
}

required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

override func draw(_ dirtyRect: NSRect) {
super.draw(dirtyRect)
guard let context = NSGraphicsContext.current?.cgContext,
let firstRange = ranges.first,
let minRect = layoutManager.rectForOffset(firstRange.lowerBound) else {
return
}

for range in ranges {
for line in layoutManager.lineStorage.linesInRange(range) {
drawLine(line, in: range, yOffset: minRect.minY, context: context)
}
}
}

private func drawLine(
_ line: TextLineStorage<TextLine>.TextLinePosition,
in selectedRange: NSRange,
yOffset: CGFloat,
context: CGContext
) {
for fragment in line.data.lineFragments {
guard let fragmentRange = fragment.range.shifted(by: line.range.location),
fragmentRange.intersection(selectedRange) != nil else {
continue
}
let fragmentYPos = line.yPos + fragment.yPos - yOffset
fragment.data.draw(in: context, yPos: fragmentYPos)

// Clear text that's not selected
if fragmentRange.contains(selectedRange.lowerBound) {
let relativeOffset = selectedRange.lowerBound - line.range.lowerBound
let selectionXPos = fragment.data.xPos(for: relativeOffset)
context.clear(
CGRect(
x: 0.0,
y: fragmentYPos,
width: selectionXPos,
height: fragment.height
).pixelAligned
)
}

if fragmentRange.contains(selectedRange.upperBound) {
let relativeOffset = selectedRange.upperBound - line.range.lowerBound
let selectionXPos = fragment.data.xPos(for: relativeOffset)
context.clear(
CGRect(
x: selectionXPos,
y: fragmentYPos,
width: frame.width - selectionXPos,
height: fragment.height
).pixelAligned
)
}
}
}
}
Loading