Skip to content

Commit fefc805

Browse files
committed
Refactor Suggestion Window
- Creates a `SuggestionViewController` for managing the view contents of the window. - Renames `SuggestionControllerDelegate` to `CodeSuggestionDelegate` - Moves `CodeSuggestionEntry` to its own file. - Removes a few magic numbers - Removes the `horizontalOffset` parameter when moving the window. We'll rely on the suggestion delegate to tell us where to place the window's top-left corner.
1 parent a5bcf89 commit fefc805

File tree

8 files changed

+457
-270
lines changed

8 files changed

+457
-270
lines changed
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
//
2+
// CodeSuggestionDelegate.swift
3+
// CodeEditSourceEditor
4+
//
5+
// Created by Abe Malla on 12/26/24.
6+
//
7+
8+
public protocol CodeSuggestionDelegate: AnyObject {
9+
func completionTriggerCharacters() -> Set<String>
10+
11+
func completionSuggestionsRequested(
12+
textView: TextViewController,
13+
cursorPosition: CursorPosition
14+
) async -> (windowPosition: CursorPosition, items: [CodeSuggestionEntry])?
15+
16+
// This can't be async, we need it to be snappy. At most, it should just be filtering completion items
17+
func completionOnCursorMove(
18+
textView: TextViewController,
19+
cursorPosition: CursorPosition
20+
) -> [CodeSuggestionEntry]?
21+
22+
// Optional
23+
func completionWindowDidClose()
24+
25+
func completionWindowApplyCompletion(
26+
item: CodeSuggestionEntry,
27+
textView: TextViewController,
28+
cursorPosition: CursorPosition
29+
)
30+
// Optional
31+
func completionWindowDidSelect(item: CodeSuggestionEntry)
32+
}
33+
34+
public extension CodeSuggestionDelegate {
35+
func completionTriggerCharacters() -> Set<String> { [] }
36+
func completionWindowDidClose() { }
37+
func completionWindowDidSelect(item: CodeSuggestionEntry) { }
38+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
//
2+
// CodeSuggestionEntry.swift
3+
// CodeEditSourceEditor
4+
//
5+
// Created by Khan Winter on 7/22/25.
6+
//
7+
8+
import AppKit
9+
10+
/// Represents an item that can be displayed in the code suggestion view
11+
public protocol CodeSuggestionEntry {
12+
var view: NSView { get }
13+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
//
2+
// CodeSuggestionRowView.swift
3+
// CodeEditSourceEditor
4+
//
5+
// Created by Khan Winter on 7/22/25.
6+
//
7+
8+
import AppKit
9+
10+
/// Used to draw a custom selection highlight for the table row
11+
final class CodeSuggestionRowView: NSTableRowView {
12+
var getSelectionColor: (() -> NSColor)?
13+
14+
init(getSelectionColor: (() -> NSColor)? = nil) {
15+
self.getSelectionColor = getSelectionColor
16+
super.init(frame: .zero)
17+
}
18+
19+
required init?(coder: NSCoder) {
20+
fatalError("init(coder:) has not been implemented")
21+
}
22+
23+
override func drawSelection(in dirtyRect: NSRect) {
24+
guard isSelected else { return }
25+
guard let context = NSGraphicsContext.current?.cgContext else { return }
26+
27+
context.saveGState()
28+
defer { context.restoreGState() }
29+
30+
// Create a rect that's inset from the edges and has proper padding
31+
// TODO: We create a new selectionRect instead of using dirtyRect
32+
// because there is a visual bug when holding down the arrow keys
33+
// to select the first or last item, which draws a clipped
34+
// rectangular highlight shape instead of the whole rectangle.
35+
// Replace this when it gets fixed.
36+
let selectionRect = NSRect(
37+
x: SuggestionController.WINDOW_PADDING,
38+
y: 0,
39+
width: bounds.width - (SuggestionController.WINDOW_PADDING * 2),
40+
height: bounds.height
41+
)
42+
let cornerRadius: CGFloat = 5
43+
let path = NSBezierPath(roundedRect: selectionRect, xRadius: cornerRadius, yRadius: cornerRadius)
44+
let selectionColor = getSelectionColor?() ?? NSColor.controlBackgroundColor
45+
46+
context.setFillColor(selectionColor.cgColor)
47+
path.fill()
48+
}
49+
}

Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionController+Window.swift

Lines changed: 6 additions & 157 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import AppKit
99

1010
extension SuggestionController {
1111
/// Will constrain the window's frame to be within the visible screen
12-
public func constrainWindowToScreenEdges(cursorRect: NSRect, horizontalOffset: CGFloat) {
12+
public func constrainWindowToScreenEdges(cursorRect: NSRect) {
1313
guard let window = self.window,
1414
let screenFrame = window.screen?.visibleFrame else {
1515
return
@@ -18,7 +18,7 @@ extension SuggestionController {
1818
let windowSize = window.frame.size
1919
let padding: CGFloat = 22
2020
var newWindowOrigin = NSPoint(
21-
x: cursorRect.origin.x - Self.WINDOW_PADDING - horizontalOffset,
21+
x: cursorRect.origin.x - Self.WINDOW_PADDING,
2222
y: cursorRect.origin.y
2323
)
2424

@@ -64,17 +64,11 @@ extension SuggestionController {
6464
static func makeWindow() -> NSWindow {
6565
let window = NSWindow(
6666
contentRect: NSRect(origin: .zero, size: self.DEFAULT_SIZE),
67-
styleMask: [.resizable, .fullSizeContentView, .nonactivatingPanel],
67+
styleMask: [.resizable, .fullSizeContentView, .nonactivatingPanel, .utilityWindow],
6868
backing: .buffered,
6969
defer: false
7070
)
7171

72-
configureWindow(window)
73-
configureWindowContent(window)
74-
return window
75-
}
76-
77-
static func configureWindow(_ window: NSWindow) {
7872
window.titleVisibility = .hidden
7973
window.titlebarAppearsTransparent = true
8074
window.isExcludedFromWindowsMenu = true
@@ -86,87 +80,8 @@ extension SuggestionController {
8680
window.hidesOnDeactivate = true
8781
window.backgroundColor = .clear
8882
window.minSize = Self.DEFAULT_SIZE
89-
}
90-
91-
static func configureWindowContent(_ window: NSWindow) {
92-
guard let contentView = window.contentView else { return }
93-
94-
contentView.wantsLayer = true
95-
// TODO: GET COLOR FROM THEME
96-
contentView.layer?.backgroundColor = CGColor(
97-
srgbRed: 31.0 / 255.0,
98-
green: 31.0 / 255.0,
99-
blue: 36.0 / 255.0,
100-
alpha: 1.0
101-
)
102-
contentView.layer?.cornerRadius = 8.5
103-
contentView.layer?.borderWidth = 1
104-
contentView.layer?.borderColor = NSColor.gray.withAlphaComponent(0.45).cgColor
105-
106-
let innerShadow = NSShadow()
107-
innerShadow.shadowColor = NSColor.black.withAlphaComponent(0.1)
108-
innerShadow.shadowOffset = NSSize(width: 0, height: -1)
109-
innerShadow.shadowBlurRadius = 2
110-
contentView.shadow = innerShadow
111-
}
112-
113-
func configureTableView() {
114-
tableView.delegate = self
115-
tableView.dataSource = self
116-
tableView.headerView = nil
117-
tableView.backgroundColor = .clear
118-
tableView.intercellSpacing = .zero
119-
tableView.allowsEmptySelection = false
120-
tableView.selectionHighlightStyle = .regular
121-
tableView.style = .plain
122-
tableView.usesAutomaticRowHeights = false
123-
tableView.rowSizeStyle = .custom
124-
tableView.rowHeight = 21
125-
tableView.gridStyleMask = []
126-
tableView.target = self
127-
tableView.action = #selector(tableViewClicked(_:))
128-
let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("ItemsCell"))
129-
tableView.addTableColumn(column)
130-
}
131-
132-
@objc private func tableViewClicked(_ sender: Any?) {
133-
if NSApp.currentEvent?.clickCount == 2 {
134-
let row = tableView.selectedRow
135-
guard row >= 0, row < items.count else {
136-
return
137-
}
138-
let selectedItem = items[row]
139-
delegate?.applyCompletionItem(item: selectedItem)
140-
self.close()
141-
}
142-
}
14383

144-
func configureScrollView() {
145-
scrollView.documentView = tableView
146-
scrollView.hasVerticalScroller = true
147-
scrollView.verticalScroller = NoSlotScroller()
148-
scrollView.scrollerStyle = .overlay
149-
scrollView.autohidesScrollers = true
150-
scrollView.drawsBackground = false
151-
scrollView.automaticallyAdjustsContentInsets = false
152-
scrollView.translatesAutoresizingMaskIntoConstraints = false
153-
scrollView.verticalScrollElasticity = .allowed
154-
scrollView.contentInsets = NSEdgeInsets(
155-
top: Self.WINDOW_PADDING,
156-
left: 0,
157-
bottom: Self.WINDOW_PADDING,
158-
right: 0
159-
)
160-
161-
guard let contentView = window?.contentView else { return }
162-
contentView.addSubview(scrollView)
163-
164-
NSLayoutConstraint.activate([
165-
scrollView.topAnchor.constraint(equalTo: contentView.topAnchor),
166-
scrollView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
167-
scrollView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
168-
scrollView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor)
169-
])
84+
return window
17085
}
17186

17287
/// Updates the item box window's height based on the number of items.
@@ -176,12 +91,9 @@ extension SuggestionController {
17691
return
17792
}
17893

179-
noItemsLabel.isHidden = !items.isEmpty
180-
scrollView.isHidden = items.isEmpty
181-
18294
// Update window dimensions
183-
let numberOfVisibleRows = min(CGFloat(items.count), Self.MAX_VISIBLE_ROWS)
184-
let newHeight = items.count == 0 ?
95+
let numberOfVisibleRows = min(CGFloat(model.items.count), Self.MAX_VISIBLE_ROWS)
96+
let newHeight = model.items.count == 0 ?
18597
Self.rowsToWindowHeight(for: 1) : // Height for 1 row when empty
18698
Self.rowsToWindowHeight(for: numberOfVisibleRows)
18799

@@ -206,15 +118,6 @@ extension SuggestionController {
206118
window.minSize = NSSize(width: Self.DEFAULT_SIZE.width, height: newHeight)
207119
}
208120

209-
func configureNoItemsLabel() {
210-
window?.contentView?.addSubview(noItemsLabel)
211-
212-
NSLayoutConstraint.activate([
213-
noItemsLabel.centerXAnchor.constraint(equalTo: window!.contentView!.centerXAnchor),
214-
noItemsLabel.centerYAnchor.constraint(equalTo: window!.contentView!.centerYAnchor)
215-
])
216-
}
217-
218121
/// Calculate the window height for a given number of rows.
219122
static func rowsToWindowHeight(for numberOfRows: CGFloat) -> CGFloat {
220123
let wholeRows = floor(numberOfRows)
@@ -229,57 +132,3 @@ extension SuggestionController {
229132
return baseHeight + partialHeight + padding
230133
}
231134
}
232-
233-
extension SuggestionController: NSTableViewDataSource, NSTableViewDelegate {
234-
public func numberOfRows(in tableView: NSTableView) -> Int {
235-
return items.count
236-
}
237-
238-
public func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
239-
guard row >= 0, row < items.count else { return nil }
240-
return items[row].view
241-
}
242-
243-
public func tableView(_ tableView: NSTableView, rowViewForRow row: Int) -> NSTableRowView? {
244-
CodeSuggestionRowView()
245-
}
246-
247-
public func tableView(_ tableView: NSTableView, shouldSelectRow row: Int) -> Bool {
248-
// Only allow selection through keyboard navigation or single clicks
249-
let event = NSApp.currentEvent
250-
if event?.type == .leftMouseDragged {
251-
return false
252-
}
253-
return true
254-
}
255-
}
256-
257-
/// Used to draw a custom selection highlight for the table row
258-
private class CodeSuggestionRowView: NSTableRowView {
259-
override func drawSelection(in dirtyRect: NSRect) {
260-
guard isSelected else { return }
261-
guard let context = NSGraphicsContext.current?.cgContext else { return }
262-
263-
context.saveGState()
264-
defer { context.restoreGState() }
265-
266-
// Create a rect that's inset from the edges and has proper padding
267-
// TODO: We create a new selectionRect instead of using dirtyRect
268-
// because there is a visual bug when holding down the arrow keys
269-
// to select the first or last item, which draws a clipped
270-
// rectangular highlight shape instead of the whole rectangle.
271-
// Replace this when it gets fixed.
272-
let selectionRect = NSRect(
273-
x: SuggestionController.WINDOW_PADDING,
274-
y: 0,
275-
width: bounds.width - (SuggestionController.WINDOW_PADDING * 2),
276-
height: bounds.height
277-
)
278-
let cornerRadius: CGFloat = 5
279-
let path = NSBezierPath(roundedRect: selectionRect, xRadius: cornerRadius, yRadius: cornerRadius)
280-
let selectionColor = NSColor.gray.withAlphaComponent(0.19)
281-
282-
context.setFillColor(selectionColor.cgColor)
283-
path.fill()
284-
}
285-
}

0 commit comments

Comments
 (0)