Created
December 14, 2021 15:06
-
-
Save luckman212/615ad90c3ac4fce2460fc22505671a26 to your computer and use it in GitHub Desktop.
Hammerspoon module for draggable live updating floating preview windows
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| --[[ Live-updating floating previews | |
| https://www.hammerspoon.org/docs/hs.window.html | |
| https://www.hammerspoon.org/docs/hs.canvas.html | |
| https://www.hammerspoon.org/docs/hs.image.html | |
| https://www.hammerspoon.org/docs/hs.mouse.html | |
| https://www.hammerspoon.org/docs/hs.timer.html | |
| https://www.lua.org/manual/5.4/manual.html#6.2 (coroutines) | |
| https://github.com/Hammerspoon/hammerspoon/issues/2710#issuecomment-788266990 (thanks @asmagill) | |
| ]]-- | |
| -- user-settable prefs | |
| local main_key = 'f13' -- PrtScr | |
| local refreshIntervals = { 2, 1, 0.5, 0.25 } -- 0.10, 0.05 removed due to bad perf on 4k | |
| local sizes = { 400, 300, 200, 100, 50, 25 } | |
| local sizeIndex = 2 -- initial size of floater | |
| local animateBorderDraw = true | |
| local reactivateWhenClosing = true -- bring app back to foreground when stopped | |
| local notifyOnClose = true -- notify if monitored window closes unexpectedly | |
| local soundEnabled = true | |
| local invertScrollDirection = true | |
| local moveOffscreen = true -- push main app window offscreen upon activation | |
| local hiliteCaptureArea = false -- for debugging source win capture area | |
| local captureRectColor = { red=0, green=1, blue=0, alpha=1 } | |
| local lineColor = { red=1, green=0, blue=0, alpha=1 } | |
| -- scale | |
| local scale = hs.screen.primaryScreen():currentMode().scale | |
| -- sounds | |
| local spath=(os.getenv("HOME") .. "/path/to/sounds/dir/") | |
| local snd_on=hs.sound.getByFile(spath .. "filename-of-on-sound.aif"):device(nil):volume(0.25) | |
| local snd_off=hs.sound.getByFile(spath .. "filename-of-off-sound.aif"):device(nil):volume(0.50) | |
| local snd_size=hs.sound.getByFile(spath .. "filename-of-tick-sound.aif"):device(nil):volume(0.50) | |
| -- use hs.styledtext.fontNames() to list available fonts | |
| local textFontSize = 20 | |
| local textPadding = 4.0 | |
| local fTextStyle = { | |
| font = { name="SFProText-Medium", size=textFontSize }, | |
| color = { red=1, green=1, blue=1, alpha=0.9 }, | |
| paragraphStyle = { | |
| alignment="left", | |
| lineBreak="clip", | |
| maximumLineHeight=textFontSize + 2 | |
| }, | |
| shadow = { | |
| offset = { h=-1, w=-2 }, | |
| color = { red=0, green=0, blue=0, alpha=0.7 } | |
| } | |
| } | |
| -- globals | |
| local _cMouseAction | |
| local floater | |
| local w -- current hs.window object to capture from | |
| local i -- windowID of w | |
| local e -- elapsed time between snapshots | |
| local app -- stores hs.application object that owns w | |
| local appname -- app:name() | |
| local capture_origin = {} | |
| local capture_center = {} | |
| local floater_origin = {} | |
| local win_frame = {} | |
| local winPosCache = {} | |
| local canvasSize = {} | |
| local patterns = {} | |
| local _lastClickPos = {} | |
| local _lastClickTime = hs.timer.absoluteTime() | |
| local doubleClickTime = 250000000 -- nanoseconds (0.25s) | |
| local refreshInt = refreshIntervals[1] | |
| local loopCount = 0 | |
| local failCount = 0 | |
| local refreshFailCount = 0 | |
| local hasBeenMovedOffscreen = false | |
| local hkTable = {} -- hotkeys table | |
| local timerTable = {} | |
| local screenSleep = false | |
| local screenSaver = false | |
| local screenLock = false | |
| local _updatesPaused = false | |
| local _screenOff = false | |
| function get_size(incr) | |
| idx = sizeIndex or 0 | |
| max = math.min(win_frame.h,win_frame.h) | |
| if incr then idx = idx+1 end | |
| if idx > #sizes then idx = 1 end | |
| for i=idx, #sizes do | |
| if sizes[i] < max then | |
| sizeIndex = i | |
| break | |
| end | |
| sizeIndex = 1 | |
| end | |
| return sizes[sizeIndex] | |
| end | |
| function displayRefreshInt() | |
| if canvasSize.w >= 100 then | |
| txt = hs.styledtext.new(refreshInt..'s', fTextStyle) | |
| removeOverlays('interval') | |
| floater:insertElement({ | |
| id = 'interval', | |
| type = 'text', | |
| text = txt, | |
| padding = textPadding, | |
| action = 'stroke', | |
| frame = { x="0%", y="0%", h="100%", w="100%" }, | |
| antialias = true | |
| }) | |
| end | |
| destroyTimers('refreshInt') | |
| timerTable['refreshInt'] = hs.timer.doAfter(1.5, function() removeOverlays('interval') end) | |
| end | |
| function cycleRefreshInt() | |
| for c,int in ipairs(refreshIntervals) do | |
| if refreshInt == int then | |
| refreshInt = (refreshIntervals[c+1] or refreshIntervals[1]) | |
| break | |
| end | |
| end | |
| startMainTimer() | |
| _stopCoroutine('cycle refreshInt') | |
| resumeUpdates(false) | |
| end | |
| function cycleRefreshIntReverse() | |
| for c,int in ipairs(refreshIntervals) do | |
| if refreshInt == int then | |
| refreshInt = (refreshIntervals[c-1] or refreshIntervals[1]) | |
| break | |
| end | |
| end | |
| startMainTimer() | |
| _stopCoroutine('cycle refreshInt') | |
| resumeUpdates(false) | |
| end | |
| function resetSrcWindow() | |
| w,i = nil | |
| app,appname = nil | |
| canvasSize = {} | |
| end | |
| function destroyCanvasObj(cObj,gc) | |
| if not cObj or hstype(cObj) ~= 'hs.canvas' then return end | |
| -- explicit :delete() is deprecated, use gc | |
| -- see https://github.com/Hammerspoon/hammerspoon/issues/3021 | |
| -- cObj:delete(delay or 0) | |
| for i=#cObj,1,-1 do | |
| cObj[i] = nil | |
| end | |
| cObj:clickActivating(false) | |
| cObj:mouseCallback(nil) | |
| cObj:canvasMouseEvents(nil, nil, nil, nil) | |
| cObj = nil | |
| if gc and gc == true then collectgarbage() end | |
| end | |
| function close_floater(flag,log,delay,reason) | |
| if log then print('closing floater: '..(reason or 'unknown')) end | |
| _stopCoroutine('called by close_floater') | |
| destroyTimers({'animGuardTimer','strokeDashPatternTimer'}) | |
| destroyCanvasObj(floater,false) | |
| destroyCanvasObj(captureHilite,true) | |
| -- floater = nil | |
| -- captureHilite = nil | |
| destroyHotkeys({'hk_destroy','hk_speed','hk_togglewin'}) | |
| if flag then | |
| playSound(snd_off,soundEnabled) | |
| destroyTimers('main') | |
| moveWinOffscreen(false) | |
| resetSrcWindow() | |
| end | |
| failCount = 0 | |
| _updatesPaused = false | |
| collectgarbage() | |
| end | |
| function point_near(p1,p2,d) | |
| local delta = math.abs(p1.x - p2.x) + math.abs(p1.y - p2.y) | |
| return (delta <= d) | |
| end | |
| function checkForDoubleClick(ts,pos) | |
| if ts - _lastClickTime < doubleClickTime then | |
| if point_near(pos, _lastClickPos, 2*scale) then | |
| return true | |
| end | |
| end | |
| _lastClickPos = pos | |
| _lastClickTime = ts | |
| return false | |
| end | |
| function startGuardTimer() | |
| destroyTimers('guardTimer') | |
| timerTable['guardTimer'] = hs.timer.doWhile( | |
| function() return w ~= nil end, | |
| function() srcWinExists(w) end, | |
| 1) | |
| end | |
| function startMainTimer() | |
| destroyTimers('main') | |
| timerTable['main'] = hs.timer.doEvery(refreshInt, function() refreshWin(i) end) | |
| displayRefreshInt() | |
| startGuardTimer() | |
| end | |
| function dumpWin(w) | |
| print('wID:',w:id()) | |
| print('min?',w:isMinimized()) | |
| print('vis?',w:isVisible()) | |
| print('axui:',hs.inspect(hs.axuielement.windowElement(w):isValid())) | |
| print('_updatesPaused:',_updatesPaused) | |
| print('_screenOff:',_screenOff) | |
| end | |
| function srcWinExists(w) | |
| -- dumpWin(w) | |
| if _updatesPaused or _screenOff then return true end | |
| if w and hs.axuielement.windowElement(w):isValid() then | |
| return true | |
| else | |
| w = nil | |
| if notifyOnClose then | |
| msg = 'The window that was being tracked from '..appname..' has closed.' | |
| hs.notify.show('Underlying window closed','',msg) | |
| -- print(msg) | |
| end | |
| close_floater(true, true, 0, 'unexpected close') | |
| return false | |
| end | |
| end | |
| function valid_capture_pos(p,max) | |
| if p < 0 then return 0 end | |
| if p > max then return to_int(max) end | |
| return to_int(p) | |
| end | |
| function get_relative_mouse(mouse,frame) | |
| local left = frame.x | |
| local right = frame.x + frame.w | |
| local top = frame.y | |
| local bottom = frame.y + frame.h | |
| local rmx = mouse.x - left | |
| local rmy = mouse.y - top | |
| if rmx < 0 then rmx = 0 end | |
| if rmy < 0 then rmy = 0 end | |
| if mouse.x > right then rmx = right end | |
| if mouse.y > bottom then rmy = bottom end | |
| return { x=to_int(rmx), y=to_int(rmy) } | |
| end | |
| function valid_origin(o,min,max) | |
| local max_adj = min + max | |
| if o < min then return to_int(min) end | |
| if o > max_adj then return to_int(max_adj) end | |
| return to_int(o) | |
| end | |
| function _updateCaptureOrigin(new_origin,offset) | |
| capture_origin = { | |
| x = new_origin.x, | |
| y = new_origin.y | |
| } | |
| capture_center = { | |
| x = capture_origin.x + offset.x, | |
| y = capture_origin.y + offset.y | |
| } | |
| end | |
| function max_x_pos() | |
| -- hs.inspect(hs.screen.screenPositions()) | |
| local max_x = 0 | |
| for _,scr in ipairs(hs.screen.allScreens()) do | |
| scrWidth = scr:currentMode().w | |
| scrPos = { scr:position() } | |
| scrX = scrWidth + scrPos[1] | |
| if scrX > max_x then max_x = scrX end | |
| end | |
| return (to_int(max_x) or 0) | |
| end | |
| function mouseHasMoved(old,new) | |
| if (type(old) ~= 'table') or (type(new) ~= 'table') then return true end | |
| if old == new then return false end | |
| if to_int(old.x) ~= to_int(new.x) then return true end | |
| if to_int(old.y) ~= to_int(new.y) then return true end | |
| return false | |
| end | |
| function showPausedText() | |
| if canvasElementExists('pausedrect') then return end | |
| if canvasSize.w and canvasSize.w >= 100 then | |
| txt = hs.styledtext.new('PAUSED', fTextStyle) | |
| else | |
| txt = hs.styledtext.new('X', fTextStyle) | |
| end | |
| floater:appendElements({ | |
| id = 'pausedrect', | |
| type = 'rectangle', | |
| action = 'fill', | |
| fillColor = { red=0, green=0, blue=0, alpha=0.5 }, | |
| antialias = false, | |
| frame = { x = "0%", y = "0%", h = "100%", w = "100%" }, | |
| trackMouseDown = true, | |
| trackMouseUp = true | |
| }, | |
| { | |
| id = 'pausedtxt', | |
| type = 'text', | |
| text = txt, | |
| padding = textPadding, | |
| action = 'stroke', | |
| frame = { x="0%", y="0%", h="100%", w="100%" }, | |
| antialias = true, | |
| trackMouseDown = true, | |
| trackMouseUp = true | |
| }) | |
| end | |
| function pauseUpdates(bTriggeredByScreenEvent) | |
| if bTriggeredByScreenEvent then _screenOff = true end | |
| if not floater then return end | |
| if not _updatesPaused then | |
| _updatesPaused = true | |
| -- print('_updatesPaused: ',_updatesPaused) | |
| -- print('_screenOff: ',_screenOff) | |
| showPausedText() | |
| end | |
| end | |
| function resumeUpdates(bTriggeredByScreenEvent) | |
| _screenOff = false | |
| if not floater then return end | |
| if _updatesPaused then | |
| _updatesPaused = false | |
| removeOverlays({'pausedrect','pausedtxt'}) | |
| end | |
| end | |
| function refreshWin(i,force) | |
| if not floater then return end | |
| if _screenOff then return end | |
| if not w then return end | |
| -- dumpWin(w) | |
| if w:isVisible() then | |
| if _updatesPaused then | |
| resumeUpdates(false) | |
| if force then showPausedText() end | |
| return | |
| end | |
| else | |
| pauseUpdates(false) | |
| return | |
| end | |
| -- https://github.com/Hammerspoon/hammerspoon/issues/2742 (now fixed) | |
| -- local snap = hs.window.snapshotForID(i, false) | |
| if e then | |
| local e_in_s = (hs.timer.absoluteTime() - e) / 1000000000 | |
| local e_rounded = round(e_in_s,0.25) | |
| -- print('elapsed time (s): ' .. e_rounded) | |
| if e_rounded > refreshInt then | |
| refreshFailCount = refreshFailCount + 1 | |
| if refreshFailCount >= 10 then | |
| print('reducing refresh interval ('..e_rounded..' vs '..refreshInt..')') | |
| refreshFailCount = 0 | |
| cycleRefreshIntReverse() | |
| end | |
| end | |
| end | |
| e = hs.timer.absoluteTime() | |
| local snap = w:snapshot(false) | |
| if not (snap and hstype(snap) == 'hs.image') then | |
| failCount = failCount + 1 | |
| if failCount >= 10 then | |
| close_floater(true,true,0.1,'failed to get snapshot, giving up after '..failCount..' tries') | |
| end | |
| return | |
| end | |
| local cropped = snap:croppedCopy({ | |
| x=capture_origin.x * scale, | |
| y=capture_origin.y * scale, | |
| h=canvasSize.h * scale, | |
| w=canvasSize.w * scale | |
| }):size({ h=canvasSize.h, w=canvasSize.w }, true) | |
| --[[ | |
| print( | |
| 'x='..capture_origin.x, | |
| 'y='..capture_origin.y, | |
| 'h='..canvasSize.h, | |
| 'w='..canvasSize.w | |
| ) | |
| ]]-- | |
| if floater[2] == nil then | |
| floater[2] = { | |
| id = 'preview', | |
| type = 'image', | |
| action = 'fill', | |
| compositeRule = 'copy', | |
| antialias = false, | |
| frame = { x = "0%", y = "0%", h = "100%", w = "100%" }, | |
| padding = 2.0, | |
| image = cropped, | |
| imageAlpha = 1.0, | |
| imageAlignment = 'center', | |
| imageScaling = 'none', | |
| trackMouseDown = true, | |
| trackMouseUp = true, | |
| trackMouseEnterExit = true | |
| } | |
| else | |
| floater[2].image = cropped | |
| end | |
| loopCount = loopCount + 1 | |
| if loopCount > 40 then | |
| collectgarbage() | |
| -- print("Process resident size: "..hs.crash.residentSize()) | |
| -- print("Lua state size: "..math.floor(collectgarbage("count")*1024)) | |
| loopCount = 0 | |
| end | |
| failCount = 0 | |
| end | |
| function moveWinOffscreen(bool) | |
| if not bool then | |
| if winPosCache[i] then | |
| if w then w:setFrame(winPosCache[i]) end | |
| winPosCache[i] = nil | |
| end | |
| if reactivateWhenClosing and app and w then | |
| app:activate() | |
| w:unminimize() | |
| end | |
| hasBeenMovedOffscreen = false | |
| elseif bool then | |
| if moveOffscreen and w then | |
| winPosCache[i] = win_frame | |
| w:setFrame({ x=max_x_pos(), y=win_frame.y, w=win_frame.w, h=win_frame.h }, 0) | |
| w:move({35,0}, false, 0.5) -- kludge because setFrame won't push a window fully offscreen | |
| hasBeenMovedOffscreen = true | |
| end | |
| end | |
| end | |
| function drawBoxAroundCaptureArea() | |
| captureHilite = hs.canvas.new({ | |
| x=win_frame.x + capture_origin.x, | |
| y=win_frame.y + capture_origin.y, | |
| h=canvasSize.h, | |
| w=canvasSize.w | |
| }) | |
| captureHilite[1] = { | |
| id = 'border', | |
| type = 'rectangle', | |
| action = 'stroke', | |
| strokeWidth = 4.0, | |
| strokeColor = captureRectColor, | |
| antialias = false, | |
| frame = { x = "0%", y = "0%", h = "100%", w = "100%" } | |
| } | |
| captureHilite:level('overlay') | |
| captureHilite:show() | |
| end | |
| function drawCrosshairs() | |
| if _updatesPaused then return end | |
| if canvasElementExists(floater,'crosshair') then return end | |
| top = { x=floater:frame().w/2, y=0 } | |
| bottom = { x=floater:frame().w/2, y=floater:frame().h } | |
| left = { x=0, y=floater:frame().h/2 } | |
| right = { x=floater:frame().w, y=floater:frame().h/2 } | |
| center = { x=floater:frame().w/2, y=floater:frame().h/2 } | |
| floater:insertElement({ | |
| id = 'crosshair', | |
| type = 'segments', | |
| action = 'stroke', | |
| coordinates = { top, bottom, center, left, right }, | |
| strokeWidth = 1.0, | |
| antialias = false, | |
| strokeColor = lineColor | |
| }) | |
| end | |
| function drawEscText() | |
| if canvasElementExists(floater,'esctxt') then return end | |
| if canvasElementExists(floater,'crosshair') then | |
| if canvasSize.w >= 100 then | |
| removeOverlays('interval') | |
| txt = hs.styledtext.new('ESC', fTextStyle) | |
| floater:insertElement({ | |
| id = 'esctxt', | |
| type = 'text', | |
| text = txt, | |
| padding = textPadding, | |
| action = 'stroke', | |
| frame = { x="0%", y="0%", h="100%", w="100%" }, | |
| antialias = true | |
| }) | |
| end | |
| end | |
| end | |
| function canvasElementExists(canvas,id) | |
| if not ( | |
| canvas and | |
| id and | |
| hstype(canvas) == 'hs.canvas' and | |
| type(id) == 'string' | |
| ) then return false end | |
| for _,e in pairs(canvas:canvasElements()) do | |
| if id == e.id then return true end | |
| end | |
| return false | |
| end | |
| function removeOverlays(ids) -- (...) | |
| -- local arg={...} | |
| if not ids then return end | |
| if not (floater and #floater > 2) then return end | |
| if type(ids) == 'string' then ids = { ids } end | |
| for _,id in pairs(ids) do | |
| for i=#floater,3,-1 do -- use hs.canvas:elementCount() ? | |
| if (id == 'all') or (floater[i].id == id) then -- floater:elementAttribute(e,'id') | |
| floater:removeElement(i) -- floater[e] = nil | |
| end | |
| end | |
| end | |
| end | |
| -- https://www.hammerspoon.org/docs/hs.hotkey.html | |
| function activateHotkey(name,mods,key,fn) | |
| if not hkTable then return end | |
| if not name then return end | |
| if not fn then return end | |
| if type(key) ~= 'string' then return end | |
| if hkTable[name] then destroyHotkeys(name) end | |
| hkTable[name] = hs.hotkey.bind(mods,key,fn,nil,nil) | |
| -- if hkTable[name] then print('bound hotkey '..name..' to '..hkTable[name].idx) end | |
| end | |
| function destroyHotkeys(hklist) | |
| if not hklist then return end | |
| if type(hklist) == 'string' then hklist = { hklist } end | |
| for _,name in pairs(hklist) do | |
| if hkTable[name] and hstype(hkTable[name]) == 'hs.hotkey' then | |
| hkTable[name]:delete() | |
| hkTable[name] = nil | |
| end | |
| end | |
| end | |
| function destroyTimers(tlist) | |
| if not tlist then return end | |
| if type(tlist) == 'string' then tlist = { tlist } end | |
| for _,name in pairs(tlist) do | |
| if timerTable[name] and hstype(timerTable[name]) == 'hs.timer' then | |
| timerTable[name]:stop() | |
| timerTable[name] = nil | |
| end | |
| end | |
| end | |
| function timerIsRunning(name) | |
| if timerTable[name] and timerTable[name]:running() then | |
| return true | |
| else | |
| return false | |
| end | |
| end | |
| function _stopCoroutine(reason) | |
| _cMouseAction = nil | |
| -- print('stopping coroutine: '..(reason or 'unknown')) | |
| removeOverlays({'esctxt','crosshair'}) | |
| destroyHotkeys({'hk_esc'}) | |
| end | |
| function createFail(msg) | |
| print(msg) | |
| resetSrcWindow() | |
| return false | |
| end | |
| function destroy_floater() | |
| if floater and floater:elementCount() > 0 then | |
| close_floater(true,false,0.1,'closed by hotkey') | |
| end | |
| end | |
| function animateFloaterBorder(cObj) | |
| if not next(patterns) then | |
| destroyTimers({'animGuardTimer','strokeDashPatternTimer'}) | |
| cObj[1].strokeDashPattern = nil | |
| cObj[1].strokeColor = lineColor | |
| return | |
| end | |
| if not timerIsRunning('animGuardTimer') then | |
| timerTable['animGuardTimer'] = hs.timer.doWhile( | |
| function() return next(patterns) ~= nil end, | |
| function() animateFloaterBorder(cObj) end, | |
| 1) | |
| else | |
| timerTable['animGuardTimer']:setNextTrigger(1) | |
| end | |
| cObj[1].strokeDashPattern = patterns[1].p | |
| cObj[1].strokeColor = lineColor | |
| table.remove(patterns,1) | |
| if next(patterns) then | |
| -- print('will set dashPat = '..hs.inspect(patterns[1].p)..' in '..patterns[1].d..'s') | |
| destroyTimers('strokeDashPatternTimer') | |
| timerTable['strokeDashPatternTimer'] = hs.timer.doAfter(patterns[1].d, function() animateFloaterBorder(cObj) end) | |
| end | |
| end | |
| function strokeFloaterWithAnimation(cObj) | |
| if hstype(cObj) ~= 'hs.canvas' then return end | |
| patterns = {} | |
| local gap = 24 | |
| local step = 2 -- orig:6 | |
| local mod = gap % step | |
| if mod > 0 then | |
| print('invalid gap/step pair, adjusting gap '..gap..'->'..gap - mod) | |
| gap = gap - mod | |
| end | |
| local duration = 0.25 -- total duration of the animation | |
| local speed = duration / (gap/step) | |
| for p=step,gap,step do table.insert(patterns, {d=speed, p={ p, gap-p }}) end | |
| -- print('animation has '..#patterns..' steps:') | |
| -- print(hs.inspect(patterns)) | |
| animateFloaterBorder(cObj) | |
| end | |
| function strokeFloaterWithoutAnimation(cObj) | |
| if hstype(cObj) ~= 'hs.canvas' then return end | |
| cObj[1].strokeColor = lineColor | |
| cObj[1].strokeDashPattern = nil | |
| end | |
| function create_floater() | |
| cur_pos = hs.mouse.absolutePosition() | |
| -- make sure we have a valid window to track | |
| if not w then | |
| w = hs.window.focusedWindow() | |
| if not w then return createFail('no focused window') end | |
| if w == hs.window.desktop() then return createFail('can\'t float the desktop window') end | |
| if not w:isVisible() then createFail('can\'t float an invisible window') end | |
| i = w:id() | |
| if not i then return createFail('couldn\'t obtain the windowID') end | |
| scale = hs.screen.mainScreen():currentMode().scale | |
| app = w:application() | |
| appname = (app:name() or 'unknown') | |
| win_frame = w:frame() | |
| win_scr = w:screen() | |
| win_scr_frame = win_scr:fullFrame() | |
| capture_center = get_relative_mouse(cur_pos,win_frame) | |
| end | |
| if floater and floater:elementCount() > 0 then | |
| floater_center = { | |
| x = to_int(floater:frame().x + floater:frame().w/2), | |
| y = to_int(floater:frame().y + floater:frame().h/2) | |
| } | |
| close_floater(false,false,0,'size change') | |
| presetSize = get_size(true) | |
| _animate = false | |
| _moveOffscreenNow = false | |
| playSound(snd_size,soundEnabled) | |
| else | |
| floater_center = { | |
| x = to_int(cur_pos.x), | |
| y = to_int(cur_pos.y) | |
| } | |
| presetSize = get_size(false) | |
| _animate = animateBorderDraw | |
| _moveOffscreenNow = moveOffscreen | |
| playSound(snd_on,soundEnabled) | |
| end | |
| canvasSize = { h=math.min(presetSize,to_int(win_frame.h)), w=math.min(presetSize,to_int(win_frame.w)) } | |
| canvasAdj = { x=to_int(canvasSize.w/2), y=to_int(canvasSize.h/2) } | |
| floater_origin = { | |
| x = valid_origin(floater_center.x - canvasAdj.x, win_scr_frame.x, win_scr_frame.x + win_scr_frame.w - canvasSize.w), | |
| y = valid_origin(floater_center.y - canvasAdj.y, win_scr_frame.y, win_scr_frame.y + win_scr_frame.h - canvasSize.h) | |
| } | |
| _updateCaptureOrigin({ | |
| x = valid_capture_pos(capture_center.x - canvasAdj.x, win_frame.w - canvasSize.w), | |
| y = valid_capture_pos(capture_center.y - canvasAdj.y, win_frame.h - canvasSize.h)}, | |
| canvasAdj) | |
| if hiliteCaptureArea then drawBoxAroundCaptureArea() end | |
| floater = hs.canvas.new({ | |
| x=floater_origin.x, | |
| y=floater_origin.y, | |
| h=canvasSize.h, | |
| w=canvasSize.w | |
| }) | |
| floater:level('overlay') -- allows floater to be positioned above dock/menubar | |
| floater[1] = { | |
| id = 'border', | |
| type = 'rectangle', | |
| action = 'stroke', | |
| strokeWidth = 4.0, | |
| strokeColor = { red=0, green=0, blue=0, alpha=0 }, | |
| antialias = false, | |
| frame = { x = "0%", y = "0%", h = "100%", w = "100%" } | |
| } | |
| refreshWin(i,true) | |
| floater:show() | |
| if _animate then | |
| strokeFloaterWithAnimation(floater) | |
| else | |
| strokeFloaterWithoutAnimation(floater) | |
| end | |
| timerTable['waitForAnimCompletion'] = hs.timer.waitUntil( | |
| function() return (floater and floater.strokeDashPattern == nil) end, | |
| function() | |
| if _moveOffscreenNow then moveWinOffscreen(true) end | |
| if not timerIsRunning('main') then startMainTimer() end | |
| end, 0.1) | |
| if floater and floater:elementCount() > 0 then | |
| activateHotkey('hk_speed', {'ctrl'}, main_key, cycleRefreshInt) | |
| activateHotkey('hk_destroy', {'shift'}, main_key, destroy_floater) | |
| activateHotkey('hk_togglewin', {'cmd'}, main_key, function() moveWinOffscreen(not hasBeenMovedOffscreen) end) | |
| floater:clickActivating(false):mouseCallback(floaterCallback) | |
| end | |
| end | |
| -- https://www.hammerspoon.org/docs/hs.canvas.html#mouseCallback | |
| function floaterCallback(_c, _m, _i, _x, _y) | |
| if _m == "mouseUp" then _stopCoroutine(_m) return end | |
| if _m == "mouseExit" then drawEscText() return end | |
| if _m == "mouseDown" then | |
| local _buttons = hs.eventtap.checkMouseButtons() | |
| local last_pos = hs.mouse.absolutePosition() | |
| if _buttons.left and checkForDoubleClick(hs.timer.absoluteTime(),last_pos) then | |
| moveWinOffscreen(not hasBeenMovedOffscreen) | |
| return | |
| end | |
| if _buttons.right then drawCrosshairs() end | |
| local initial_pos = last_pos | |
| local orig_capture_origin = capture_origin | |
| _cMouseAction = coroutine.wrap( | |
| function() | |
| activateHotkey('hk_esc', nil, 'escape', function() _stopCoroutine('hotkey') end) | |
| while _cMouseAction do | |
| local cur_pos = hs.mouse.absolutePosition() | |
| if mouseHasMoved(last_pos,cur_pos) then | |
| last_pos = cur_pos | |
| if _buttons.right and not _updatesPaused then -- move capture area | |
| local delta = { x=(to_int(cur_pos.x - initial_pos.x)), y=(to_int(cur_pos.y - initial_pos.y)) } | |
| -- print('delta: x:'..delta.x..' y:'..delta.y..' invert:'..tostring(invertScrollDirection)) | |
| if invertScrollDirection then delta.x, delta.y = -delta.x, -delta.y end | |
| _updateCaptureOrigin({ | |
| x = valid_capture_pos(orig_capture_origin.x + delta.x, win_frame.w - canvasSize.w), | |
| y = valid_capture_pos(orig_capture_origin.y + delta.y, win_frame.h - canvasSize.h)}, | |
| canvasAdj) | |
| refreshWin(i) | |
| elseif _buttons.left then -- move floater | |
| local frame = _c:frame() | |
| frame.x, frame.y = to_int(cur_pos.x - _x), to_int(cur_pos.y - _y) | |
| _c:frame(frame) | |
| end | |
| end | |
| coroutine.applicationYield() -- 0.001 ? | |
| end -- end of while _cMouseAction loop | |
| end) -- end of coroutine.wrap | |
| _cMouseAction() | |
| end | |
| end | |
| function screenEvent() | |
| if not floater then return end | |
| -- print('screenSaver:',screenSaver) | |
| -- print('screenLock: ',screenLock) | |
| -- print('screenSleep: ',screenSleep) | |
| if screenSaver or screenLock or screenSleep then | |
| -- print('pausing updates') | |
| pauseUpdates(true) | |
| else | |
| -- print('resuming updates') | |
| resumeUpdates(true) | |
| end | |
| end | |
| function floatWatcherCallback(event) | |
| -- print('floatWatcherCallback fired: '..tostring(event)) | |
| if event == hs.caffeinate.watcher.screensaverDidStart then | |
| screenSaver = true | |
| -- print('screensaverDidStart') | |
| elseif event == hs.caffeinate.watcher.screensDidLock then | |
| screenLock = true | |
| -- print('screensDidLock') | |
| elseif event == hs.caffeinate.watcher.screensDidSleep then | |
| screenSleep = true | |
| -- print('screensDidSleep') | |
| elseif event == hs.caffeinate.watcher.screensaverDidStop then | |
| screenSaver = false | |
| -- print('screensaverDidStop') | |
| elseif event == hs.caffeinate.watcher.screensDidUnlock then | |
| screenLock = false | |
| -- print('screensDidUnlock') | |
| elseif hs.caffeinate.watcher.screensDidWake then | |
| screenSleep = false | |
| -- print('screensDidWake') | |
| end | |
| screenEvent() | |
| end | |
| floatWatcher = hs.caffeinate.watcher.new(floatWatcherCallback) | |
| floatWatcher:start() | |
| activateHotkey('hk_create', nil, main_key, create_floater) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment