Skip to content

Commit 5eb6897

Browse files
committed
v0.3.5: fix caret and HUD drift
1 parent 85d0ce8 commit 5eb6897

4 files changed

Lines changed: 117 additions & 28 deletions

File tree

Sources/Spotlight/MultilineEditor.swift

Lines changed: 31 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -835,17 +835,14 @@ final class PlaceholderTextView: NSTextView {
835835
/// layout manager centers glyphs inside that fragment, so the caret uses
836836
/// the same vertical inset.
837837
override func drawInsertionPoint(in rect: NSRect, color: NSColor, turnedOn flag: Bool) {
838-
let rect = normalizedInsertionPointRect(rect)
839-
let caretFont = font ?? .systemFont(ofSize: 14)
840-
let fontHeight = caretFont.ascender - caretFont.descender
841-
let centeredGlyphInset = max(0, rect.height - fontHeight) / 2
838+
let displayRect = insertionPointDisplayRect(for: rect, turnedOn: flag)
842839
if vimEngine?.mode == .normal {
843-
let blockWidth = max(rect.width, 8)
840+
let blockWidth = max(displayRect.width, 8)
844841
let blockRect = NSRect(
845-
x: rect.origin.x,
846-
y: rect.origin.y + centeredGlyphInset,
842+
x: displayRect.origin.x,
843+
y: displayRect.origin.y,
847844
width: blockWidth,
848-
height: fontHeight
845+
height: displayRect.height
849846
)
850847
guard flag else {
851848
invalidateInsertionPointRect(blockRect)
@@ -855,14 +852,33 @@ final class PlaceholderTextView: NSTextView {
855852
color.withAlphaComponent(0.4).setFill()
856853
blockRect.fill()
857854
} else {
858-
let shrunk = NSRect(
859-
x: rect.origin.x,
860-
y: rect.origin.y + centeredGlyphInset,
861-
width: rect.width,
862-
height: fontHeight
863-
)
864-
super.drawInsertionPoint(in: shrunk, color: color, turnedOn: flag)
855+
super.drawInsertionPoint(in: displayRect, color: color, turnedOn: flag)
856+
if flag {
857+
lastInsertionPointDisplayRect = displayRect
858+
} else {
859+
invalidateInsertionPointRect(displayRect)
860+
}
861+
}
862+
}
863+
864+
func insertionPointDisplayRect(for rect: NSRect, turnedOn flag: Bool) -> NSRect {
865+
if !flag, let lastInsertionPointDisplayRect {
866+
return lastInsertionPointDisplayRect
865867
}
868+
let baseRect = flag ? normalizedInsertionPointRect(rect) : rect
869+
return shrinkInsertionPointRectToFont(baseRect)
870+
}
871+
872+
private func shrinkInsertionPointRectToFont(_ rect: NSRect) -> NSRect {
873+
let caretFont = font ?? .systemFont(ofSize: 14)
874+
let fontHeight = caretFont.ascender - caretFont.descender
875+
let centeredGlyphInset = max(0, rect.height - fontHeight) / 2
876+
return NSRect(
877+
x: rect.origin.x,
878+
y: rect.origin.y + centeredGlyphInset,
879+
width: rect.width,
880+
height: fontHeight
881+
)
866882
}
867883

868884
private func invalidateInsertionPointRect(_ rect: NSRect) {

Sources/Spotlight/SpotlightWindow.swift

Lines changed: 63 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,14 @@ public final class SpotlightWindowController {
7070
}
7171
}
7272
private var navAnchor: NavAnchorState = .none
73+
private struct MeasuredHeightCache {
74+
let text: String
75+
let maxVisibleLines: Int
76+
let chromeAbove: CGFloat
77+
let chromeBelow: CGFloat
78+
let height: CGFloat
79+
}
80+
7381
/// Screen-space `y` of the editor card's top edge. Every non-nav
7482
/// resize (tutorial toggle, editor text growth) keeps this point
7583
/// fixed so the editor never visually jumps. Reset to nil on every
@@ -78,6 +86,8 @@ public final class SpotlightWindowController {
7886
/// what snaps the drifted-during-cycling editor back to its rest
7987
/// position the moment the user starts typing again.
8088
private var editorTopY: CGFloat?
89+
private var measuredHeightCache: MeasuredHeightCache?
90+
private var programmaticFrameToIgnore: NSRect?
8191
private var cancellables: Set<AnyCancellable> = []
8292

8393
/// Layout above the editor card inside the panel (find bar + tutorial
@@ -108,9 +118,24 @@ public final class SpotlightWindowController {
108118
/// Total panel height SwiftUI will render with the current state.
109119
/// Mirrors `SpotlightRootView.extraChromeHeight + editor`.
110120
private var expectedPanelHeight: CGFloat {
121+
let chromeAbove = chromeAboveEditor
122+
let chromeBelow = chromeBelowEditor
123+
if let measuredHeightCache {
124+
if hasMeasuredHeight(forChromeAbove: chromeAbove, chromeBelow: chromeBelow) {
125+
return measuredHeightCache.height
126+
}
127+
}
111128
let lines = EditorMetrics.lineCount(in: session.currentText)
112129
let editor = EditorMetrics.panelHeight(forLines: lines, maxLines: preferences.maxVisibleLines)
113-
return editor + chromeAboveEditor + chromeBelowEditor
130+
return editor + chromeAbove + chromeBelow
131+
}
132+
133+
private func hasMeasuredHeight(forChromeAbove chromeAbove: CGFloat, chromeBelow: CGFloat) -> Bool {
134+
guard let measuredHeightCache else { return false }
135+
return measuredHeightCache.text == session.currentText
136+
&& measuredHeightCache.maxVisibleLines == preferences.maxVisibleLines
137+
&& measuredHeightCache.chromeAbove == chromeAbove
138+
&& measuredHeightCache.chromeBelow == chromeBelow
114139
}
115140

116141
public init(
@@ -249,7 +274,7 @@ public final class SpotlightWindowController {
249274
pinnedTopY = top
250275
}
251276
let x = (screenFrame.midX - panel.frame.width / 2).rounded()
252-
panel.setFrame(
277+
setPanelFrame(
253278
NSRect(x: x, y: top - height, width: panel.frame.width, height: height),
254279
display: false
255280
)
@@ -270,12 +295,12 @@ public final class SpotlightWindowController {
270295
panel.isOpaque = false
271296
panel.backgroundColor = .clear
272297
panel.hasShadow = true
273-
panel.level = .floating
298+
panel.level = Self.panelLevel
274299
panel.isFloatingPanel = true
275300
panel.hidesOnDeactivate = false
276301
panel.becomesKeyOnlyIfNeeded = false
277302
panel.isMovableByWindowBackground = true
278-
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
303+
panel.collectionBehavior = Self.panelCollectionBehavior
279304
panel.contentView = NSHostingView(
280305
rootView: SpotlightRootView(
281306
focusTrigger: focusTrigger,
@@ -332,7 +357,10 @@ public final class SpotlightWindowController {
332357
guard dx <= Self.driftCorrectionThreshold, dy <= Self.driftCorrectionThreshold else {
333358
return
334359
}
335-
panel.setFrameOrigin(target)
360+
setPanelFrame(
361+
NSRect(origin: target, size: panel.frame.size),
362+
display: true
363+
)
336364
}
337365

338366
private func setPanelHeight(_ height: CGFloat, animated: Bool) {
@@ -356,14 +384,42 @@ public final class SpotlightWindowController {
356384
width: current.size.width,
357385
height: height
358386
)
359-
panel.setFrame(newFrame, display: true, animate: animated)
387+
measuredHeightCache = MeasuredHeightCache(
388+
text: session.currentText,
389+
maxVisibleLines: preferences.maxVisibleLines,
390+
chromeAbove: chromeAbove,
391+
chromeBelow: chromeBelowEditor,
392+
height: height
393+
)
394+
setPanelFrame(newFrame, display: true, animate: animated)
360395
if case .pendingFirstResize = navAnchor {
361396
// The overlay is now on screen. Lock its bottom edge for every
362397
// subsequent cycle.
363398
navAnchor = .bottomPinned(newY)
364399
}
365400
}
366401

402+
private func setPanelFrame(_ frame: NSRect, display: Bool, animate: Bool = false) {
403+
guard let panel else { return }
404+
programmaticFrameToIgnore = frame
405+
panel.setFrame(frame, display: display, animate: animate)
406+
}
407+
408+
private func shouldIgnoreProgrammaticMove(_ frame: NSRect) -> Bool {
409+
guard let expected = programmaticFrameToIgnore else { return false }
410+
guard Self.rect(frame, isApproximatelyEqualTo: expected) else { return false }
411+
programmaticFrameToIgnore = nil
412+
return true
413+
}
414+
415+
private static func rect(_ lhs: NSRect, isApproximatelyEqualTo rhs: NSRect) -> Bool {
416+
let tolerance: CGFloat = 0.5
417+
return abs(lhs.origin.x - rhs.origin.x) <= tolerance
418+
&& abs(lhs.origin.y - rhs.origin.y) <= tolerance
419+
&& abs(lhs.size.width - rhs.size.width) <= tolerance
420+
&& abs(lhs.size.height - rhs.size.height) <= tolerance
421+
}
422+
367423
}
368424

369425
extension SpotlightWindowController {
@@ -405,6 +461,7 @@ extension SpotlightWindowController {
405461
) { [weak self, weak panel] _ in
406462
MainActor.assumeIsolated {
407463
guard let self, let panel else { return }
464+
if self.shouldIgnoreProgrammaticMove(panel.frame) { return }
408465
let newTop = panel.frame.origin.y + panel.frame.size.height
409466
self.pinnedTopY = newTop
410467
self.editorTopY = newTop - self.chromeAboveEditor

Tests/SpotlightTests/LineHeightConsistencyTests.swift

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -116,9 +116,6 @@ struct LineHeightConsistencyTests {
116116
useFixedLayoutManager: true
117117
)
118118
#expect(lines.count == 4)
119-
let gap12 = (lines[2].baseline - lines[1].baseline * 100).rounded() / 100
120-
let gap23 = (lines[3].baseline - lines[2].baseline * 100).rounded() / 100
121-
// Use the actual computed gaps, not the intermediate expression
122119
let realGap12 = lines[2].baseline - lines[1].baseline
123120
let realGap23 = lines[3].baseline - lines[2].baseline
124121
#expect(
@@ -131,10 +128,6 @@ struct LineHeightConsistencyTests {
131128
func multipleEmptyLines() {
132129
let lines = layoutLines(for: "A\n\n\n\nB\n")
133130
#expect(lines.count == 5)
134-
var gaps: [CGFloat] = []
135-
for i in 1..<lines.count {
136-
gaps.append((lines[i].baseline - lines[i - 1].baseline * 100).rounded() / 100)
137-
}
138131
let realGaps = (1..<lines.count).map {
139132
lines[$0].baseline - lines[$0 - 1].baseline
140133
}

Tests/SpotlightTests/MultilineEditorTokenTests.swift

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,29 @@ struct MultilineEditorChecklistToggleTests {
341341
#expect(textView.string == "prefix ☑ cursor placement")
342342
}
343343

344+
@Test("caret erase uses previously painted rect after selection moves")
345+
func caretEraseUsesPreviousPaintedRectAfterSelectionMoves() {
346+
let textView = makeChecklistTextView(text: "old caret\nnew caret")
347+
textView.setSelectedRange(NSRange(location: 3, length: 0))
348+
349+
let painted = textView.insertionPointDisplayRect(
350+
for: NSRect(x: 30, y: 0, width: 1, height: EditorMetrics.lineHeight),
351+
turnedOn: true
352+
)
353+
let image = NSImage(size: NSSize(width: 240, height: 80))
354+
image.lockFocus()
355+
defer { image.unlockFocus() }
356+
textView.drawInsertionPoint(in: painted, color: .labelColor, turnedOn: true)
357+
textView.setSelectedRange(NSRange(location: (textView.string as NSString).length, length: 0))
358+
359+
let erase = textView.insertionPointDisplayRect(
360+
for: NSRect(x: 160, y: EditorMetrics.lineHeight, width: 1, height: EditorMetrics.lineHeight),
361+
turnedOn: false
362+
)
363+
364+
#expect(erase == painted)
365+
}
366+
344367
private func makeChecklistTextView(text: String) -> PlaceholderTextView {
345368
let textView = PlaceholderTextView(frame: NSRect(x: 0, y: 0, width: EditorMetrics.panelWidth, height: 200))
346369
textView.font = .systemFont(ofSize: EditorMetrics.fontSize)

0 commit comments

Comments
 (0)