Hammerspoon / hammerspoon

Staggeringly powerful macOS desktop automation with Lua
http://www.hammerspoon.org
MIT License
12.17k stars 587 forks source link

Support moving window to another Space. #235

Closed TwoLeaves closed 9 years ago

TwoLeaves commented 9 years ago

Can we implement moving a window to another Space? The kludgy process of achieving this is documented here (http://ianyh.com/blog/2013/06/05/accessibility/) and code that achieves this is here (https://github.com/ianyh/Silica/blob/c10cfeda040ebcf475a73307614ed2576747bba8/Silica/SIWindow.m#L189). The process in general is programmatically clicking on a free point on the toolbar (5 pixels to the right of the 'zoom button' is likely to be free), changing the Space (using private APIs via this Hammerspoon extension: https://github.com/asmagill/hammerspoon_asm.undocumented/tree/master/spaces) and releasing the click. Thank you.

cmsj commented 9 years ago

So, we now have hs.window:zoomButtonRect(), which is as far, I think, as Hammerspoon itself should go, so I have closed the issue.

However, we can continue to discuss how to make a Hammerspoon config that uses zoomButtonRect() and hs.eventtap, to implement the kludge :)

(I just don't think the kludge itself should be part of Hammerspoon, at least until we get some clearer idea of whether Apple is going to rewrite Spaces again, or add an API, or what)

cmsj commented 9 years ago

(and 0.9.24 is now released, so zoomButtonRect() is now available)

cmsj commented 9 years ago

So, once 0.9.25 is out, the following will work:

function moveWindowOneSpace(direction)
    local win = hs.window.focusedWindow()
    local clickPoint = win:zoomButtonRect()
    local mouseOrigin = hs.mouse.getAbsolutePosition()

    clickPoint.x = clickPoint.x + clickPoint.w + 5
    clickPoint.y = clickPoint.y + (clickPoint.h / 2)

    local mouseClickEvent = hs.eventtap.event.newMouseEvent(hs.eventtap.event.types.leftmousedown, clickPoint)
    local mouseReleaseEvent = hs.eventtap.event.newMouseEvent(hs.eventtap.event.types.leftmouseup, clickPoint)
    local nextSpaceDownEvent = hs.eventtap.event.newKeyEvent({["ctrl"]=true}, direction, true)
    local nextSpaceUpEvent = hs.eventtap.event.newKeyEvent({["ctrl"]=true}, direction, false)

    mouseClickEvent:post()
    hs.timer.usleep(200000)
    nextSpaceDownEvent:post()
    hs.timer.usleep(200000)
    nextSpaceUpEvent:post()
    hs.timer.usleep(200000)
    mouseReleaseEvent:post()
    hs.timer.usleep(200000)

    hs.mouse.setAbsolutePosition(mouseOrigin)
end
hk1 = hs.hotkey.bind({"cmd", "shift", "ctrl", "alt"}, "right", function() moveWindowOneSpace("right") end)
hk2 = hs.hotkey.bind({"cmd", "shift", "ctrl", "alt"}, "left", function() moveWindowOneSpace("left") end)

It's not 100% perfect, because we're doing some pretty crazy stuff here, but it seems to work for me almost all of the time.

cmsj commented 9 years ago

(fwiw, I'd expect 0.9.25 to be out within the next few days)

cmsj commented 9 years ago

(also while I'm talking about eventtap stuff - @asmagill you might be interested to look at the last few commits to extensions/eventtap/event.m, I believe I have fixed at least one surprisingly significant bug. I'd be curious to hear if you disagree with what I've done)

TwoLeaves commented 9 years ago

Thank you cmsj, I'll check it out when 0.9.25 is released (I tried building the app but my build behaves differently to the official build).

TwoLeaves commented 9 years ago

The above code is not working for me cmsj. It worked once. But barring that one time the space doesn't move. I tried changing the spaces using asmagill's spaces extension, and the space changed successfully. The window however did not move with it. I disabled the mouse event and emulated what the code does myself with the mouse, and this worked. So it seems that for me the OSX space changing does not work (but the mostly-working space change extension can replace this) and the programmatic mouse up/down does not work at all. I am using version 0.9.25 on OSX 10.10.2.

cmsj commented 9 years ago

@TwoLeaves you shouldn't need the spaces extension to use the code I pasted above. It relies on the system keyboard shortcuts for changing space (ctrl-left an ctrl-right). Having said that, it is possible that the spaces extension is interacting with things badly.

This is all horribly hacky and experimental - until Apple provides an API we can use to interact with Spaces, that seems unlikely to change. Perhaps you need slightly longer usleep() times between the events? Perhaps the particular window you were using didn't have draggable space at the exact co-ordinate we simulated the mouse down/up events. It's so hard to know :/

TwoLeaves commented 9 years ago

Yeah ctrl-left and ctrl-right doesn't move the space for me (when pressed via Hammerspoon). If I click the hotkey twice it sometimes works. Tried to no avail longer usleep times. Don't think it's the co-ordinates thing either.

Oh well, I'll continue to move windows by hand for now. Thanks for working on it Chris Jones. :)

jdtsmith commented 9 years ago

In case anyone is trying this I modified a bit for 0.9.29 and the following is working for moving a window one space right or left. For some reason I had to create and then post the event in succession or it would not trigger.

function moveWindowOneSpace(direction)
   _mouseOrigin = hs.mouse.getAbsolutePosition()
   local win = hs.window.focusedWindow()
   _clickPoint = win:zoomButtonRect()

   _clickPoint.x = _clickPoint.x + _clickPoint.w + 5
   _clickPoint.y = _clickPoint.y + (_clickPoint.h / 2)

   local mouseClickEvent = hs.eventtap.event.newMouseEvent(hs.eventtap.event.types.leftmousedown, _clickPoint)
   mouseClickEvent:post()

   hs.timer.usleep(150000)

   local nextSpaceDownEvent = hs.eventtap.event.newKeyEvent({"ctrl"}, direction, true)
   nextSpaceDownEvent:post()
end

function moveWindowOneSpaceEnd(direction)
   local nextSpaceUpEvent = hs.eventtap.event.newKeyEvent({"ctrl"}, direction, false)
   nextSpaceUpEvent:post()
   hs.timer.usleep(150000)
   local mouseReleaseEvent = hs.eventtap.event.newMouseEvent(hs.eventtap.event.types.leftmouseup, _clickPoint)
   mouseReleaseEvent:post()
   hs.timer.usleep(100000)
   hs.mouse.setAbsolutePosition(_mouseOrigin)
end

hk1 = hs.hotkey.bind({"ctrl","shift"}, "right",
   function() moveWindowOneSpace("right") end,
   function() moveWindowOneSpaceEnd("right") end)
hk2 = hs.hotkey.bind({"ctrl","shift"}, "left",
   function() moveWindowOneSpace("left") end,
   function() moveWindowOneSpaceEnd("left") end)
johntdyer commented 9 years ago

@jdtsmith -

I tired your example on 0.9.29 and the context moved to the second space when I did shift+control+right however the application didn't move with it. I tried ot move back to the previous space ( shift+control+left ) and I go the following error:

/Users/jdyer/.hammerspoon/init.lua:401: attempt to index global '_clickPoint' (a nil value)
stack traceback:
    /Users/jdyer/.hammerspoon/init.lua:401: in function 'moveWindowOneSpace'
    /Users/jdyer/.hammerspoon/init.lua:427: in function </Users/jdyer/.hammerspoon/init.lua:427>
    [C]: in function 'xpcall'
    ...oon.app/Contents/Resources/extensions/hs/hotkey/init.lua:29: in function <...oon.app/Contents/Resources/extensions/hs/hotkey/init.lua:27>
...n.app/Contents/Resources/extensions/hs/eventtap/init.lua:53: bad argument #2 to '_newMouseEvent' (table expected, got nil)
stack traceback:
    [C]: in function '_newMouseEvent'
    ...n.app/Contents/Resources/extensions/hs/eventtap/init.lua:53: in function 'newMouseEvent'
    /Users/jdyer/.hammerspoon/init.lua:417: in function 'moveWindowOneSpaceEnd'
    /Users/jdyer/.hammerspoon/init.lua:428: in function </Users/jdyer/.hammerspoon/init.lua:428>
    [C]: in function 'xpcall'
    ...oon.app/Contents/Resources/extensions/hs/hotkey/init.lua:29: in function <...oon.app/Contents/Resources/extensions/hs/hotkey/init.lua:27>

However if I did shift+control+right a second time I switched back to the first space. However as I mentioned the application in context did not switch with me when I went from space to space.

johntdyer commented 9 years ago

ok, I have no idea what changed but now its working.... I did reload the config before, no idea why it is not cooperating. I have noticed that it only appears to work now on applications on my "primary" screen ( I have two cinema displays ). If i move the app to the second display I am unable to then move that app to another space.

jdtsmith commented 9 years ago

I was having some trouble with the start/end formulation, so went back to a single function which seems to be working well. You may need to adjust the sleep timings. Critical was binding and posting the event in short succession.


function moveWindowOneSpace(direction)
   local mouseOrigin = mouse.getAbsolutePosition()
   local win = hs.window.focusedWindow()
   local clickPoint = win:zoomButtonRect()

   clickPoint.x = clickPoint.x + clickPoint.w + 5
   clickPoint.y = clickPoint.y + (clickPoint.h / 2)

   local mouseClickEvent = hs.eventtap.event.newMouseEvent(hs.eventtap.event.types.leftmousedown, clickPoint)
   mouseClickEvent:post()
   hs.timer.usleep(150000)

   local nextSpaceDownEvent = hs.eventtap.event.newKeyEvent({"ctrl"}, direction, true)
   nextSpaceDownEvent:post()
   hs.timer.usleep(150000)

   local nextSpaceUpEvent = hs.eventtap.event.newKeyEvent({"ctrl"}, direction, false)
   nextSpaceUpEvent:post()
   hs.timer.usleep(150000)

   local mouseReleaseEvent = hs.eventtap.event.newMouseEvent(hs.eventtap.event.types.leftmouseup, clickPoint)
   mouseReleaseEvent:post()
   hs.timer.usleep(150000)

   mouse.setAbsolutePosition(mouseOrigin)
end

hk1 = hs.hotkey.bind(mash, "s",
             function() moveWindowOneSpace("right") end)
hk2 = hs.hotkey.bind(mash, "a",
             function() moveWindowOneSpace("left") end)
lanox commented 7 years ago

HI @jdtsmith @cmsj sorry to reopen this, I am using @jdtsmith code above and it works fine on everything except chrome, it moves to different screen it just that chrome doesn't move. Is anyone having the same issue ? and if so is there way to fix it.

szymonkaliski commented 7 years ago

@lanox I recently switched to using hs._asm.undocumented.spaces moveWindowToSpace and it's much more reliable, I'd encourage checking it out :)

lanox commented 7 years ago

@szymonkaliski I will be completely honest and say I have not clue how to use lua.

szymonkaliski commented 7 years ago

@lanox my dotfiles are a mess right now so they are not online, but I'll try to cut out relevant parts for how I use it:

local spaces = require('hs._asm.undocumented.spaces')

-- get ids of spaces in same layout as mission control has them (hopefully)
local getSpacesIdsTable = function()
  local spacesLayout = spaces.layout()
  local spacesIds = {}

  hs.fnutils.each(hs.screen.allScreens(), function(screen)
    local spaceUUID = screen:spacesUUID()

    local userSpaces = hs.fnutils.filter(spacesLayout[spaceUUID], function(spaceId)
      return spaces.spaceType(spaceId) == spaces.types.user
    end)

    hs.fnutils.concat(spacesIds, userSpaces or {})
  end)

  return spacesIds
end

local throwToSpace = function(win, spaceIdx)
  local spacesIds = getSpacesIdsTable()
  local spaceId = spacesIds[spaceIdx]

  spaces.moveWindowToSpace(win:id(), spaceId)
end

hs.hotkey.bind({ 'ctrl', 'shift' }, 1, nil, function()
  local win = hs.window.focusedWindow()
  if not win then return end

  -- throw window to space
  throwToSpace(win, 1)

  -- move to that space
  hs.eventtap.keyStroke({ 'ctrl' }, '1')
end)

Making it work across multiple screens etc is bit more complex, but doable (I have it working in my setup).

lanox commented 7 years ago

@szymonkaliski thanks will give that a go, yes I have had a look at your dotfiles and i was scratching my head lol ..

but does that mean I need to get spaces ? module or plugin.

TwoLeaves commented 7 years ago

@lanox: For reference, the issue you were having with Chrome is perhaps due to the hidden window placed on top of all visible Chrome windows. These hidden windows have an empty title, so you can avoid selecting them by checking if a window's title is empty and if it belongs to Chrome (because some programs give their main window an empty title). That is unless Chrome has changed this behaviour (I haven't used Hammerspoon or Chrome on OSX for a couple of years now). The code I used to perform this task was as such:

local function spaceChange()
  visibleWindows = hs.window.orderedWindows()
  for i, window in ipairs(visibleWindows) do
    if window:application():title() == "Google Chrome" then
      if window:title() ~= "" then
        window:focus()
        break
      end
    else
      window:focus()
      break
    end
  end
end
lanox commented 7 years ago

@TwoLeaves do you have a working solution with the code from jdsmith, sorry I am not that good wiht lua, I am just starting out with hammerspoon , thanks so much

TwoLeaves commented 7 years ago

I don't I'm sorry. I only have Thinkpads around at the moment. You would have to extend the line from jdsmith which reads "local win = hs.window.focusedWindow()".

lanox commented 7 years ago

if anyone has a working solution would be much appreciated, sorry never worked with lua.

jdtsmith commented 7 years ago

Not sure if this helps the Chrome issue, but I hadn't yet heard of the undocumented Spaces plugin, thanks for the tip. Here's my attempt at using it to replace the old simulate-titlebar-mouse-press-and-move hack. Note that this version still requires the use of Ctrl-left and Ctrl-right shortcuts for switching to the next space, because the hs._asm.undocumented.spaces space switching requires killing the Dock, which is slow and ugly.

Advantages of the old method over the new:

And disadvantages:

Advantages of the new method:

Disadvantages:

Here's the code. I put it in move_space.lua and require("move_space") in my init.lua

local hotkey = require "hs.hotkey"
local window = require "hs.window"
local spaces = require "hs._asm.undocumented.spaces"

function getGoodFocusedWindow(nofull)
   local win = window.focusedWindow()
   if not win or not win:isStandard() then return end
   if nofull and win:isFullScreen() then return end
   return win
end 

function flashScreen(screen)
   local flash=hs.canvas.new(screen:fullFrame()):appendElements({
     action = "fill",
     fillColor = { alpha = 0.25, red=1},
     type = "rectangle"})
   flash:show()
   hs.timer.doAfter(.15,function () flash:delete() end)
end 

function switchSpace(skip,dir)
   for i=1,skip do
      hs.eventtap.keyStroke({"ctrl"},dir)
   end 
end

function moveWindowOneSpace(dir,switch)
   local win = getGoodFocusedWindow(true)
   if not win then return end
   local screen=win:screen()
   local uuid=screen:spacesUUID()
   local userSpaces=nil
   for k,v in pairs(spaces.layout()) do
      userSpaces=v
      if k==uuid then break end
   end
   if not userSpaces then return end
   local thisSpace=win:spaces() -- first space win appears on
   if not thisSpace then return else thisSpace=thisSpace[1] end
   local last=nil
   local skipSpaces=0
   for _, spc in ipairs(userSpaces) do
      if spaces.spaceType(spc)~=spaces.types.user then -- skippable space
     skipSpaces=skipSpaces+1
      else          -- A good user space, check it
     if last and
        ((dir=="left"  and spc==thisSpace) or
         (dir=="right" and last==thisSpace))
     then
        win:spacesMoveTo(dir=="left" and last or spc)
        if switch then
           switchSpace(skipSpaces+1,dir)
           win:focus()
        end
        return
     end
     last=spc    -- Haven't found it yet...
     skipSpaces=0
      end 
   end
   flashScreen(screen)   -- Shouldn't get here, so no space found
end
mash =      {"ctrl", "cmd"}
mashshift = {"ctrl", "cmd","shift"}

hotkey.bind(mash, "s",nil,
        function() moveWindowOneSpace("right",true) end)
hotkey.bind(mash, "a",nil,
        function() moveWindowOneSpace("left",true) end)
hotkey.bind(mashshift, "s",nil,
        function() moveWindowOneSpace("right",false) end)
hotkey.bind(mashshift, "a",nil,
        function() moveWindowOneSpace("left",false) end)
jdtsmith commented 7 years ago

And by the way, it appears I had altered my version of the old hackish method to handle multiple moves in quick succession (reentrancy) without reporting it, so for full disclosure here it is. The new version doesn't have this issue, since the window can be moved arbitrarily far ahead and the space switching animation catches up after the fact.

local mouseOrigin
local inMove=0
-- move a window to an adjacent Space
function moveWindowOneSpaceOld(direction)
   local win = window.focusedWindow()
   if not win then return end
   local clickPoint = win:zoomButtonRect()
   if inMove==0 then mouseOrigin = mouse.getAbsolutePosition() end

   clickPoint.x = clickPoint.x + clickPoint.w + 5
   clickPoint.y = clickPoint.y + (clickPoint.h / 2)
   local mouseClickEvent = hs.eventtap.event.newMouseEvent(
      hs.eventtap.event.types.leftMouseDown, clickPoint)
   mouseClickEvent:post()

   local nextSpaceDownEvent = hs.eventtap.event.newKeyEvent(
      {"ctrl"},direction, true)
   nextSpaceDownEvent:post()
   inMove=inMove+1      -- nested moves possible, ensure reentrancy

   hs.timer.doAfter(.1,function()
               local nextSpaceUpEvent = hs.eventtap.event.newKeyEvent(
              {"ctrl"}, direction, false)
               nextSpaceUpEvent:post()
               -- wait to release the mouse to avoid sticky window syndrome
               hs.timer.doAfter(.25, 
                    function()
                       local mouseReleaseEvent = hs.eventtap.event.newMouseEvent(
                          hs.eventtap.event.types.leftMouseUp, clickPoint)
                       mouseReleaseEvent:post()
                       inMove=math.max(0,inMove-1)
                       if inMove==0 then mouse.setAbsolutePosition(mouseOrigin) end 
               end)
   end)
end
lanox commented 7 years ago

@jdtsmith woha thanks, I have trued to use the old method but I get attempt to index a nil value (global 'window')stack traceback

this is how my code looks(well your code)

local mouseOrigin
local inMove=0
-- move a window to an adjacent Space
function moveWindowOneSpaceOld(direction)
   local win = window.focusedWindow()
   if not win then return end
   local clickPoint = win:zoomButtonRect()
   if inMove==0 then mouseOrigin = mouse.getAbsolutePosition() end

   clickPoint.x = clickPoint.x + clickPoint.w + 5
   clickPoint.y = clickPoint.y + (clickPoint.h / 2)
   local mouseClickEvent = hs.eventtap.event.newMouseEvent(
      hs.eventtap.event.types.leftMouseDown, clickPoint)
   mouseClickEvent:post()

   local nextSpaceDownEvent = hs.eventtap.event.newKeyEvent(
      {"ctrl"},direction, true)
   nextSpaceDownEvent:post()
   inMove=inMove+1      -- nested moves possible, ensure reentrancy

   hs.timer.doAfter(.1,function()
               local nextSpaceUpEvent = hs.eventtap.event.newKeyEvent(
              {"ctrl"}, direction, false)
               nextSpaceUpEvent:post()
               -- wait to release the mouse to avoid sticky window syndrome
               hs.timer.doAfter(.25, 
                    function()
                       local mouseReleaseEvent = hs.eventtap.event.newMouseEvent(
                          hs.eventtap.event.types.leftMouseUp, clickPoint)
                       mouseReleaseEvent:post()
                       inMove=math.max(0,inMove-1)
                       if inMove==0 then mouse.setAbsolutePosition(mouseOrigin) end 
               end)
   end)
end
hk1 = hs.hotkey.bind(mash3, "Right",
            function() moveWindowOneSpaceOld("right", true) end)
hk2 = hs.hotkey.bind(mash3, "Left",
            function() moveWindowOneSpaceOld("left", true) end)
lanox commented 7 years ago

another thing tyring out the new version with undocumented methode, I can move chrome but I cant move anything else ? also why do you need to have 2 mash ? thanks

jdtsmith commented 7 years ago

I didn't provide complete code for the old method. You need, at the top:

local window = require "hs.window"
mash3 =      {"ctrl", "cmd"}

or similar to set up which modifier keys to use. Probably worth spending some time familiarizing yourself with a variety of example dotfiles and some Lua coding to make good use of HS.

lanox commented 7 years ago

@jdtsmith thanks mate, I dunno but no matter what I do i cant seem to get past this error

2017-11-03 09:24:19: 09:24:19 ERROR:   LuaSkin: hs.hotkey callback error: attempt to index a nil value
stack traceback:
    [C]: in for iterator 'for iterator'
    /Users/lxxx/.hammerspoon/init.lua:37: in function 'moveWindowOneSpace'
    /Users/lxxx/.hammerspoon/init.lua:62: in function </Users/lxxx/.hammerspoon/init.lua:62>
2017-11-03 09:24:19: ********

This is using this code.

local hotkey = require "hs.hotkey"
local window = require "hs.window"
local spaces = require "hs._asm.undocumented.spaces"

function getGoodFocusedWindow(nofull)
   local win = window.focusedWindow()
   if not win or not win:isStandard() then return end
   if nofull and win:isFullScreen() then return end
   return win
end 

function flashScreen(screen)
   local flash=hs.canvas.new(screen:fullFrame()):appendElements({
     action = "fill",
     fillColor = { alpha = 0.25, red=1},
     type = "rectangle"})
   flash:show()
   hs.timer.doAfter(.15,function () flash:delete() end)
end 

function switchSpace(skip,dir)
   for i=1,skip do
      hs.eventtap.keyStroke({"ctrl"},dir)
   end 
end

function moveWindowOneSpace(dir,switch)
   local win = getGoodFocusedWindow(true)
   if not win then return end
   local screen=win:screen()
   local uuid=screen:spacesUUID()
   local userSpaces=spaces.layout()[uuid]
   local thisSpace=win:spaces() -- first space win appears on
   if not thisSpace then return else thisSpace=thisSpace[1] end
   local last=nil
   local skipSpaces=0
   for _, spc in ipairs(userSpaces) do
      if spaces.spaceType(spc)~=spaces.types.user then -- skippable space
     skipSpaces=skipSpaces+1
      else          -- A good user space, check it
     if last and
        (dir=="left"  and spc==thisSpace) or
        (dir=="right" and last==thisSpace)
     then
        win:spacesMoveTo(dir=="left" and last or spc)
        if switch then
           switchSpace(skipSpaces+1,dir)
           win:focus()
        end
        return
     end
     last=spc    -- Haven't found it yet...
     skipSpaces=0
      end 
   end
   flashScreen(screen)   -- Shouldn't get here, so no space found
end
mash =      {"ctrl", "cmd"}
mashshift = {"ctrl", "cmd","shift"}

hotkey.bind(mash, "s",nil,
        function() moveWindowOneSpace("right",true) end)
hotkey.bind(mash, "a",nil,
        function() moveWindowOneSpace("left",true) end)
hotkey.bind(mashshift, "s",nil,
        function() moveWindowOneSpace("right",false) end)
hotkey.bind(mashshift, "a",nil,
        function() moveWindowOneSpace("left",false) end)
jdtsmith commented 7 years ago

Throw a print(uuid) line after the local uuid=... line and see what it says. Sounds like an issue of not finding any spaces.

lanox commented 7 years ago

I dunno all the suddenly it started working. Thanks so much mate..

lanox commented 7 years ago

@jdtsmith Ah I found out why it was not working, when I have multiple 2 external monitors connected, if I move window to second monitor then it doesnt work. If the window is on primary screen it works fine.

jdtsmith commented 7 years ago

Multi screens work for me. Do you have different spaces per screen?

lanox commented 7 years ago

Yes I do have Display have separate Spaces un ticket is it possible to work with aout been ticket ?

If i thick the box all works fine. Thanks so much for you time and effort on helping me with this

jdtsmith commented 7 years ago

OK, I see what happens when spaces are shared between screens (only one screen is listed in layout()). I've fixed the original code above so it can handle both cases.

lanox commented 7 years ago

thanks so much man, all works well now.