@@ -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 \n How to activate: \n • Mouse: Configurable button/modifier (see Activation Method in menu) \n • Trackpad: Hold Option key and scroll with two fingers \n • Menu: Use the menu bar icon and select 'Start/Stop Auto-Scroll' \n \n How to stop: \n • Click anywhere to exit auto-scroll mode \n • Use your configured activation method again \n \n While active, move your cursor to control scroll speed and direction. \n \n Adjust scroll speed using the slider in the menu bar (0.2x - 3.0x). \n Speeds below 1.0x are exponentially slower for fine control. \n \n Configure 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 \n How to activate: \n • Configure your preferred method in ' Activation Method' \n • Option + Scroll on trackpad \n • Menu: Use 'Start/Stop Auto-Scroll' \n \n How to stop: \n • Middle Click (Hold): Release the button \n • Other methods: Click any mouse button \n \n While active, move your cursor to control scroll speed and direction. \n \n Adjust 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