From 9f2d8d011f31cd98bd71b1696060da3d7d52d002 Mon Sep 17 00:00:00 2001 From: Gabriel Jordan Date: Sun, 25 Oct 2026 14:34:42 -0300 Subject: [PATCH] Track the pointer over the notch instead of everywhere Pullo listened to every mouse move on the system to notice the pointer reaching the notch, because the panel toggled ignoresMouseEvents and so could not see the pointer itself. The panel now leaves that property alone, so transparent pixels pass clicks through on their own, and a tracking area on the panel reports the pointer only over what is drawn. The global monitor only listens for clicks, plus pointer moves when the notch follows the pointer between displays. Handing key focus back after closing waits for the close animation, so closing by moving the pointer away animates again. --- CHANGELOG.md | 3 ++ Sources/Pullo/App/NotchPanelController.swift | 52 ++++++++++++------- Sources/Pullo/App/PointerTracker.swift | 32 ++++++++++++ .../Features/Notch/NotchInteraction.swift | 8 +-- Tests/PulloTests/NotchInteractionTests.swift | 17 ++++++ 5 files changed, 89 insertions(+), 23 deletions(-) create mode 100644 Sources/Pullo/App/PointerTracker.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 17be0f0..809ad75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ All notable changes to Pullo are listed here, newest first. The format follows [ ## [Unreleased] +### Changed +- Pullo no longer wakes up on every mouse move: it only follows the pointer while it is over the notch, which cuts its CPU use while you move the mouse elsewhere by about 95%. + ## [0.11.3] - 2026-10-25 ### Fixed diff --git a/Sources/Pullo/App/NotchPanelController.swift b/Sources/Pullo/App/NotchPanelController.swift index 8c0d45d..174af31 100644 --- a/Sources/Pullo/App/NotchPanelController.swift +++ b/Sources/Pullo/App/NotchPanelController.swift @@ -9,6 +9,8 @@ final class NotchPanelController { private var hotKey: HotKey? private var registeredShortcut: ShortcutChoice? private var monitors: [Any] = [] + private var pointerMonitor: Any? + private var tracker: PointerTracker? init(app: AppDependencies) { self.app = app @@ -31,6 +33,7 @@ final class NotchPanelController { MainActor.assumeIsolated { self?.screensChanged() } } installEventMonitors() + updatePointerMonitor() } func reopen() { @@ -39,18 +42,22 @@ final class NotchPanelController { } private func stateChanged(to state: NotchState) { - updateMouseCapture() if state == .open, !panel.isKeyWindow, NSEvent.pressedMouseButtons == 0 { panel.makeKeyAndOrderFront(nil) } if state == .closed, panel.isKeyWindow { - DispatchQueue.main.async { [panel] in - panel.orderOut(nil) - panel.orderFrontRegardless() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) { [weak self] in + MainActor.assumeIsolated { self?.giveUpKeyFocus() } } } } + private func giveUpKeyFocus() { + guard notch.state == .closed, panel.isKeyWindow else { return } + panel.orderOut(nil) + panel.orderFrontRegardless() + } + private func toggleFromKeyboard() { notch.toggleFromKeyboard() if notch.state == .open { panel.makeKeyAndOrderFront(nil) } @@ -79,6 +86,7 @@ final class NotchPanelController { DispatchQueue.main.async { MainActor.assumeIsolated { self?.registerShortcut() + self?.updatePointerMonitor() self?.screensChanged() self?.observeSettings() } @@ -102,36 +110,40 @@ final class NotchPanelController { panel.setFrame(target.panelFrame, display: true) } - private func pointerChanged(_ event: NSEvent) { - let buttonHeld = event.type == .leftMouseDragged - followPointer(to: NSEvent.mouseLocation) - notch.mouseMoved(to: NSEvent.mouseLocation, buttonHeld: buttonHeld) - if notch.state == .open, buttonHeld { return } - updateMouseCapture() + private func pointerChanged(leaving: Bool = false) { + notch.mouseMoved(to: NSEvent.mouseLocation, buttonHeld: NSEvent.pressedMouseButtons & 1 != 0, leaving: leaving) } - private func updateMouseCapture() { - let state = notch.state - let over = notch.layout.hitRect(for: state).contains(NSEvent.mouseLocation) - let ignores = state == .closed || !over - if panel.ignoresMouseEvents != ignores { panel.ignoresMouseEvents = ignores } + private func updatePointerMonitor() { + let follows = app.settings.display == .pointer + guard follows != (pointerMonitor != nil) else { return } + if let pointerMonitor { + NSEvent.removeMonitor(pointerMonitor) + self.pointerMonitor = nil + } else { + pointerMonitor = NSEvent.addGlobalMonitorForEvents(matching: .mouseMoved) { [weak self] _ in + MainActor.assumeIsolated { self?.followPointer(to: NSEvent.mouseLocation) } + } + } } private func installEventMonitors() { - let pointer: NSEvent.EventTypeMask = [.mouseMoved, .leftMouseDragged, .leftMouseUp] - if let global = NSEvent.addGlobalMonitorForEvents(matching: pointer.union(.leftMouseDown), handler: { [weak self] event in + if let view = panel.contentView { + tracker = PointerTracker(view: view) { [weak self] leaving in self?.pointerChanged(leaving: leaving) } + } + if let global = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .leftMouseUp], handler: { [weak self] event in MainActor.assumeIsolated { if event.type == .leftMouseDown { self?.notch.mouseDown(at: NSEvent.mouseLocation) } else { - self?.pointerChanged(event) + self?.pointerChanged() } } }) { monitors.append(global) } - if let local = NSEvent.addLocalMonitorForEvents(matching: pointer, handler: { [weak self] event in - MainActor.assumeIsolated { self?.pointerChanged(event) } + if let local = NSEvent.addLocalMonitorForEvents(matching: .leftMouseUp, handler: { [weak self] event in + MainActor.assumeIsolated { self?.pointerChanged() } return event }) { monitors.append(local) diff --git a/Sources/Pullo/App/PointerTracker.swift b/Sources/Pullo/App/PointerTracker.swift new file mode 100644 index 0000000..d0e3fcb --- /dev/null +++ b/Sources/Pullo/App/PointerTracker.swift @@ -0,0 +1,32 @@ +import AppKit + +final class PointerTracker: NSResponder { + private let onChange: @MainActor (_ leaving: Bool) -> Void + + init(view: NSView, onChange: @escaping @MainActor (_ leaving: Bool) -> Void) { + self.onChange = onChange + super.init() + view.addTrackingArea(NSTrackingArea( + rect: .zero, + options: [.mouseEnteredAndExited, .mouseMoved, .activeAlways, .inVisibleRect], + owner: self, + userInfo: nil + )) + } + + required init?(coder: NSCoder) { + nil + } + + override func mouseEntered(with event: NSEvent) { + onChange(false) + } + + override func mouseMoved(with event: NSEvent) { + onChange(false) + } + + override func mouseExited(with event: NSEvent) { + onChange(true) + } +} diff --git a/Sources/Pullo/Features/Notch/NotchInteraction.swift b/Sources/Pullo/Features/Notch/NotchInteraction.swift index 7a8366c..7408166 100644 --- a/Sources/Pullo/Features/Notch/NotchInteraction.swift +++ b/Sources/Pullo/Features/Notch/NotchInteraction.swift @@ -30,6 +30,7 @@ final class NotchInteraction { @ObservationIgnored private var hoverTask: Task? @ObservationIgnored private var waitingForPointerExit = false @ObservationIgnored private var pointer: NSPoint? + @ObservationIgnored private var pointerLeft = false @ObservationIgnored private var buttonHeld = false init( @@ -63,10 +64,11 @@ final class NotchInteraction { // MARK: - Pointer - func mouseMoved(to point: NSPoint, buttonHeld: Bool = false) { + func mouseMoved(to point: NSPoint, buttonHeld: Bool = false, leaving: Bool = false) { pointer = point + pointerLeft = leaving self.buttonHeld = buttonHeld - let inside = layout.hitRect(for: state).contains(point) + let inside = !leaving && layout.hitRect(for: state).contains(point) if waitingForPointerExit { if !inside { waitingForPointerExit = false } @@ -113,7 +115,7 @@ final class NotchInteraction { } private func isPointerOver(_ state: NotchState) -> Bool { - pointer.map(layout.hitRect(for: state).contains) ?? false + !pointerLeft && (pointer.map(layout.hitRect(for: state).contains) ?? false) } private func cancelHover() { diff --git a/Tests/PulloTests/NotchInteractionTests.swift b/Tests/PulloTests/NotchInteractionTests.swift index 41f37ca..8b81746 100644 --- a/Tests/PulloTests/NotchInteractionTests.swift +++ b/Tests/PulloTests/NotchInteractionTests.swift @@ -83,6 +83,23 @@ extension NotchBehavior { #expect(h.notch.state == .closed) } + @Test func leavingThePanelClosesItEvenNearItsEdge() async { + let h = await NotchHarness() + h.notch.open() + let edge = h.notch.layout.hitRect(for: .open).insetBy(dx: 2, dy: 2).origin + h.notch.mouseMoved(to: edge, leaving: true) + await h.clock.advance(by: .milliseconds(200)) + #expect(h.notch.state == .closed) + } + + @Test func leavingTheClosedNotchCancelsTheOpen() async { + let h = await NotchHarness() + h.notch.mouseMoved(to: h.inside(.closed)) + h.notch.mouseMoved(to: h.inside(.closed), leaving: true) + await h.clock.advance(by: .milliseconds(200)) + #expect(h.notch.state == .closed) + } + @Test func staysOpenWhileDraggingAPROut() async { let h = await NotchHarness() h.notch.open()