Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
79 commits
Select commit Hold shift + click to select a range
f770d0f
Begin TextView implementation
thecoolwinter Jun 29, 2023
f70e24d
Add height to textline tree
thecoolwinter Jul 15, 2023
dbfe605
Merge branch 'main' into feat/inputview
thecoolwinter Jul 15, 2023
2776c2e
Get something rendering...
thecoolwinter Jul 16, 2023
ab76fef
Wrap lines, remove custom layer
thecoolwinter Jul 17, 2023
864400c
Begin integrating highlighting system
thecoolwinter Jul 17, 2023
6069382
Syntax highlighting integration (no edit)
thecoolwinter Jul 17, 2023
f3d8a5a
Update TODO.md
thecoolwinter Jul 17, 2023
d4780de
Begin layer reuse instead of raw draw
thecoolwinter Jul 22, 2023
6678dc4
Use reusable views for rendering lines
thecoolwinter Aug 16, 2023
a685b9f
Initial cursor implementation
thecoolwinter Aug 16, 2023
2629ec2
Fix some resizing bugs
thecoolwinter Aug 16, 2023
b45ec3d
Begin (completely non functional) text input
thecoolwinter Aug 16, 2023
2a292f0
Refactor TextLineStorage, Add Insert Editing
thecoolwinter Aug 20, 2023
a40000c
Final layout refactor, resizable, copy/paste
thecoolwinter Aug 22, 2023
c3c9e62
Fix scroll jumping on scroll up
thecoolwinter Aug 22, 2023
c37ab5c
Add setNeedsLayout to TextLayoutManager
thecoolwinter Aug 22, 2023
417bd32
Move line storage builder
thecoolwinter Aug 22, 2023
f5cc9f6
Add line number to Line Storage nodes
thecoolwinter Aug 22, 2023
fbe25d8
Add GutterView
thecoolwinter Aug 22, 2023
19500f9
Line Numbers final, fix insert bug
thecoolwinter Aug 23, 2023
1f16841
Selected line highlights, remove strong references
thecoolwinter Aug 23, 2023
eee21f9
Fix Cursor + Selection Background Misalignment
thecoolwinter Aug 23, 2023
8452403
Fix text position calculation
thecoolwinter Aug 23, 2023
edbe8d3
Spelling Error
thecoolwinter Aug 23, 2023
5a7bbcd
Begin RB delete, add docs
thecoolwinter Aug 24, 2023
a757f5c
Begin Delete
thecoolwinter Aug 25, 2023
30a6222
Add TextSelectionManager as NSTextStorage delegate
thecoolwinter Aug 26, 2023
793bf10
Small refactor
thecoolwinter Aug 26, 2023
377c986
Fix TODO, Fix Performance Test
thecoolwinter Aug 26, 2023
8d877cc
Add RB Tree Delete, lazy update cursors
thecoolwinter Aug 31, 2023
a5f077e
Add small test
thecoolwinter Aug 31, 2023
63cff48
Begin `TextLayoutManager` updates for editing
thecoolwinter Sep 3, 2023
203ffca
Add CodeEditTextInput and Common targets
thecoolwinter Sep 3, 2023
3141026
Finish delete
thecoolwinter Sep 3, 2023
2baa6d9
Merge remote-tracking branch 'origin/main' into feat/inputview
thecoolwinter Sep 3, 2023
bdc2d6d
Finalize Insert, Delete, Newlines, Delete Lines
thecoolwinter Sep 11, 2023
1c8aea5
Arrow Left & Right, Fix Lint Errors, Trailing Insets
thecoolwinter Sep 11, 2023
b0ac245
Fix Lint Bugs, Remove Warnings
thecoolwinter Sep 11, 2023
a3aad97
Add Some TextSelectionManager Tests
thecoolwinter Sep 12, 2023
1e42a4c
Add `scrollSelectionToVisible`, Work on Arrow Keys
thecoolwinter Sep 13, 2023
882fa43
Finish Keyboard Navigation
thecoolwinter Sep 15, 2023
18a958c
QOL GutterView Improvements
thecoolwinter Sep 17, 2023
0a00de9
Add line break strategies
thecoolwinter Sep 19, 2023
37a8331
Limit word break lookback to startingOffset
thecoolwinter Sep 19, 2023
307b0b3
Draw Selection Rects
thecoolwinter Sep 19, 2023
f22ae5a
Drag To Select, Autoscroll On Drag
thecoolwinter Oct 2, 2023
7046569
Finalize Selection Modification API
thecoolwinter Oct 6, 2023
668b294
Fix Gutter Clipping, Improve Insert Performance
thecoolwinter Oct 7, 2023
048e7b8
Fix TextLineStorage.update Test
thecoolwinter Oct 7, 2023
5c389da
Fix Undo/Redo, Line Fragment Move, Lint Errors
thecoolwinter Oct 7, 2023
d7050ab
Typing Attributes, Remove STTextView, Integrate Previous Features
thecoolwinter Oct 14, 2023
9e6a7b5
Remove Print Statement
thecoolwinter Oct 14, 2023
61a1b9b
Accessibility
thecoolwinter Oct 14, 2023
181e7ff
Fix Linter
thecoolwinter Oct 14, 2023
5a11ada
Fix Editing Bugs, Pass Tests
thecoolwinter Oct 15, 2023
5384b6b
Fix Basic Editing At End Of Files & New Files
thecoolwinter Oct 17, 2023
f7760af
Fix Empty File, End Of File, LineEndings
thecoolwinter Oct 19, 2023
9586155
Fix TextFormation, EOD Selection, Layout Transactions
thecoolwinter Oct 20, 2023
5bf20ed
Fix textOffsetAtPoint, Add Selection getFillRects
thecoolwinter Oct 21, 2023
d6f399c
Remove Xcode Baselines
thecoolwinter Oct 21, 2023
bdcd0f6
Multi-Cursor, Sync Cursor Animations, Line Highlights
thecoolwinter Oct 22, 2023
af81a47
Fix Lint Errors
thecoolwinter Oct 22, 2023
4d7dbc5
Merge remote-tracking branch 'upstream' into feat/inputview
thecoolwinter Oct 22, 2023
38db0d4
Fix Lint Errors
thecoolwinter Oct 22, 2023
dae9e60
Misc fixes, Fixup SwiftUI API
thecoolwinter Oct 24, 2023
8a221c4
Fix Tests
thecoolwinter Oct 24, 2023
9e0ca63
Correctly Reset `setTextStorage`, background colors
thecoolwinter Nov 2, 2023
f0e6059
Initial Marked Text implementation
thecoolwinter Nov 7, 2023
4716991
Update README.md Icon
austincondiff Nov 8, 2023
05390c5
Update README.md
austincondiff Nov 8, 2023
93bfd76
Marked Text, Remove Common Module, `lineBreakStrategy`
thecoolwinter Nov 9, 2023
81c7f98
Update SwiftUI API, Add `CursorPosition` & `TextViewCoordinator`
thecoolwinter Nov 18, 2023
0016b1d
Merge remote-tracking branch 'upstream' into feat/inputview
thecoolwinter Nov 18, 2023
7cf6937
Remove `internal`, Unnecessary Public Extensions
thecoolwinter Nov 18, 2023
7ca798d
Update TextViewControllerTests.swift
thecoolwinter Nov 18, 2023
2b6ae13
Explicit frame size in test
thecoolwinter Nov 18, 2023
22dfab2
Linter
thecoolwinter Nov 18, 2023
afcafa9
Update Sources/CodeEditInputView/TextView/TextView+Move.swift
austincondiff Dec 9, 2023
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Fix textOffsetAtPoint, Add Selection getFillRects
  • Loading branch information
thecoolwinter committed Oct 21, 2023
commit 5bf20edf6ac3c6ac84bc73bdc6e87ad84982f8b3
Original file line number Diff line number Diff line change
Expand Up @@ -58,14 +58,15 @@ extension TextLayoutManager {
length: fragmentRange.length
)
let endPosition = position.range.location + fragmentRange.location + fragmentRange.length
// Return eol
return endPosition - (
// Before the eol character (insertion point is before the eol)
// And the line *has* an eol character
fragmentPosition.range.max == position.range.max
&& LineEnding(line: textStorage.substring(from: globalFragmentRange) ?? "") != nil
? detectedLineEnding.length : 0
)

// If the endPosition is at the end of the line, and the line ends with a line ending character
// return the index before the eol.
if endPosition == position.range.max,
let lineEnding = LineEnding(line: textStorage.substring(from: globalFragmentRange) ?? "") {
return endPosition - lineEnding.length
} else {
return endPosition
}
} else {
// Somewhere in the fragment
let fragmentIndex = CTLineGetStringIndexForPosition(
Expand Down
7 changes: 7 additions & 0 deletions Sources/CodeEditInputView/TextLine/LineFragmentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,15 @@ final class LineFragmentView: NSView {
true
}

override var isOpaque: Bool {
false
}

/// Prepare the view for reuse, clears the line fragment reference.
override func prepareForReuse() {
super.prepareForReuse()
lineFragment = nil

}

/// Set a new line fragment for this view, updating view size.
Expand All @@ -35,6 +40,8 @@ final class LineFragmentView: NSView {
return
}
context.saveGState()
context.setAllowsFontSmoothing(true)
context.setShouldSmoothFonts(true)
context.textMatrix = .init(scaleX: 1, y: -1)
context.textPosition = CGPoint(
x: 0,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import AppKit
import Common

public protocol TextSelectionManagerDelegate: AnyObject {
var font: NSFont { get }
var visibleTextRange: NSRange? { get }

func setNeedsDisplay()
func estimatedLineHeight() -> CGFloat
Expand All @@ -27,7 +27,7 @@ public class TextSelectionManager: NSObject {

// MARK: - TextSelection

public class TextSelection {
public class TextSelection: Hashable {
public var range: NSRange
internal weak var view: CursorView?
internal var boundingRect: CGRect = .zero
Expand All @@ -43,6 +43,14 @@ public class TextSelectionManager: NSObject {
var isCursor: Bool {
range.length == 0
}

public func hash(into hasher: inout Hasher) {
hasher.combine(range)
}

public static func == (lhs: TextSelection, rhs: TextSelection) -> Bool {
lhs.range == rhs.range
}
}

public enum Destination {
Expand Down Expand Up @@ -78,7 +86,7 @@ public class TextSelectionManager: NSObject {
public var selectionBackgroundColor: NSColor = NSColor.selectedTextBackgroundColor

private var markedText: [MarkedText] = []
private(set) public var textSelections: [TextSelection] = []
private(set) public var textSelections: Set<TextSelection> = []
internal weak var layoutManager: TextLayoutManager?
internal weak var textStorage: NSTextStorage?
internal weak var layoutView: NSView?
Expand Down Expand Up @@ -112,11 +120,19 @@ public class TextSelectionManager: NSObject {

public func setSelectedRanges(_ ranges: [NSRange]) {
textSelections.forEach { $0.view?.removeFromSuperview() }
textSelections = ranges.map {
textSelections = Set(ranges.map {
let selection = TextSelection(range: $0)
selection.suggestedXPos = layoutManager?.rectForOffset($0.location)?.minX
return selection
}
})
updateSelectionViews()
NotificationCenter.default.post(Notification(name: Self.selectionChangedNotification, object: self))
}

public func addSelectedRange(_ range: NSRange) {
let selection = TextSelection(range: range)
guard !textSelections.contains(selection) else { return }
textSelections.insert(TextSelection(range: range))
updateSelectionViews()
NotificationCenter.default.post(Notification(name: Self.selectionChangedNotification, object: self))
}
Expand Down Expand Up @@ -211,51 +227,7 @@ public class TextSelectionManager: NSObject {
context.saveGState()
context.setFillColor(selectionBackgroundColor.cgColor)

var fillRects = [CGRect]()

for linePosition in layoutManager.lineStorage.linesInRange(range) {
if linePosition.range.intersection(range) == linePosition.range {
// If the selected range contains the entire line
fillRects.append(CGRect(
x: rect.minX,
y: linePosition.yPos,
width: rect.width,
height: linePosition.height
))
} else {
// The selected range contains some portion of the line
for fragmentPosition in linePosition.data.lineFragments {
guard let fragmentRange = fragmentPosition
.range
.shifted(by: linePosition.range.location),
let intersectionRange = fragmentRange.intersection(range),
let minRect = layoutManager.rectForOffset(intersectionRange.location) else {
continue
}

let maxRect: CGRect
if fragmentRange.max <= range.max || range.contains(fragmentRange.max) {
maxRect = CGRect(
x: rect.maxX,
y: fragmentPosition.yPos + linePosition.yPos,
width: 0,
height: fragmentPosition.height
)
} else if let maxFragmentRect = layoutManager.rectForOffset(intersectionRange.max) {
maxRect = maxFragmentRect
} else {
continue
}

fillRects.append(CGRect(
x: minRect.origin.x,
y: minRect.origin.y,
width: maxRect.minX - minRect.minX,
height: max(minRect.height, maxRect.height)
))
}
}
}
let fillRects = getFillRects(in: rect, for: textSelection)

let min = fillRects.min(by: { $0.origin.y < $1.origin.y })?.origin ?? .zero
let max = fillRects.max(by: { $0.origin.y < $1.origin.y }) ?? .zero
Expand All @@ -265,6 +237,98 @@ public class TextSelectionManager: NSObject {
context.fill(fillRects)
context.restoreGState()
}

/// Calculate a set of rects for a text selection suitable for highlighting the selection.
/// - Parameters:
/// - rect: The bounding rect of available draw space.
/// - textSelection: The selection to use.
/// - Returns: An array of rects that the selection overlaps.
func getFillRects(in rect: NSRect, for textSelection: TextSelection) -> [CGRect] {
guard let layoutManager else { return [] }
let range = textSelection.range

var fillRects: [CGRect] = []
guard let firstLinePosition = layoutManager.lineStorage.getLine(atOffset: range.location),
let lastLinePosition = range.max == layoutManager.lineStorage.length
? layoutManager.lineStorage.last
: layoutManager.lineStorage.getLine(atOffset: range.max) else {
return []
}

// Calculate the first line and any rects selected
// If the last line position is not the same as the first, calculate any rects from that line.
// If there's > 0 space between the first and last positions, add a rect between them to cover any
// intermediate lines.

fillRects.append(contentsOf: getFillRects(in: rect, selectionRange: range, forPosition: firstLinePosition))

if lastLinePosition.range != firstLinePosition.range {
fillRects.append(contentsOf: getFillRects(in: rect, selectionRange: range, forPosition: lastLinePosition))
}

if firstLinePosition.yPos + firstLinePosition.height < lastLinePosition.yPos {
fillRects.append(CGRect(
x: rect.minX,
y: firstLinePosition.yPos + firstLinePosition.height,
width: rect.width,
height: lastLinePosition.yPos - (firstLinePosition.yPos + firstLinePosition.height)
))
}

return fillRects
}

/// Find fill rects for a specific line position.
/// - Parameters:
/// - rect: The bounding rect of the overall view.
/// - range: The selected range to create fill rects for.
/// - linePosition: The line position to use.
/// - Returns: An array of rects that the selection overlaps.
private func getFillRects(
in rect: NSRect,
selectionRange range: NSRange,
forPosition linePosition: TextLineStorage<TextLine>.TextLinePosition
) -> [CGRect] {
guard let layoutManager else { return [] }
var fillRects: [CGRect] = []

// The selected range contains some portion of the line
for fragmentPosition in linePosition.data.lineFragments {
guard let fragmentRange = fragmentPosition
.range
.shifted(by: linePosition.range.location),
let intersectionRange = fragmentRange.intersection(range),
let minRect = layoutManager.rectForOffset(intersectionRange.location) else {
continue
}

let maxRect: CGRect
// If the selection is at the end of the line, or contains the end of the fragment, and is not the end
// of the document, we select the entire line to the right of the selection point.
if (fragmentRange.max <= range.max || range.contains(fragmentRange.max))
&& range.max != layoutManager.lineStorage.length {
maxRect = CGRect(
x: rect.maxX,
y: fragmentPosition.yPos + linePosition.yPos,
width: 0,
height: fragmentPosition.height
)
} else if let maxFragmentRect = layoutManager.rectForOffset(intersectionRange.max) {
maxRect = maxFragmentRect
} else {
continue
}

fillRects.append(CGRect(
x: minRect.origin.x,
y: minRect.origin.y,
width: maxRect.minX - minRect.minX,
height: max(minRect.height, maxRect.height)
))
}

return fillRects
}
}

// MARK: - Private TextSelection
Expand Down
89 changes: 68 additions & 21 deletions Sources/CodeEditInputView/TextView/TextView+Drag.swift
Original file line number Diff line number Diff line change
@@ -1,32 +1,79 @@
//
// TextView+Drag.swift
//
//
// Created by Khan Winter on 9/19/23.
//
// Created by Khan Winter on 10/20/23.
//

import AppKit
import Common

extension TextView {
public override func mouseDragged(with event: NSEvent) {
if mouseDragAnchor == nil {
mouseDragAnchor = convert(event.locationInWindow, from: nil)
super.mouseDragged(with: event)
} else {
guard let mouseDragAnchor,
let startPosition = layoutManager.textOffsetAtPoint(mouseDragAnchor),
let endPosition = layoutManager.textOffsetAtPoint(convert(event.locationInWindow, from: nil)) else {

extension TextView: NSDraggingSource {
class DragSelectionGesture: NSPressGestureRecognizer {
override func mouseDown(with event: NSEvent) {
guard isEnabled, let view = self.view as? TextView, event.type == .leftMouseDown else {
return
}
selectionManager.setSelectedRange(
NSRange(
location: min(startPosition, endPosition),
length: max(startPosition, endPosition) - min(startPosition, endPosition)
)
)
setNeedsDisplay()
self.autoscroll(with: event)

let clickPoint = view.convert(event.locationInWindow, from: nil)
let selectionRects = view.selectionManager.textSelections.filter({ !$0.range.isEmpty }).flatMap {
view.selectionManager.getFillRects(in: view.frame, for: $0)
}
if !selectionRects.contains(where: { $0.contains(clickPoint) }) {
state = .failed
}

super.mouseDown(with: event)
}
}

internal func setUpDragGesture() {
let dragGesture = DragSelectionGesture(target: self, action: #selector(dragGestureHandler(_:)))
dragGesture.minimumPressDuration = NSEvent.doubleClickInterval / 3
dragGesture.isEnabled = isSelectable
addGestureRecognizer(dragGesture)
}

@objc private func dragGestureHandler(_ sender: Any) {
let selectionRects = selectionManager.textSelections.filter({ !$0.range.isEmpty }).flatMap {
selectionManager.getFillRects(in: frame, for: $0)
}
// TODO: This SUcks
let minX = (selectionRects.min(by: { $0.minX < $1.minX })?.minX ?? 0.0)
let minY = selectionRects.min(by: { $0.minY < $1.minY })?.minY ?? 0.0
let maxX = selectionRects.max(by: { $0.maxX < $1.maxX })?.maxX ?? 0.0
let maxY = selectionRects.max(by: { $0.maxY < $1.maxY })?.maxY ?? 0.0
let imageBounds = CGRect(
x: minX,
y: minY,
width: maxX - minX,
height: maxY - minY
)

guard let bitmap = bitmapImageRepForCachingDisplay(in: imageBounds) else {
return
}

selectionRects.forEach { selectionRect in
self.cacheDisplay(in: selectionRect, to: bitmap)
}

let draggingImage = NSImage(cgImage: bitmap.cgImage!, size: imageBounds.size)

let attributedString = selectionManager
.textSelections
.sorted(by: { $0.range.location < $1.range.location })
.map { textStorage.attributedSubstring(from: $0.range) }
.reduce(NSMutableAttributedString(), { $0.append($1); return $0 })
let draggingItem = NSDraggingItem(pasteboardWriter: attributedString)
draggingItem.setDraggingFrame(imageBounds, contents: draggingImage)

beginDraggingSession(with: [draggingItem], event: NSApp.currentEvent!, source: self)
}

public func draggingSession(
_ session: NSDraggingSession,
sourceOperationMaskFor context: NSDraggingContext
) -> NSDragOperation {
context == .outsideApplication ? .copy : .move
}
}
Loading