Hover intent: opening a notch panel after 80 ms without flicker
SwiftUI's onHover is a hint, not a fact. How NotchStar confirms every hover against the real pointer.
TL;DR
- SwiftUI’s
onHoverreports an exit and an enter whenever a view resizes under a pointer that has not moved. A panel that grows on hover and shrinks on exit will turn that into an endless open and close loop. - NotchStar’s fix, shipped in 1.0.4 on 11 October 2026, treats
onHoveras a hint only. Opening waits 80 ms and then checks the real pointer position. Closing polls every 120 ms until the pointer is really outside a rectangle computed from screen geometry. - The pointer is injected in tests, so the whole behavior is covered by tests that run in a test host with no mouse.
The problem: a panel that moves under the pointer
The notch panel has two sizes. Closed, it is the size of the hardware notch. Open, it is 660 by 296 points. Moving the pointer onto the notch should open it, and moving the pointer away should close it.
The obvious build is a SwiftUI view with .onHover, a state value flipped by the callback, and an animation on the frame. It works until the pointer rests still. When the view grows, the geometry under a stationary pointer changes. SwiftUI answers with a false “exit”, then a fresh “enter”. Each one flips the state. The panel closes, shrinks, reports another enter, opens, and does it again.
That is the symptom recorded in the 1.0.4 changelog (11 October 2026): “resting the pointer on the notch could make it snap shut and open again in a loop.” The comment in the code gives the cause in two sentences:
// SwiftUI's onHover fires spurious exit/enter pairs whenever the panel resizes under a still pointer
// (open grows it, the delayed close shrinks it). Acting on those directly made the notch bounce open and
// shut in a loop. So onHover is only a hint: both directions are confirmed against the real pointer.
The source files are Sources/NotchStar/Notch/NotchController.swift, NotchRootView.swift, NotchReducer.swift and NotchModel.swift. Nothing here was measured on a stopwatch: the timings below are constants from the code, and where behavior was checked it was checked by tests, which are named.
The first version, and why it bounced
The very first controller (commit 522dd70, 6 October 2026) already had an intent delay on the way in. Entering waited 80 ms and then opened, and leaving before the 80 ms were up cancelled the task. The way out, until 1.0.4, was direct:
model.onHover = { [weak self] inside in
if inside { self?.hoverEntered() } else { self?.send(.hoverExited) }
}
Exit went straight to the state machine. That was the wrong turn. The delay only filtered one direction, and the other direction still trusted whatever SwiftUI said. The view that reports hover is the same view that resizes, so it reports on its own animation.
The fix landed in 58bba04 (Release 1.0.4, 11 October 2026): route exit through a function that checks the pointer, and make the enter check the pointer too, after the delay.
The rule: ask the pointer, not the view
The reducer is a pure function and stays trivial. In hover mode, enter opens and exit closes. In click mode, hover is ignored and a click toggles. Escape closes, and a file drag that reaches the notch opens it in either mode.
case (.escape, _): return .closed
case (.dragEntered, _): return .open
case (.hoverEntered, .hover): return .open
case (.hoverExited, .hover): return .closed
case (.clicked, .click): return s == .open ? .closed : .open
case (.clicked, .hover): return .open
All the care is in the controller, which decides when a hover callback becomes a .hoverEntered or .hoverExited event. Entering:
private func hoverEntered() {
intentTask?.cancel()
guard !ShotDragView.isDragging else { return } // a screenshot dragged across the notch is not a hover
intentTask = Task { [weak self] in
try? await Task.sleep(for: .milliseconds(80))
guard !Task.isCancelled, let self, self.hoverRect.contains(self.pointerLocation()) else { return }
self.send(.hoverEntered)
}
}
Three conditions must hold at the end of the 80 ms: the task was not cancelled, the controller still exists, and the real pointer is inside hoverRect. A pointer that only crossed the notch fails the first test, because leaving cancels the task. A false enter from a shrinking panel fails the third, because the pointer is nowhere near the rectangle.
Leaving is the more interesting half:
private func hoverExited() {
intentTask?.cancel()
intentTask = Task { [weak self] in
// Keep checking until the pointer has really left; a spurious exit may not be followed by another.
while true {
try? await Task.sleep(for: .milliseconds(120))
guard !Task.isCancelled, let self, self.model.state == .open else { return }
if !self.hoverRect.contains(self.pointerLocation()) { self.send(.hoverExited); return }
}
}
}
An exit hint starts a loop that wakes every 120 ms. If the pointer is still inside the rectangle, the exit was spurious and the loop simply keeps watching. The comment explains why it must keep watching and not check once: after a false exit, the pointer may later leave for real without SwiftUI sending another exit, because it already sent one. The loop ends when the notch closes, when a newer hover task cancels it, or when the pointer leaves.
pointer rests on the closed notch
enter hint -> wait 80 ms -> pointer inside rect? yes -> open
panel grows under the pointer
exit hint (false) -> poll every 120 ms -> pointer inside rect? yes -> keep watching
enter hint (false) -> cancels the poll, starts 80 ms check -> inside, already open, no change
pointer leaves for good
next poll -> pointer outside rect -> close
The rectangle
hoverRect is computed from the screen, not read from the panel:
var hoverRect: CGRect {
guard let screen = notchScreen else { return .zero }
// Closed, only the notch counts: the screenshot lip hanging below it never opens the panel.
let size = model.state == .open ? NotchModel.openSize : model.metrics.closedSize
let slackBelow = model.showsScreenshotStrip ? 0 : Self.hoverSlack
// While the island shows, its wings count as the notch too.
let width = model.showsIsland ? IslandMetrics.totalWidth(notch: size.width) : size.width
return CGRect(x: screen.frame.midX - width / 2 - Self.hoverSlack, y: screen.frame.maxY - size.height - slackBelow,
width: width + 2 * Self.hoverSlack, height: size.height + slackBelow + Self.hoverSlack)
}
The doc comment on the property gives the reason for not using the panel frame: the frame lags the state by the close delay. When the notch closes, the state flips at once and the window shrinks 420 ms later, so panel.frame is the wrong answer for most of that interval. Screen geometry has no such lag.
hoverSlack is 6 points. Applied to each side, it makes the rectangle 12 points wider than the shape, and it is the margin that stops the edge from flickering when the pointer sits on the boundary. The numbers, derived from the code:
| State | Width | Height | Note |
|---|---|---|---|
| Open | 672 | 308 | 660 by 296, plus 6 on the sides and 6 under, and no strip is showing |
| Closed, no strip | notch width plus 12 | notch height plus 12 | 6 below, 6 slack at the top edge |
| Closed, strip showing | notch width plus 12 | notch height plus 6 | no extra slack below, so the screenshot strip never opens the panel |
| Closed, island showing | island width plus 12 | notch height plus 12 | the wings count as the notch |
Closed, the rectangle ignores the screenshot strip that hangs below the notch. That is deliberate and written in the comment: the strip is a separate window and pointing at it must not open the panel.
Everything else that touches the same state
Hover shares the controller with other inputs, and each had to be made safe against it.
- Pinning.
sendreturns early for.hoverExitedand.escapewhen the panel is pinned, so a pinned notch ignores both. - Screenshots in motion.
hoverEnteredreturns at once whileShotDragView.isDraggingis true. A screenshot thumbnail dragged out of the strip passes over the notch, and that is not a request to open it. - File drags. A drag toward the notch opens the Shelf tool. It uses a separate global monitor and the drag pasteboard’s change count, not hover.
- Closing. After
.closed, the window shrinks after 420 ms. If a new enter arrives inside that window,closeTaskis cancelled and the panel never shrinks. A test covers it. - Clicks. In click mode, hover events are ignored by the reducer, and an outside click closes the notch.
What happens after a hint is accepted
Once a hover survives the checks, it becomes an event and goes through send. That function is where the timing constants meet the window:
func send(_ event: NotchEvent) {
if event == .hoverExited { intentTask?.cancel() }
if model.isPinned && (event == .hoverExited || event == .escape) { return }
let next = NotchReducer.reduce(model.state, event, mode: mode)
guard next != model.state else { return }
closeTask?.cancel()
shadowTask?.cancel()
model.shadowVisible = false // never visible while the shape is moving
model.state = next
updateKeyboard()
// opening: haptic, layout(), shadow after 400 ms. closing: layout() after 420 ms.
}
Three behaviors come out of those lines. A repeated event that does not change the state returns early, so a second enter on an open notch is free. Any state change cancels the pending shadow and shrink tasks, which is what lets a pointer that comes back during the close animation keep the panel open. And the state flips immediately in both directions: the animation is SwiftUI’s job, driven by model.state, while the window frame follows later (immediately on open, after 420 ms on close) so the shape has room to animate inside a window that is already big enough.
The open mode itself is a setting with two values, hover and click, read through the injected modeProvider. In click mode the root view’s tap gesture opens the notch, and the global mouse monitor closes it when a click lands outside shapeRect. In hover mode the same tap gesture opens it too, which is why a click on a closed notch always works. While open, the gesture is switched off with including: open ? .none : .all, so taps reach the tools inside.
The tests
The controller takes two closures so tests never touch real preferences or the real mouse:
init(modeProvider: @escaping @MainActor () -> OpenMode = { AppSettings.shared.openMode },
pointerLocation: @escaping @MainActor () -> CGPoint = { NSEvent.mouseLocation })
A test sets a fake pointer and calls the same callback SwiftUI would call. The regression for this bug reads almost like the report:
func testSpuriousHoverExitWhilePointerInsideKeepsNotchOpen() async {
let c = makeController()
pointer = CGPoint(x: screen.frame.midX, y: screen.frame.maxY - 5)
c.model.onHover?(true)
await settle(0.2)
XCTAssertEqual(c.model.state, .open)
c.model.onHover?(false) // spurious: pointer never moved
await settle()
XCTAssertEqual(c.model.state, .open)
pointer = CGPoint(x: -5_000, y: -5_000) // now really leave, with no further hover event
await settle(0.3)
XCTAssertEqual(c.model.state, .closed)
}
Its sibling covers the opposite mistake, a false enter with the pointer far away, which must leave the notch closed. NotchControllerTests.swift has 15 test functions in total (counted with grep -c "func test" on 4 November 2026). These tests were not run for this post, so the claim is that they exist and what they assert. They run as part of scripts/test-all.sh, which the pre push hook calls.
What is still bad
- The 80 ms is a chosen number. The code does not say how it was chosen and no experiment with other values was recorded, so the post cannot claim it is the best value.
- The exit loop polls every 120 ms for as long as the notch is open with the pointer inside. The cost of that was not measured. CPU use of the app has not been measured at all, so no figure is given.
hoverRectuses the first screen with a notch, or the main screen. Behavior with several displays, a Mac without a notch, full screen apps and Spaces has not been reported by the owner, who has confirmed that all features work properly on their own Mac.- Hover is only confirmed on real hardware by tests that launch the app (
NotchStarUITests) and by the owner. The synthetic tests prove the logic, not how a particular trackpad feels. - Every constant (80, 120, 400, 420, 6) lives inline in the controller, not in a settings file, so changing one means a rebuild.
Try it yourself
- Make a SwiftUI view that grows on hover. Give it
.onHover { inside in print(inside) }and.animation(.spring, value: isOpen). - Put the pointer on the view and keep it still. Watch the console. A view that resizes under a still pointer can print
falsethentruewithout any mouse movement. - Replace the callback with a hint. Start a
Taskthat sleeps for the intent time, then comparesNSEvent.mouseLocationagainst a rectangle you compute fromNSScreen.main?.frame. - Make the exit path a loop that checks until the pointer is outside, and cancel it whenever a newer hint arrives.
- Inject the pointer as a closure, so a test can move it without a mouse.
A starting point for steps 3 and 4:
@MainActor final class HoverGate {
var pointer: () -> CGPoint = { NSEvent.mouseLocation }
var rect: () -> CGRect = { .zero }
private var task: Task<Void, Never>?
func hint(inside: Bool, set: @escaping @MainActor (Bool) -> Void) {
task?.cancel()
task = Task {
while true {
try? await Task.sleep(for: .milliseconds(inside ? 80 : 120))
guard !Task.isCancelled else { return }
let isInside = rect().contains(pointer())
if isInside == inside { set(inside); return }
if inside { return } // enter hint but pointer is elsewhere: ignore it
}
}
}
}
This sketch is not NotchStar code and was not compiled. It shows the shape: a hint, a wait, a check against the real pointer, and a cancel when a newer hint arrives.
Cheat sheet
| Symptom | Likely cause | Fix |
|---|---|---|
| A panel opens and closes in a loop | onHover reports on the view’s own resize | Treat it as a hint and confirm with the pointer |
| It opens when the pointer only passes over | No intent delay | Wait before opening (80 ms here) and cancel on exit |
| It closes while the pointer is still on it | A false exit | Poll until the pointer is outside the rectangle |
| It flickers at the edge | A rectangle with no margin | Add slack (6 points here) |
| The hover rectangle is wrong during the close animation | It was read from the panel frame | Compute it from screen geometry |
| Tests need a real mouse | The pointer is read inside the controller | Inject pointerLocation as a closure |
- Hover is a hint, never an event
- Enter waits, then checks the pointer
- Exit keeps checking until the pointer has left
- The rectangle comes from the screen and has slack
- Pinned and drag states are excluded from hover
- The pointer and the open mode are injected in tests
Close
NotchStar is the app this controller drives. If you want to try the notch, it is at notchstar.com.
NotchStar is a native Mac notch app, $4 once, with a 7 day free trial. Try it, or read the ten tools.