Skip to content

Commit afaa420

Browse files
committed
Add Middle Click (Hold) mode and UI enhancements
- Add new Middle Click (Hold) activation method that stops on button release - Replace scroll speed slider with cleaner dropdown menu (0.5x to 3.0x) - Increase maximum scroll speed from 30px to 60px - Increase acceleration from 2.5x to 4.0x - Hide app from Dock (menu bar only) - Remove unused 'Activation Methods' info menu
1 parent 01efbc8 commit afaa420

2 files changed

Lines changed: 114 additions & 86 deletions

File tree

Scrollapp/Info.plist

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,8 @@
1414

1515
<key>LSRequiresNativeExecution</key>
1616
<true/>
17+
18+
<key>LSUIElement</key>
19+
<true/>
1720
</dict>
1821
</plist>

Scrollapp/ScrollappApp.swift

Lines changed: 111 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -42,24 +42,25 @@ class AppDelegate: NSObject, NSApplicationDelegate {
4242

4343
enum ActivationMethod: String, CaseIterable {
4444
case middleClick = "Middle Click"
45+
case middleClickHold = "Middle Click (Hold)"
4546
case shiftMiddleClick = "Shift + Middle Click"
4647
case cmdMiddleClick = "Cmd + Middle Click"
4748
case optionMiddleClick = "Option + Middle Click"
4849
case button4 = "Mouse Button 4"
4950
case button5 = "Mouse Button 5"
5051
case doubleMiddleClick = "Double Middle Click"
51-
52+
5253
var buttonNumber: Int? {
5354
switch self {
54-
case .middleClick, .shiftMiddleClick, .cmdMiddleClick, .optionMiddleClick, .doubleMiddleClick:
55+
case .middleClick, .middleClickHold, .shiftMiddleClick, .cmdMiddleClick, .optionMiddleClick, .doubleMiddleClick:
5556
return 2
5657
case .button4:
5758
return 3
5859
case .button5:
5960
return 4
6061
}
6162
}
62-
63+
6364
var requiresModifier: Bool {
6465
switch self {
6566
case .shiftMiddleClick, .cmdMiddleClick, .optionMiddleClick:
@@ -68,7 +69,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
6869
return false
6970
}
7071
}
71-
72+
7273
var modifierFlags: NSEvent.ModifierFlags? {
7374
switch self {
7475
case .shiftMiddleClick:
@@ -81,27 +82,34 @@ class AppDelegate: NSObject, NSApplicationDelegate {
8182
return nil
8283
}
8384
}
85+
86+
var usesHoldBehavior: Bool {
87+
return self == .middleClickHold
88+
}
8489
}
8590

8691
func applicationDidFinishLaunching(_ notification: Notification) {
92+
// Hide from Dock - menu bar only app
93+
NSApp.setActivationPolicy(.accessory)
94+
8795
// Load user preferences
8896
isDirectionInverted = UserDefaults.standard.bool(forKey: "invertScrollDirection")
8997
launchAtLogin = UserDefaults.standard.bool(forKey: "launchAtLogin")
9098
scrollSensitivity = UserDefaults.standard.double(forKey: "scrollSensitivity")
9199
if scrollSensitivity == 0 { scrollSensitivity = 1.0 } // Default if not set
92-
100+
93101
// Load activation method
94102
if let savedMethod = UserDefaults.standard.string(forKey: "activationMethod"),
95103
let method = ActivationMethod(rawValue: savedMethod) {
96104
activationMethod = method
97105
}
98-
106+
99107
// Set initial launch at login state based on saved preference
100108
updateLoginItemState()
101-
109+
102110
// Check and request Accessibility permissions
103111
checkAccessibilityPermissions()
104-
112+
105113
setupMenuBar()
106114
createScrollCursor()
107115
setupMiddleClickListeners()
@@ -124,69 +132,59 @@ class AppDelegate: NSObject, NSApplicationDelegate {
124132
let menu = NSMenu()
125133
menu.addItem(NSMenuItem(title: "Start/Stop Auto-Scroll", action: #selector(toggleTrackpadMode), keyEquivalent: ""))
126134
menu.addItem(NSMenuItem.separator())
127-
128-
// Add sensitivity slider
129-
let sensitivityItem = NSMenuItem(title: String(format: "Scroll Speed: %.1fx", scrollSensitivity), action: nil, keyEquivalent: "")
130-
let sensitivityView = NSView(frame: NSRect(x: 0, y: 0, width: 250, height: 30))
131-
132-
let slider = NSSlider(frame: NSRect(x: 20, y: 5, width: 150, height: 20))
133-
slider.minValue = 0.2
134-
slider.maxValue = 3.0
135-
slider.doubleValue = scrollSensitivity
136-
slider.target = self
137-
slider.action = #selector(sensitivityChanged(_:))
138-
slider.isContinuous = true
139-
140-
let label = NSTextField(labelWithString: String(format: "%.1fx", scrollSensitivity))
141-
label.frame = NSRect(x: 180, y: 5, width: 50, height: 20)
142-
label.alignment = .center
143-
label.tag = 100 // Tag to find it later
144-
145-
sensitivityView.addSubview(slider)
146-
sensitivityView.addSubview(label)
147-
sensitivityItem.view = sensitivityView
148-
menu.addItem(sensitivityItem)
149-
135+
136+
// Add scroll speed submenu
137+
let speedMenu = NSMenu()
138+
let speedItem = NSMenuItem(title: "Scroll Speed", action: nil, keyEquivalent: "")
139+
speedItem.submenu = speedMenu
140+
141+
let speeds: [(String, Double)] = [
142+
("Very Slow (0.5x)", 0.5),
143+
("Slow (0.75x)", 0.75),
144+
("Normal (1.0x)", 1.0),
145+
("Fast (1.5x)", 1.5),
146+
("Very Fast (2.0x)", 2.0),
147+
("Ultra Fast (3.0x)", 3.0)
148+
]
149+
150+
for (title, speed) in speeds {
151+
let speedMenuItem = NSMenuItem(title: title, action: #selector(setScrollSpeed(_:)), keyEquivalent: "")
152+
speedMenuItem.representedObject = speed
153+
speedMenuItem.state = (abs(scrollSensitivity - speed) < 0.01) ? .on : .off
154+
speedMenu.addItem(speedMenuItem)
155+
}
156+
157+
menu.addItem(speedItem)
150158
menu.addItem(NSMenuItem.separator())
151-
159+
152160
// Add activation method submenu
153161
let activationMenu = NSMenu()
154162
let activationItem = NSMenuItem(title: "Activation Method", action: nil, keyEquivalent: "")
155163
activationItem.submenu = activationMenu
156-
164+
157165
for method in ActivationMethod.allCases {
158166
let methodItem = NSMenuItem(title: method.rawValue, action: #selector(selectActivationMethod(_:)), keyEquivalent: "")
159167
methodItem.representedObject = method
160168
methodItem.state = (method == activationMethod) ? .on : .off
161169
activationMenu.addItem(methodItem)
162170
}
163-
171+
164172
menu.addItem(activationItem)
165-
166-
// Add inverted direction toggle option - reworded to match new default
173+
menu.addItem(NSMenuItem.separator())
174+
175+
// Add inverted direction toggle option
167176
let invertItem = NSMenuItem(title: "Invert Scrolling Direction", action: #selector(toggleDirectionInversion), keyEquivalent: "")
168177
invertItem.state = isDirectionInverted ? .on : .off
169178
menu.addItem(invertItem)
170-
179+
171180
// Add launch at login toggle
172181
let launchItem = NSMenuItem(title: "Launch at Login", action: #selector(toggleLaunchAtLogin), keyEquivalent: "")
173182
launchItem.state = launchAtLogin ? .on : .off
174183
menu.addItem(launchItem)
175-
184+
176185
menu.addItem(NSMenuItem.separator())
177186
menu.addItem(NSMenuItem(title: "About Scrollapp", action: #selector(showAbout), keyEquivalent: ""))
178-
179-
let methodsMenu = NSMenu()
180-
let methodsItem = NSMenuItem(title: "Activation Methods", action: nil, keyEquivalent: "")
181-
methodsItem.submenu = methodsMenu
182-
183-
methodsMenu.addItem(NSMenuItem(title: "Mouse - Configurable button/modifier (see Activation Method)", action: nil, keyEquivalent: ""))
184-
methodsMenu.addItem(NSMenuItem(title: "Option + Scroll - Start auto-scroll (trackpad)", action: nil, keyEquivalent: ""))
185-
methodsMenu.addItem(NSMenuItem(title: "Menu Bar - Use the menu option above", action: nil, keyEquivalent: ""))
186-
methodsMenu.addItem(NSMenuItem(title: "Click - Stop auto-scroll", action: nil, keyEquivalent: ""))
187-
188-
menu.addItem(methodsItem)
189-
187+
190188
menu.addItem(NSMenuItem.separator())
191189
menu.addItem(NSMenuItem(title: "Quit", action: #selector(quitApp), keyEquivalent: "q"))
192190
statusItem.menu = menu
@@ -314,8 +312,8 @@ class AppDelegate: NSObject, NSApplicationDelegate {
314312

315313
// Quadratic acceleration: scrollSpeed grows faster as distance increases
316314
let acceleration = pow(distance / 50, 2.0) // scale distance into a nice curve
317-
let maxScrollSpeed: CGFloat = 30.00
318-
let scrollSpeed = min(acceleration * 2.5, maxScrollSpeed) // scaled + capped
315+
let maxScrollSpeed: CGFloat = 60.00
316+
let scrollSpeed = min(acceleration * 4.0, maxScrollSpeed) // scaled + capped
319317

320318
// Apply sensitivity multiplier with exponential scaling for values < 1.0
321319
// This makes slower speeds MUCH slower but still usable
@@ -397,44 +395,53 @@ class AppDelegate: NSObject, NSApplicationDelegate {
397395
}
398396
}
399397

400-
@objc func sensitivityChanged(_ sender: NSSlider) {
401-
scrollSensitivity = sender.doubleValue
398+
@objc func setScrollSpeed(_ sender: NSMenuItem) {
399+
guard let speed = sender.representedObject as? Double else { return }
400+
401+
scrollSensitivity = speed
402402
UserDefaults.standard.set(scrollSensitivity, forKey: "scrollSensitivity")
403-
404-
// Update the label and menu item title
405-
if let sensitivityItem = statusItem.menu?.items.first(where: { $0.title.starts(with: "Scroll Speed") }) {
406-
sensitivityItem.title = String(format: "Scroll Speed: %.1fx", scrollSensitivity)
407-
408-
if let view = sensitivityItem.view,
409-
let label = view.viewWithTag(100) as? NSTextField {
410-
label.stringValue = String(format: "%.1fx", scrollSensitivity)
403+
404+
// Update menu item states
405+
if let speedItem = statusItem.menu?.items.first(where: { $0.title == "Scroll Speed" }),
406+
let submenu = speedItem.submenu {
407+
for item in submenu.items {
408+
if let itemSpeed = item.representedObject as? Double {
409+
item.state = (abs(itemSpeed - speed) < 0.01) ? .on : .off
410+
}
411411
}
412412
}
413413
}
414-
414+
415415
@objc func selectActivationMethod(_ sender: NSMenuItem) {
416416
guard let method = sender.representedObject as? ActivationMethod else { return }
417-
417+
418418
activationMethod = method
419419
UserDefaults.standard.set(method.rawValue, forKey: "activationMethod")
420-
420+
421421
// Update menu item states
422422
if let activationItem = statusItem.menu?.items.first(where: { $0.title == "Activation Method" }),
423423
let submenu = activationItem.submenu {
424424
for item in submenu.items {
425425
item.state = (item.representedObject as? ActivationMethod == method) ? .on : .off
426426
}
427427
}
428-
428+
429429
// Restart mouse listeners with new configuration
430430
setupMiddleClickListeners()
431+
432+
// Restart click monitor with new behavior
433+
if let monitor = clickMonitor {
434+
NSEvent.removeMonitor(monitor)
435+
clickMonitor = nil
436+
}
437+
setupClickMonitor()
431438
}
432439

433440
@objc func showAbout() {
434441
let alert = NSAlert()
435442
alert.messageText = "About Scrollapp"
436443

437-
alert.informativeText = "Scrollapp enables auto-scrolling on macOS.\n\nHow to activate:\nMouse: Configurable button/modifier (see Activation Method in menu)\nTrackpad: Hold Option key and scroll with two fingers\n• Menu: Use the menu bar icon and select 'Start/Stop Auto-Scroll'\n\nHow to stop:\n• Click anywhere to exit auto-scroll mode\nUse your configured activation method again\n\nWhile active, move your cursor to control scroll speed and direction.\n\nAdjust scroll speed using the slider in the menu bar (0.2x - 3.0x).\nSpeeds below 1.0x are exponentially slower for fine control.\n\nConfigure your preferred activation method in the 'Activation Method' submenu to avoid conflicts with browser link opening."
444+
alert.informativeText = "Scrollapp enables auto-scrolling on macOS.\n\nHow to activate:\nConfigure your preferred method in 'Activation Method'\n• Option + Scroll on trackpad\n• Menu: Use 'Start/Stop Auto-Scroll'\n\nHow to stop:\nMiddle Click (Hold): Release the button\nOther methods: Click any mouse button\n\nWhile active, move your cursor to control scroll speed and direction.\n\nAdjust scroll speed in the 'Scroll Speed' submenu."
438445
alert.alertStyle = .informational
439446
alert.addButton(withTitle: "OK")
440447
alert.runModal()
@@ -457,47 +464,65 @@ class AppDelegate: NSObject, NSApplicationDelegate {
457464
// Detect Option key via flagsChanged
458465
optionKeyMonitor = NSEvent.addGlobalMonitorForEvents(matching: .flagsChanged) { [weak self] event in
459466
guard let self = self else { return }
460-
467+
461468
// Detect Option key
462469
let optionKeyFlag = NSEvent.ModifierFlags.option
463-
470+
464471
// If Option key is pressed and we're not already scrolling
465472
if event.modifierFlags.contains(optionKeyFlag) && !self.isAutoScrolling {
466473
// Start a timer to detect if two-finger scroll happens while Option is pressed
467474
self.lastScrollTime = Date()
468-
475+
469476
// If we detect a scroll within 1 second of Option press, activate auto-scroll
470477
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
471478
self?.lastScrollTime = nil
472479
}
473480
}
474481
}
475-
482+
476483
// Detect two-finger scroll while Option is pressed
477484
scrollMonitor = NSEvent.addGlobalMonitorForEvents(matching: .scrollWheel) { [weak self] event in
478485
guard let self = self,
479486
let lastScrollTime = self.lastScrollTime,
480487
Date().timeIntervalSince(lastScrollTime) < 1.0,
481488
!self.isAutoScrolling,
482489
abs(event.deltaY) > 0.1 else { return }
483-
490+
484491
// Option + scroll detected, activate auto-scroll
485492
self.startTrackpadAutoScroll()
486493
}
487-
488-
// Monitor for clicks to exit auto-scroll mode
489-
clickMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown, .otherMouseDown]) { [weak self] event in
490-
guard let self = self, self.isAutoScrolling else { return }
491-
492-
// Don't stop auto-scroll for the configured activation button
493-
if event.type == .otherMouseDown,
494-
let activationButtonNumber = self.activationMethod.buttonNumber,
495-
event.buttonNumber == activationButtonNumber {
496-
return // Skip - let the activation method handler deal with it
494+
495+
setupClickMonitor()
496+
}
497+
498+
func setupClickMonitor() {
499+
// Monitor for clicks/releases to exit auto-scroll mode
500+
if activationMethod.usesHoldBehavior {
501+
// Hold behavior: stop on button release
502+
clickMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.otherMouseUp]) { [weak self] event in
503+
guard let self = self, self.isAutoScrolling else { return }
504+
505+
// Stop auto-scroll when releasing the activation button
506+
if let activationButtonNumber = self.activationMethod.buttonNumber,
507+
event.buttonNumber == activationButtonNumber {
508+
self.stopAutoScroll()
509+
}
510+
}
511+
} else {
512+
// Original behavior: stop on any click
513+
clickMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown, .otherMouseDown]) { [weak self] event in
514+
guard let self = self, self.isAutoScrolling else { return }
515+
516+
// Don't stop auto-scroll for the configured activation button
517+
if event.type == .otherMouseDown,
518+
let activationButtonNumber = self.activationMethod.buttonNumber,
519+
event.buttonNumber == activationButtonNumber {
520+
return // Skip - let the activation method handler deal with it
521+
}
522+
523+
// For all other clicks, stop auto-scroll
524+
self.stopAutoScroll()
497525
}
498-
499-
// For all other clicks, stop auto-scroll
500-
self.stopAutoScroll()
501526
}
502527
}
503528

@@ -521,7 +546,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
521546
launchAtLogin = !launchAtLogin
522547
UserDefaults.standard.set(launchAtLogin, forKey: "launchAtLogin")
523548
updateLoginItemState()
524-
549+
525550
// Update menu item state
526551
if let launchItem = statusItem.menu?.items.first(where: { $0.title == "Launch at Login" }) {
527552
launchItem.state = launchAtLogin ? .on : .off

0 commit comments

Comments
 (0)