Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
52 changes: 32 additions & 20 deletions Sources/Pullo/App/NotchPanelController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -31,6 +33,7 @@ final class NotchPanelController {
MainActor.assumeIsolated { self?.screensChanged() }
}
installEventMonitors()
updatePointerMonitor()
}

func reopen() {
Expand All @@ -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) }
Expand Down Expand Up @@ -79,6 +86,7 @@ final class NotchPanelController {
DispatchQueue.main.async {
MainActor.assumeIsolated {
self?.registerShortcut()
self?.updatePointerMonitor()
self?.screensChanged()
self?.observeSettings()
}
Expand All @@ -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)
Expand Down
32 changes: 32 additions & 0 deletions Sources/Pullo/App/PointerTracker.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
8 changes: 5 additions & 3 deletions Sources/Pullo/Features/Notch/NotchInteraction.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ final class NotchInteraction {
@ObservationIgnored private var hoverTask: Task<Void, Never>?
@ObservationIgnored private var waitingForPointerExit = false
@ObservationIgnored private var pointer: NSPoint?
@ObservationIgnored private var pointerLeft = false
@ObservationIgnored private var buttonHeld = false

init(
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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() {
Expand Down
17 changes: 17 additions & 0 deletions Tests/PulloTests/NotchInteractionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading