Skip to content

Commit 1629154

Browse files
committed
First Iteration
1 parent 1033aef commit 1629154

File tree

12 files changed

+410
-19
lines changed

12 files changed

+410
-19
lines changed

Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved

Lines changed: 0 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Package.swift

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,9 @@ let package = Package(
1616
dependencies: [
1717
// A fast, efficient, text view for code.
1818
.package(
19-
url: "https://github.com/CodeEditApp/CodeEditTextView.git",
20-
from: "0.12.0"
19+
// url: "https://github.com/CodeEditApp/CodeEditTextView.git",
20+
// from: "0.12.0"
21+
path: "../CodeEditTextView"
2122
),
2223
// tree-sitter languages
2324
.package(

Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,9 @@ extension TextViewController {
189189
}
190190

191191
func setUpKeyBindings(eventMonitor: inout Any?) {
192-
eventMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event -> NSEvent? in
192+
eventMonitor = NSEvent.addLocalMonitorForEvents(
193+
matching: [.keyDown, .flagsChanged, .mouseMoved]
194+
) { [weak self] event -> NSEvent? in
193195
guard let self = self else { return event }
194196

195197
// Check if this window is key and if the text view is the first responder
@@ -198,14 +200,36 @@ extension TextViewController {
198200

199201
// Only handle commands if this is the key window and text view is first responder
200202
guard isKeyWindow && isFirstResponder else { return event }
201-
202203
let modifierFlags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
203-
let tabKey: UInt16 = 0x30
204204

205-
if event.keyCode == tabKey {
206-
return self.handleTab(event: event, modifierFalgs: modifierFlags.rawValue)
207-
} else {
208-
return self.handleCommand(event: event, modifierFlags: modifierFlags.rawValue)
205+
switch event.type {
206+
case .keyDown:
207+
let tabKey: UInt16 = 0x30
208+
209+
if event.keyCode == tabKey {
210+
return self.handleTab(event: event, modifierFalgs: modifierFlags.rawValue)
211+
} else {
212+
return self.handleCommand(event: event, modifierFlags: modifierFlags.rawValue)
213+
}
214+
case .flagsChanged:
215+
if modifierFlags.contains(.command),
216+
let coords = view.window?.convertPoint(fromScreen: NSEvent.mouseLocation) {
217+
self.jumpToDefinitionModel?.mouseHovered(windowCoordinates: coords)
218+
}
219+
220+
if !modifierFlags.contains(.command) {
221+
self.jumpToDefinitionModel?.cancelHover()
222+
}
223+
return event
224+
case .mouseMoved:
225+
guard modifierFlags.contains(.command) else {
226+
self.jumpToDefinitionModel?.cancelHover()
227+
return event
228+
}
229+
self.jumpToDefinitionModel?.mouseHovered(windowCoordinates: event.locationInWindow)
230+
return event
231+
default:
232+
return event
209233
}
210234
}
211235
}

Sources/CodeEditSourceEditor/Controller/TextViewController.swift

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,8 @@ public class TextViewController: NSViewController {
180180
/// Filters used when applying edits..
181181
var textFilters: [TextFormation.Filter] = []
182182

183+
var jumpToDefinitionModel: JumpToDefinitionModel?
184+
183185
var cancellables = Set<AnyCancellable>()
184186

185187
/// The trailing inset for the editor. Grows when line wrapping is disabled or when the minimap is shown.
@@ -223,7 +225,7 @@ public class TextViewController: NSViewController {
223225
self.treeSitterClient = client
224226
}
225227

226-
self.textView = TextView(
228+
self.textView = SourceEditorTextView(
227229
string: string,
228230
font: font,
229231
textColor: theme.text.color,
@@ -242,6 +244,14 @@ public class TextViewController: NSViewController {
242244
$0.prepareCoordinator(controller: self)
243245
}
244246
self.textCoordinators = coordinators.map { WeakCoordinator($0) }
247+
248+
if let treeSitterClient {
249+
jumpToDefinitionModel = JumpToDefinitionModel(
250+
textView: textView,
251+
treeSitterClient: treeSitterClient,
252+
delegate: nil
253+
)
254+
}
245255
}
246256

247257
required init?(coder: NSCoder) {
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
//
2+
// JumpToDefinitionDelegate.swift
3+
// CodeEditSourceEditor
4+
//
5+
// Created by Khan Winter on 7/23/25.
6+
//
7+
8+
import Foundation
9+
10+
public protocol JumpToDefinitionDelegate: AnyObject {
11+
func queryLinks(forRange range: NSRange) async -> [JumpToDefinitionLink]?
12+
func openLink(url: URL, targetRange: NSRange)
13+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
//
2+
// JumpToDefinitionLink.swift
3+
// CodeEditSourceEditor
4+
//
5+
// Created by Khan Winter on 7/23/25.
6+
//
7+
8+
import Foundation
9+
import SwiftUI
10+
11+
public struct JumpToDefinitionLink: Identifiable, Sendable {
12+
public var id: String { url?.absoluteString ?? "\(targetRange)" }
13+
/// Leave as `nil` if the link is in the same document.
14+
public let url: URL?
15+
public let targetPosition: CursorPosition
16+
public let targetRange: NSRange
17+
18+
public let typeName: String
19+
public let sourcePreview: String
20+
21+
public let image: Image
22+
public let imageColor: Color
23+
24+
public init(
25+
url: URL?,
26+
targetPosition: CursorPosition,
27+
targetRange: NSRange,
28+
typeName: String,
29+
sourcePreview: String,
30+
image: Image = Image(systemName: "dot.square.fill"),
31+
imageColor: Color = Color(NSColor.lightGray)
32+
) {
33+
self.url = url
34+
self.targetPosition = targetPosition
35+
self.targetRange = targetRange
36+
self.typeName = typeName
37+
self.sourcePreview = sourcePreview
38+
self.image = image
39+
self.imageColor = imageColor
40+
}
41+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
//
2+
// JumpToDefinitionLinkList.swift
3+
// CodeEditSourceEditor
4+
//
5+
// Created by Khan Winter on 7/23/25.
6+
//
7+
8+
import SwiftUI
9+
10+
struct JumpToDefinitionLinkList: View {
11+
let items: [JumpToDefinitionLink]
12+
let font: NSFont
13+
let dismiss: () -> Void
14+
let onSelect: (JumpToDefinitionLink) -> Void
15+
16+
private let maxVisibleItems = 5
17+
18+
@State private var selectedRow: JumpToDefinitionLink?
19+
20+
var body: some View {
21+
VStack {
22+
if items.count > maxVisibleItems {
23+
ScrollView {
24+
listStack
25+
}
26+
.scrollIndicators(.hidden)
27+
} else {
28+
listStack
29+
}
30+
if let selectedRow {
31+
VStack {
32+
Text(selectedRow.sourcePreview)
33+
.font(Font(font))
34+
HStack {
35+
ForEach(selectedRow.url?.pathComponents ?? [], id: \.self) { component in
36+
Text(component)
37+
Image(systemName: "chevron.compact.right")
38+
}
39+
}
40+
.font(.system(size: 12))
41+
}
42+
}
43+
}
44+
}
45+
46+
@ViewBuilder private var listStack: some View {
47+
VStack(spacing: 0) {
48+
ForEach(items) { item in
49+
HStack(alignment: .firstTextBaseline, spacing: 2) {
50+
item.image
51+
.foregroundStyle(.white, item.imageColor)
52+
Text(item.typeName)
53+
Spacer(minLength: 0)
54+
}
55+
.font(Font(font))
56+
.contentShape(Rectangle())
57+
.onTapGesture {
58+
if let selectedRow {
59+
onSelect(selectedRow)
60+
}
61+
dismiss()
62+
}
63+
.onHover { isHovered in
64+
if isHovered {
65+
selectedRow = item
66+
} else if !isHovered && selectedRow?.id == item.id {
67+
selectedRow = nil
68+
}
69+
}
70+
}
71+
}
72+
}
73+
}
74+
75+
#if DEBUG
76+
77+
#Preview {
78+
JumpToDefinitionLinkList(items: [], font: .monospacedSystemFont(ofSize: 12, weight: .medium)) {
79+
80+
} onSelect: { _ in
81+
82+
}
83+
}
84+
85+
#endif
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
//
2+
// JumpToDefinitionModel.swift
3+
// CodeEditSourceEditor
4+
//
5+
// Created by Khan Winter on 7/23/25.
6+
//
7+
8+
import AppKit
9+
import CodeEditTextView
10+
11+
/// Manages two things:
12+
/// - Finding a range to hover when pressing `cmd` using tree-sitter.
13+
/// - Utilizing the `JumpToDefinitionDelegate` object to perform a jump, providing it with ranges and
14+
/// strings as necessary.
15+
/// - Presenting a popover when multiple options exist to jump to.
16+
@MainActor
17+
final class JumpToDefinitionModel {
18+
static let emphasisId = "jumpToDefinition"
19+
20+
weak var delegate: JumpToDefinitionDelegate?
21+
22+
private weak var textView: TextView?
23+
private weak var treeSitterClient: TreeSitterClient?
24+
25+
private(set) public var hoveredRange: NSRange?
26+
private var hoverRequestTask: Task<Void, Never>?
27+
28+
private var jumpRequestTask: Task<Void, Never>?
29+
30+
init(textView: TextView, treeSitterClient: TreeSitterClient, delegate: JumpToDefinitionDelegate?) {
31+
self.textView = textView
32+
self.treeSitterClient = treeSitterClient
33+
self.delegate = delegate
34+
}
35+
36+
// MARK: - Tree Sitter
37+
38+
/// Query the tree-sitter client for a valid range to query for definitions.
39+
/// - Parameter location: The current cursor location.
40+
/// - Returns: A range that contains a potential identifier to look up.
41+
private func findDefinitionRange(at location: Int) async -> NSRange? {
42+
guard let nodes = try? await treeSitterClient?.nodesAt(location: location),
43+
let node = nodes.first(where: { $0.node.nodeType?.contains("identifier") == true }) else {
44+
cancelHover()
45+
return nil
46+
}
47+
guard !Task.isCancelled else { return nil }
48+
return node.node.range
49+
}
50+
51+
// MARK: - Jump Action
52+
53+
/// Performs the jump action.
54+
/// - Parameter location: The location to query the delegate for.
55+
func performJump(at location: NSRange) {
56+
jumpRequestTask?.cancel()
57+
jumpRequestTask = Task {
58+
guard let links = await delegate?.queryLinks(forRange: location),
59+
!links.isEmpty else {
60+
NSSound.beep()
61+
return
62+
}
63+
if links.count == 1 {
64+
let link = links[0]
65+
if let url = link.url {
66+
delegate?.openLink(url: url, targetRange: link.targetRange)
67+
} else {
68+
textView?.selectionManager.setSelectedRange(link.targetRange)
69+
}
70+
71+
textView?.scrollSelectionToVisible()
72+
} else {
73+
presentLinkPopover(on: location, links: links)
74+
}
75+
}
76+
}
77+
78+
// MARK: - Link Popover
79+
80+
func presentLinkPopover(on range: NSRange, links: [JumpToDefinitionLink]) {
81+
let halfway = range.location + (range.length / 2)
82+
guard let textView = textView, let firstRect = textView.layoutManager.rectForOffset(halfway) else { return }
83+
let popover = NSPopover()
84+
popover.behavior = .transient
85+
popover.show(relativeTo: firstRect, of: textView, preferredEdge: .minY)
86+
}
87+
88+
// MARK: - Mouse Interaction
89+
90+
func mouseHovered(windowCoordinates: CGPoint) {
91+
guard let textViewCoords = textView?.convert(windowCoordinates, from: nil),
92+
let location = textView?.layoutManager.textOffsetAtPoint(textViewCoords),
93+
location < textView?.textStorage.length ?? 0 else {
94+
cancelHover()
95+
return
96+
}
97+
98+
if hoveredRange?.contains(location) == false {
99+
cancelHover()
100+
}
101+
102+
hoverRequestTask?.cancel()
103+
hoverRequestTask = Task {
104+
guard let newRange = await findDefinitionRange(at: location) else { return }
105+
updateHoveredRange(to: newRange)
106+
}
107+
}
108+
109+
func cancelHover() {
110+
if (textView as? SourceEditorTextView)?.additionalCursorRects.isEmpty != true {
111+
(textView as? SourceEditorTextView)?.additionalCursorRects = []
112+
textView?.resetCursorRects()
113+
}
114+
guard hoveredRange != nil else { return }
115+
hoveredRange = nil
116+
hoverRequestTask?.cancel()
117+
textView?.emphasisManager?.removeEmphases(for: Self.emphasisId)
118+
}
119+
120+
private func updateHoveredRange(to newRange: NSRange) {
121+
let rects = textView?.layoutManager.rectsFor(range: newRange).map { ($0, NSCursor.pointingHand) } ?? []
122+
(textView as? SourceEditorTextView)?.additionalCursorRects = rects
123+
textView?.resetCursorRects()
124+
125+
hoveredRange = newRange
126+
127+
textView?.emphasisManager?.removeEmphases(for: Self.emphasisId)
128+
let color = textView?.selectionManager.selectionBackgroundColor ?? .selectedTextBackgroundColor
129+
textView?.emphasisManager?.addEmphasis(
130+
Emphasis(range: newRange, style: .outline( color: color, fill: true)),
131+
for: Self.emphasisId
132+
)
133+
}
134+
}

0 commit comments

Comments
 (0)