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()