Skip to content
Open
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
Binary file removed .DS_Store
Binary file not shown.
16 changes: 16 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,19 @@
# macOS system files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes

# Xcode user data & derived data
build/
DerivedData/
*.xcodeproj/xcuserdata/
*.xcodeproj/project.xcworkspace/xcuserdata/
*.xcworkspace/xcuserdata/
*.xcuserstate

# Temporary scratch & release build artifacts
scratch/
dmg-source/
*.dmg
Binary file removed Purrl.icns
Binary file not shown.
18 changes: 12 additions & 6 deletions Purrl.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -182,10 +182,11 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_IDENTITY = "-";
COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = 67NY9CT7W7;
DEVELOPMENT_TEAM = "";
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
Expand Down Expand Up @@ -249,10 +250,11 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_IDENTITY = "-";
COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = 67NY9CT7W7;
DEVELOPMENT_TEAM = "";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
Expand All @@ -279,11 +281,13 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CODE_SIGN_IDENTITY = "-";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
CODE_SIGN_STYLE = Manual;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 67NY9CT7W7;
DEVELOPMENT_TEAM = "";
ENABLE_APP_SANDBOX = NO;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
Expand Down Expand Up @@ -314,11 +318,13 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CODE_SIGN_IDENTITY = "-";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
CODE_SIGN_STYLE = Manual;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 67NY9CT7W7;
DEVELOPMENT_TEAM = "";
ENABLE_APP_SANDBOX = NO;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
Expand Down
Binary file not shown.
15 changes: 15 additions & 0 deletions Purrl/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,16 @@ class AppDelegate: NSObject, NSApplicationDelegate {
menu.setSubmenu(toothMenu, for: toothParent)
menu.addItem(toothParent)

let onlyScrollableItem = NSMenuItem(
title: scrollEngine.onlyScrollableContent
? NSLocalizedString("menu.onlyScrollableEnabled", comment: "")
: NSLocalizedString("menu.onlyScrollable", comment: ""),
action: #selector(toggleOnlyScrollable),
keyEquivalent: ""
)
onlyScrollableItem.target = self
menu.addItem(onlyScrollableItem)

menu.addItem(NSMenuItem.separator())

let launchAtLoginItem = NSMenuItem(
Expand Down Expand Up @@ -146,6 +156,11 @@ class AppDelegate: NSObject, NSApplicationDelegate {
rebuildMenu()
}

@objc func toggleOnlyScrollable() {
scrollEngine.onlyScrollableContent.toggle()
rebuildMenu()
}

@objc func selectTooth(_ sender: NSMenuItem) {
guard let value = sender.representedObject as? CGFloat else { return }
scrollEngine.toothSize = value
Expand Down
10 changes: 10 additions & 0 deletions Purrl/ScrollHapticEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ class ScrollHapticEngine {
private var lastToothTime: TimeInterval = 0
private let minToothInterval: TimeInterval = 0.012

private let scrollDetector = ScrollabilityDetector()

// Configurable, con persistencia
var toothSize: CGFloat {
get { UserDefaults.standard.object(forKey: "toothSize") as? CGFloat ?? 8.0 }
Expand All @@ -25,6 +27,11 @@ class ScrollHapticEngine {
set { UserDefaults.standard.set(newValue, forKey: "isEnabled") }
}

var onlyScrollableContent: Bool {
get { UserDefaults.standard.object(forKey: "onlyScrollableContent") as? Bool ?? true }
set { UserDefaults.standard.set(newValue, forKey: "onlyScrollableContent") }
}

func start() {
globalMonitor = NSEvent.addGlobalMonitorForEvents(matching: .scrollWheel) { [weak self] event in
self?.handleScroll(event)
Expand All @@ -44,6 +51,9 @@ class ScrollHapticEngine {

private func handleScroll(_ event: NSEvent) {
guard isEnabled else { return }
if onlyScrollableContent {
guard scrollDetector.isScrollableAtCurrentCursor() else { return }
}
let delta = abs(event.scrollingDeltaY)
guard delta > 0 else { return }

Expand Down
110 changes: 110 additions & 0 deletions Purrl/ScrollabilityDetector.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
//
// ScrollabilityDetector.swift
// Purrl
//
// Created by Purrl on 08/08/2026.
//

import Cocoa
import ApplicationServices

class ScrollabilityDetector {
private var cachedLocation: CGPoint = .zero
private var cachedIsScrollable: Bool = true
private var cachedTime: TimeInterval = 0
private let cacheDuration: TimeInterval = 0.04 // 40ms cache for instant responsiveness
private let cacheDistanceThreshold: CGFloat = 1.0 // 1 point movement threshold

/// Returns whether the UI element under the current mouse cursor is in a scrollable view or region.
func isScrollableAtCurrentCursor() -> Bool {
guard let cgEvent = CGEvent(source: nil) else { return true }
let point = cgEvent.location
let now = ProcessInfo.processInfo.systemUptime

// Use cached decision if cursor location hasn't moved and cache is fresh
let distance = hypot(point.x - cachedLocation.x, point.y - cachedLocation.y)
if distance < cacheDistanceThreshold && (now - cachedTime) < cacheDuration {
return cachedIsScrollable
}

let isScrollable = checkScrollability(at: point)

cachedLocation = point
cachedTime = now
cachedIsScrollable = isScrollable
return isScrollable
}

private func checkScrollability(at point: CGPoint) -> Bool {
// If process is not trusted for Accessibility, default to true
guard AXIsProcessTrusted() else { return true }

let systemWide = AXUIElementCreateSystemWide()
var elementRef: AXUIElement?
let result = AXUIElementCopyElementAtPosition(systemWide, Float(point.x), Float(point.y), &elementRef)

guard result == .success, let startElement = elementRef else {
return true
}

var current: AXUIElement? = startElement
var depth = 0
let maxDepth = 15

// Recognized scrollable container roles in macOS Accessibility
let scrollableRoles: Set<String> = [
kAXScrollAreaRole,
"AXScrollView",
"AXWebArea",
kAXTableRole,
kAXOutlineRole,
kAXListRole,
kAXTextAreaRole,
kAXScrollBarRole,
"AXGrid",
"AXBrowser"
]

while let elem = current, depth < maxDepth {
// 1. Inspect role, subrole, and title
var roleRef: CFTypeRef?
AXUIElementCopyAttributeValue(elem, kAXRoleAttribute as CFString, &roleRef)
let roleString = (roleRef as? String) ?? ""

var subroleRef: CFTypeRef?
AXUIElementCopyAttributeValue(elem, kAXSubroleAttribute as CFString, &subroleRef)
let subroleString = (subroleRef as? String) ?? ""

var titleRef: CFTypeRef?
AXUIElementCopyAttributeValue(elem, kAXTitleAttribute as CFString, &titleRef)
let titleString = (titleRef as? String) ?? ""

// Non-scrollable system regions (Menu bar, Status bar, Dock)
if roleString == kAXMenuBarRole || roleString == "AXMenuExtra" || roleString == kAXMenuRole || roleString == kAXMenuItemRole || subroleString == "AXDockItem" {
return false
}

// Finder Desktop background check
if subroleString == "AXDesktopArea" || (roleString == kAXScrollAreaRole && titleString == "Desktop") {
return false
}

// If ancestor is a valid scrollable container
if scrollableRoles.contains(roleString) {
return true
}

// Inspect parent element up the hierarchy
var parentRef: CFTypeRef?
let parentResult = AXUIElementCopyAttributeValue(elem, kAXParentAttribute as CFString, &parentRef)
if parentResult == .success, let parent = parentRef {
current = (parent as! AXUIElement)
} else {
current = nil
}
depth += 1
}

return false
}
}
2 changes: 2 additions & 0 deletions Purrl/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
"toggle.enabled" = "Enabled ✓";
"toggle.disabled" = "Disabled";
"menu.texture" = "Purr texture";
"menu.onlyScrollable" = "Only Scrollable Content";
"menu.onlyScrollableEnabled" = "Only Scrollable Content ✓";
"menu.launchAtLogin" = "Launch at Login";
"menu.launchAtLoginEnabled" = "Launch at Login ✓";
"menu.checkForUpdates" = "Check for Updates…";
Expand Down
2 changes: 2 additions & 0 deletions Purrl/es.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
"toggle.enabled" = "Activado ✓";
"toggle.disabled" = "Desactivado";
"menu.texture" = "Textura del scroll";
"menu.onlyScrollable" = "Solo contenido desplazable";
"menu.onlyScrollableEnabled" = "Solo contenido desplazable ✓";
"menu.launchAtLogin" = "Iniciar con el sistema";
"menu.launchAtLoginEnabled" = "Iniciar con el sistema ✓";
"menu.checkForUpdates" = "Buscar actualizaciones…";
Expand Down
1 change: 1 addition & 0 deletions build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ rm -f "$ICNS_NAME"
echo "==> Building $APP_NAME (Release)..."
xcodebuild -scheme "$SCHEME" -configuration "$CONFIGURATION" \
-derivedDataPath "$DERIVED_DATA_PATH" \
CODE_SIGN_IDENTITY="-" DEVELOPMENT_TEAM="" \
clean build

APP_PATH="$DERIVED_DATA_PATH/Build/Products/$CONFIGURATION/$APP_NAME.app"
Expand Down
20 changes: 14 additions & 6 deletions project.xml
Original file line number Diff line number Diff line change
Expand Up @@ -274,8 +274,10 @@
<string>YES</string>
<key>DEBUG_INFORMATION_FORMAT</key>
<string>dwarf</string>
<key>CODE_SIGN_IDENTITY</key>
<string>-</string>
<key>DEVELOPMENT_TEAM</key>
<string>67NY9CT7W7</string>
<string></string>
<key>ENABLE_STRICT_OBJC_MSGSEND</key>
<string>YES</string>
<key>ENABLE_TESTABILITY</key>
Expand Down Expand Up @@ -403,8 +405,10 @@
<string>YES</string>
<key>DEBUG_INFORMATION_FORMAT</key>
<string>dwarf-with-dsym</string>
<key>CODE_SIGN_IDENTITY</key>
<string>-</string>
<key>DEVELOPMENT_TEAM</key>
<string>67NY9CT7W7</string>
<string></string>
<key>ENABLE_NS_ASSERTIONS</key>
<string>NO</string>
<key>ENABLE_STRICT_OBJC_MSGSEND</key>
Expand Down Expand Up @@ -470,15 +474,17 @@
<key>ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME</key>
<string>AccentColor</string>
<key>CODE_SIGN_STYLE</key>
<string>Automatic</string>
<string>Manual</string>
<key>COMBINE_HIDPI_IMAGES</key>
<string>YES</string>
<key>CURRENT_PROJECT_VERSION</key>
<string>1</string>
<key>DEAD_CODE_STRIPPING</key>
<string>YES</string>
<key>CODE_SIGN_IDENTITY</key>
<string>-</string>
<key>DEVELOPMENT_TEAM</key>
<string>3T9J355KZF</string>
<string></string>
<key>ENABLE_APP_SANDBOX</key>
<string>NO</string>
<key>ENABLE_HARDENED_RUNTIME</key>
Expand Down Expand Up @@ -533,15 +539,17 @@
<key>ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME</key>
<string>AccentColor</string>
<key>CODE_SIGN_STYLE</key>
<string>Automatic</string>
<string>Manual</string>
<key>COMBINE_HIDPI_IMAGES</key>
<string>YES</string>
<key>CURRENT_PROJECT_VERSION</key>
<string>1</string>
<key>DEAD_CODE_STRIPPING</key>
<string>YES</string>
<key>CODE_SIGN_IDENTITY</key>
<string>-</string>
<key>DEVELOPMENT_TEAM</key>
<string>3T9J355KZF</string>
<string></string>
<key>ENABLE_APP_SANDBOX</key>
<string>NO</string>
<key>ENABLE_HARDENED_RUNTIME</key>
Expand Down