27

I have a mouse with three buttons but no wheel.

In OS X, is there any way (perhaps with addon software) that would allow me to use my third button for scrolling by holding it and moving the mouse?

8 Answers 8

16

I did it with Hammerspoon with the following configuration script inspired by this thread: https://github.com/tekezo/Karabiner/issues/814#issuecomment-337643019

Steps:

  • Install Hammerspoon
  • Click its menu icon and select Open Config
  • Paste the following lua script into the configuration:

    -- HANDLE SCROLLING WITH MOUSE BUTTON PRESSED
    local scrollMouseButton = 2
    local deferred = false
    
    overrideOtherMouseDown = hs.eventtap.new({ hs.eventtap.event.types.otherMouseDown }, function(e)
        -- print("down")
        local pressedMouseButton = e:getProperty(hs.eventtap.event.properties['mouseEventButtonNumber'])
        if scrollMouseButton == pressedMouseButton 
            then 
                deferred = true
                return true
            end
    end)
    
    overrideOtherMouseUp = hs.eventtap.new({ hs.eventtap.event.types.otherMouseUp }, function(e)
        -- print("up")
        local pressedMouseButton = e:getProperty(hs.eventtap.event.properties['mouseEventButtonNumber'])
        if scrollMouseButton == pressedMouseButton 
            then 
                if (deferred) then
                    overrideOtherMouseDown:stop()
                    overrideOtherMouseUp:stop()
                    hs.eventtap.otherClick(e:location(), pressedMouseButton)
                    overrideOtherMouseDown:start()
                    overrideOtherMouseUp:start()
                    return true
                end
                return false
            end
            return false
    end)
    
    local oldmousepos = {}
    local scrollmult = -4   -- negative multiplier makes mouse work like traditional scrollwheel
    
    dragOtherToScroll = hs.eventtap.new({ hs.eventtap.event.types.otherMouseDragged }, function(e)
        local pressedMouseButton = e:getProperty(hs.eventtap.event.properties['mouseEventButtonNumber'])
        -- print ("pressed mouse " .. pressedMouseButton)
        if scrollMouseButton == pressedMouseButton 
            then 
                -- print("scroll");
                deferred = false
                oldmousepos = hs.mouse.getAbsolutePosition()    
                local dx = e:getProperty(hs.eventtap.event.properties['mouseEventDeltaX'])
                local dy = e:getProperty(hs.eventtap.event.properties['mouseEventDeltaY'])
                local scroll = hs.eventtap.event.newScrollEvent({-dx * scrollmult, dy * scrollmult},{},'pixel')
                -- put the mouse back
                hs.mouse.setAbsolutePosition(oldmousepos)
                return true, {scroll}
            else 
                return false, {}
            end 
    end)
    
    overrideOtherMouseDown:start()
    overrideOtherMouseUp:start()
    dragOtherToScroll:start()
    
5
  • just tried it and it works beautifully.
    – im_chc
    May 11, 2019 at 9:16
  • since I prefer to make the Y-scroll the other way round, I've changed the lua code a little bit: change "dy" in the line "local scroll = hs.eventtap.event.newScrollEvent({-dx * scrollmult, dy * scrollmult},{},'pixel')" to negative (so it would be "-dy * scrollmult")
    – im_chc
    May 11, 2019 at 9:26
  • 1
    Remember to restart Hammerspoon to make the configuration take effect
    – Merchako
    May 4, 2021 at 3:45
  • This is nice for a free alternative. But it doesn't feel that great! Jul 16, 2021 at 1:51
  • This did not work for me. Instead clicking the middle mouse button just zooms out all the windows.
    – BigBoy1337
    May 18, 2022 at 21:31
13

Smart Scroll does what you are looking for, with its 'Grab Scroll' feature. Assign it to 'Button 3 (Middle)' and dragging on both axes will work in apps such as browsers (Chrome), Terminal, Adobe Photoshop, and Finder - no app I've tried hasn't worked with it (using the 4.0 betas up and up). It has a free trial.

Screenshot of Smart Scroll

2
  • I love this software so much. It has in ten minutes completely changed my experience using a mouse with a scroll wheel. Thank you!
    – Josh K
    Aug 8, 2020 at 13:55
  • Thanks, worked for me with Drag Button 4
    – aklingam
    Dec 20, 2020 at 7:43
7

Smooze does that, among other things. (I'm the developer)

What differentiate it from other suggestions is the ability to use it in every mac app while still identify links, for example. (in case you use your middle button drag to grab and throw but still want that a middle button click will act as a middle button)

With Smooze is more like grab-drag-throw than grab-drag. The release effects the momentum and animation of the scroll, similar to the iPhone scroll.

enter image description here

1
  • This is exactly what I needed! Super smooth and natural grab scroll gesture. Behaves similarly to SmartScroll, but costs a third of it. I'll buy it for sure! Thank you. Jul 16, 2021 at 1:52
5

Updated (2022): There's a new open source app called Karabiner Elements which will do this and a whole lot more - it can do Keyboard and mouse remapping etc.

This supersedes the old 'Karabiner' app (See this question for [old] some examples) and does now provide for both mouse and keyboard remapping as mentioned by @Nathan in the comments.

Another option is Hammerspoon which is also an open source tool that may be used, amongst many other things, to remap the keys on the mouse (and/or keyboard) to different functions. You'll need to install the tool and supply it with some appropriate config - see examples here for mouse remapping.

To check which event types and mouseEventButtonNumbers are being generated by your device you can run this (just copy/paste the 4 lines into the console) in the Hammerspoon console (Use reload config to stop it):

hs.eventtap.new({"all"},function(e)
print(e,"mouseEventButtonNumber:",
e:getProperty(hs.eventtap.event.properties['mouseEventButtonNumber']))
end):start()

Note: If you've installed the Logitech Control Centre (LCC) tools - it grabs the events directly from the Logitech devices using their installed kernel module so Hammerspoon can't see them. You'll need to uninstall LCC if you want to remap the mouse buttons using Hammerspoon.

6
  • 2
    Unfortunately Karabiner no longer works with modern OSX. There is a 'Karabiner Elements' now that does, but it has half the functionality of the original, and this is one of the things it can't do. Oct 30, 2017 at 10:19
  • 1
    Yes it is currently limited so I've updated my answer to add another solution.
    – Pierz
    Oct 30, 2017 at 10:36
  • Hammerspoon is the solution I landed on yesterday, so good suggestion! :) For some reason I couldn't get it to bind to one of the mouse buttons, but mapping it to ctrl+cmd seemed to work fine. Oct 31, 2017 at 11:19
  • 1
    I've added another edit as I had this problem when I had LCC installed but uninstalling it fixed it (once I'd worked which buttons generated which mouseEventButtonNumber - on my Marble Mouse left mini-button is 3 and the right one is 4).
    – Pierz
    Nov 1, 2017 at 12:32
  • I suspected that might be the issue! Thanks for the confirmation, I'll sort that out when I get the chance. Nov 2, 2017 at 13:12
3

+1 for Hammerspoon and a script, a normal mouse/trackball drives me mad on a Mac.

I wrote one to scroll while middle mouse button is pressed down - the further you move the mouse the faster it will scroll.

Click still works like a normal click with a 5 pixel dead-zone so you don't have to keep the mouse perfectly still between pressing and releasing the wheel.

------------------------------------------------------------------------------------------
-- AUTOSCROLL WITH MOUSE WHEEL BUTTON
-- timginter @ GitHub
------------------------------------------------------------------------------------------

-- id of mouse wheel button
local mouseScrollButtonId = 2

-- scroll speed and direction config
local scrollSpeedMultiplier = 0.1
local scrollSpeedSquareAcceleration = true
local reverseVerticalScrollDirection = false
local mouseScrollTimerDelay = 0.01

-- circle config
local mouseScrollCircleRad = 10
local mouseScrollCircleDeadZone = 5

------------------------------------------------------------------------------------------

local mouseScrollCircle = nil
local mouseScrollTimer = nil
local mouseScrollStartPos = 0
local mouseScrollDragPosX = nil
local mouseScrollDragPosY = nil

overrideScrollMouseDown = hs.eventtap.new({ hs.eventtap.event.types.otherMouseDown }, function(e)
    -- uncomment line below to see the ID of pressed button
    --print(e:getProperty(hs.eventtap.event.properties['mouseEventButtonNumber']))

    if e:getProperty(hs.eventtap.event.properties['mouseEventButtonNumber']) == mouseScrollButtonId then
        -- remove circle if exists
        if mouseScrollCircle then
            mouseScrollCircle:delete()
            mouseScrollCircle = nil
        end

        -- stop timer if running
        if mouseScrollTimer then
            mouseScrollTimer:stop()
            mouseScrollTimer = nil
        end

        -- save mouse coordinates
        mouseScrollStartPos = hs.mouse.getAbsolutePosition()
        mouseScrollDragPosX = mouseScrollStartPos.x
        mouseScrollDragPosY = mouseScrollStartPos.y

        -- start scroll timer
        mouseScrollTimer = hs.timer.doAfter(mouseScrollTimerDelay, mouseScrollTimerFunction)

        -- don't send scroll button down event
        return true
    end
end)

overrideScrollMouseUp = hs.eventtap.new({ hs.eventtap.event.types.otherMouseUp }, function(e)
    if e:getProperty(hs.eventtap.event.properties['mouseEventButtonNumber']) == mouseScrollButtonId then
        -- send original button up event if released within 'mouseScrollCircleDeadZone' pixels of original position and scroll circle doesn't exist
        mouseScrollPos = hs.mouse.getAbsolutePosition()
        xDiff = math.abs(mouseScrollPos.x - mouseScrollStartPos.x)
        yDiff = math.abs(mouseScrollPos.y - mouseScrollStartPos.y)
        if (xDiff < mouseScrollCircleDeadZone and yDiff < mouseScrollCircleDeadZone) and not mouseScrollCircle then
            -- disable scroll mouse override
            overrideScrollMouseDown:stop()
            overrideScrollMouseUp:stop()

            -- send scroll mouse click
            hs.eventtap.otherClick(e:location(), mouseScrollButtonId)

            -- re-enable scroll mouse override
            overrideScrollMouseDown:start()
            overrideScrollMouseUp:start()
        end

        -- remove circle if exists
        if mouseScrollCircle then
            mouseScrollCircle:delete()
            mouseScrollCircle = nil
        end

        -- stop timer if running
        if mouseScrollTimer then
            mouseScrollTimer:stop()
            mouseScrollTimer = nil
        end

        -- don't send scroll button up event
        return true
    end
end)

overrideScrollMouseDrag = hs.eventtap.new({ hs.eventtap.event.types.otherMouseDragged }, function(e)
    -- sanity check
    if mouseScrollDragPosX == nil or mouseScrollDragPosY == nil then
        return true
    end

    -- update mouse coordinates
    mouseScrollDragPosX = mouseScrollDragPosX + e:getProperty(hs.eventtap.event.properties['mouseEventDeltaX'])
    mouseScrollDragPosY = mouseScrollDragPosY + e:getProperty(hs.eventtap.event.properties['mouseEventDeltaY'])

    -- don't send scroll button drag event
    return true
end)

function mouseScrollTimerFunction()
    -- sanity check
    if mouseScrollDragPosX ~= nil and mouseScrollDragPosY ~= nil then
        -- get cursor position difference from original click
        xDiff = math.abs(mouseScrollDragPosX - mouseScrollStartPos.x)
        yDiff = math.abs(mouseScrollDragPosY - mouseScrollStartPos.y)

        -- draw circle if not yet drawn and cursor moved more than 'mouseScrollCircleDeadZone' pixels
        if mouseScrollCircle == nil and (xDiff > mouseScrollCircleDeadZone or yDiff > mouseScrollCircleDeadZone) then
            mouseScrollCircle = hs.drawing.circle(hs.geometry.rect(mouseScrollStartPos.x - mouseScrollCircleRad, mouseScrollStartPos.y - mouseScrollCircleRad, mouseScrollCircleRad * 2, mouseScrollCircleRad * 2))
            mouseScrollCircle:setStrokeColor({["red"]=0.3, ["green"]=0.3, ["blue"]=0.3, ["alpha"]=1})
            mouseScrollCircle:setFill(false)
            mouseScrollCircle:setStrokeWidth(1)
            mouseScrollCircle:show()
        end

        -- send scroll event if cursor moved more than circle's radius
        if xDiff > mouseScrollCircleRad or yDiff > mouseScrollCircleRad then
            -- get real xDiff and yDiff
            deltaX = mouseScrollDragPosX - mouseScrollStartPos.x
            deltaY = mouseScrollDragPosY - mouseScrollStartPos.y

            -- use 'scrollSpeedMultiplier'
            deltaX = deltaX * scrollSpeedMultiplier
            deltaY = deltaY * scrollSpeedMultiplier

            -- square for better scroll acceleration
            if scrollSpeedSquareAcceleration then
                -- mod to keep negative values
                deltaXDirMod = 1
                deltaYDirMod = 1

                if deltaX < 0 then
                    deltaXDirMod = -1
                end
                if deltaY < 0 then
                    deltaYDirMod = -1
                end

                deltaX = deltaX * deltaX * deltaXDirMod
                deltaY = deltaY * deltaY * deltaYDirMod
            end

            -- math.ceil / math.floor - scroll event accepts only integers
             deltaXRounding = math.ceil
             deltaYRounding = math.ceil

             if deltaX < 0 then
                 deltaXRounding = math.floor
             end
             if deltaY < 0 then
                 deltaYRounding = math.floor
             end

             deltaX = deltaXRounding(deltaX)
             deltaY = deltaYRounding(deltaY)

            -- reverse Y scroll if 'reverseVerticalScrollDirection' set to true
            if reverseVerticalScrollDirection then
                deltaY = deltaY * -1
            end

            -- send scroll event
            hs.eventtap.event.newScrollEvent({-deltaX, deltaY}, {}, 'pixel'):post()
        end
    end

    -- restart timer
    mouseScrollTimer = hs.timer.doAfter(mouseScrollTimerDelay, mouseScrollTimerFunction)
end

-- start override functions
overrideScrollMouseDown:start()
overrideScrollMouseUp:start()
overrideScrollMouseDrag:start()

------------------------------------------------------------------------------------------
-- END OF AUTOSCROLL WITH MOUSE WHEEL BUTTON
------------------------------------------------------------------------------------------
6
  • Killer feature! Thanks so much, exactly what I was looking for. One bug however deltaX = deltaY * -1 should be deltaY = deltaY * -1 and I commented out deltaX = deltaX * -1 because I didn't want X axis inverted.
    – Tyler
    Nov 14, 2019 at 20:54
  • Thanks for spotting the typo. I rewrote it a bit and changed the option to reverse vertical scrolling only
    – TIM
    Nov 26, 2019 at 15:38
  • This is some lovely stuff, thanks @TIM
    – deansimcox
    Jul 8, 2021 at 6:14
  • Where to use this script? Jan 8, 2022 at 22:54
  • @NikolasCharalambidis you need to install Hammerspoon
    – TIM
    Feb 20, 2022 at 11:14
1

It depends on the software - for example, Firefox supports it, while Google Chrome does not.

Currently there is no software to enable such feature system-wide in OS X, sadly.

1
  • Maybe it wasn't compatible with Chrome way back in 2011, but certainly in 2014 after no doubt quite a few revisions, Smart Scroll's 'Grab Scroll' works smoothly with Chrome and Opera, I can confirm. I think it is OS-wide too as it works in Finder, Adobe Photoshop, and even Terminal. So I think your data is out of date! :)
    – user78017
    Jun 24, 2014 at 7:00
1

I used Better Touch Tool to assign Ctrl+middle-click to PgUp, and Option+Middle-click to PgDown. It's free, excellent software, and works well.

1

Karabiner-Elements has this implemented.

Install it, then go to this custom complex_modifications page and import the "Change mouse motion to scroll" rule. enter image description here

1
  • This doesn't seem to be working. I'm using a Logitech G303 if that makes any difference.
    – Dylan
    May 3, 2021 at 15:55

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .