Skip to content

Instantly share code, notes, and snippets.

@philsnow
Created February 8, 2023 10:32
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save philsnow/d6ba054f57e2d304f0d4d18f247cf344 to your computer and use it in GitHub Desktop.
Save philsnow/d6ba054f57e2d304f0d4d18f247cf344 to your computer and use it in GitHub Desktop.
simple_switcher.lua: simple mac window switcher with hammerspoon

simple_switcher.lua

I've been using numbered Spaces a lot more lately, and it's helpful for me to only see the apps/windows that I'm currently focused on, instead of all 30 apps I happen to have open. I wanted something similar to hs.window.switcher, but I don't care about window thumbnails and I wanted to be able to see more than just a few characters of the window title.

I cribbed heavily from the window_switcher.lua source but removed a lot of configurability, logging, backwards compatibility, and other good ideas.

Screencast/demo: (I have this new switcher bound to option+tab, and can still use the regular macos cmd-tab switcher)

simple_switcher = require("simple_switcher")
switcher = simple_switcher.new({title_width = 1000, item_height = 32,})
local function mapAltTab(event)
local flags = event:getFlags()
local chars = event:getCharacters()
if chars == "\t" and flags:containExactly{'alt'} then
switcher:next()
return true
elseif chars == string.char(25) and flags:containExactly{'alt','shift'} then
switcher:previous()
return true
end
end
tapAltTab = hs.eventtap.new({hs.eventtap.event.types.keyDown}, mapAltTab)
tapAltTab:start()
-- TODO: uses hs.window.filter right now but would it be better to watch window creation / deletion / space-moving / etc with hs.uielement.watcher and keep some internal table up to date with that?
-- TODO: notice when a window gets sent to another space and rebuild the window list
local hsdrawing = require('hs.drawing')
local hseventtap = require('hs.eventtap')
local hsgeom = require('hs.geometry')
local hsimage = require('hs.image')
local hsscreen = require('hs.screen')
local hstimer = require('hs.timer')
local min = math.min
local max = math.max
local checkMods = hseventtap.checkKeyboardModifiers
hs.window.animationDuration = 0
local UNAVAILABLE=hsimage.imageFromName('NSStopProgressTemplate')
local simple_switcher = {}
local icons=setmetatable({},{__mode='kv'})
local function getIcon(bundle)
if not bundle then return UNAVAILABLE
elseif not icons[bundle] then icons[bundle]=hsimage.imageFromAppBundle(bundle) or UNAVAILABLE end
return icons[bundle]
end
local function rgba(r, g, b, a) return {red = r, green = g, blue = b, alpha = a} end
local function rgb(r, g, b, a) return rgba(r, g, b, 1) end
local function gc(self)
self.screen_watcher:stop()
end
local function set_frames(nwindows, drawings, ui)
if not drawings.screen_frame then
drawings.screen_frame = hsscreen.mainScreen():frame()
end
local screen_frame = drawings.screen_frame
local padding = ui.item_height * 0.1
local size = min(
ui.item_height,
(screen_frame.h - padding*(nwindows+1))/nwindows
)
drawings.size = size
-- TODO: pin to some corner (top-left) + padding, or center?
-- TODO: expand in x to longest title?
drawings.background = hsdrawing.rectangle(hsgeom(0,0,1,1))
local bg_frame = hsgeom(0,0,
size+padding+ui.title_width+padding,
(size+padding)*nwindows+padding)
:setcenter(screen_frame.center)
drawings.background
:setFillColor(ui.background_color)
:setStroke(false)
:setFrame(bg_frame)
:setRoundedRectRadii(padding, padding)
drawings.highlight_rect = hsdrawing.rectangle(hsgeom(0,0,1,1))
:setFillColor(ui.highlight_color)
:setStroke(false)
:setRoundedRectRadii(padding, padding)
local title_height = hsdrawing.getTextDrawingSize(
'O', ui.title_text_style).h
for i = 1,nwindows do
local dr = drawings[i]
local icon_frame = hsgeom(bg_frame.x, bg_frame.y, size, size)
:move(padding, padding + (size+padding)*(i-1))
dr.icon:setFrame(icon_frame)
dr.title_frame = hsgeom.copy(icon_frame)
:move(padding*2 + size, max(0,size - title_height)/2)
:setw(ui.title_width)
local sel_frame = hsgeom.copy(icon_frame)
:setw(size + ui.title_width)
dr.sel_frame = sel_frame
end
end
local function show_selected(selected, drawings)
local dr = drawings[selected]
drawings.highlight_rect:setFrame(dr.sel_frame)
end
local function draw(windows, drawings, ui)
drawings.background:show()
drawings.highlight_rect:show()
for i = 1,#windows do
local win = windows[i]
local dr = drawings[i]
dr.icon:setImage(getIcon(win:application():bundleID())):show()
local title = win:title() or ' '
dr.title_text:setFrame(dr.title_frame)
dr.title_text:setText(title):show()
end
end
local MODS_INTERVAL=0.05 -- recheck for (lack of) mod keys after this interval
local function mods_pressed()
local mods = checkMods(true)._raw
return mods>0 and mods ~= 65536 -- caps lock
end
local function exit(self)
local selected = self.selected
local windows = self.windows
local drawings = self.drawings
local ui = self.ui
self.windows = nil
self.selected = nil
self.modsTimer = nil
drawings.background:hide()
drawings.highlight_rect:hide()
for i = 1,#windows do
local dr = drawings[i]
dr.icon:hide()
dr.title_text:hide()
end
windows[selected]:focus()
end
local function show(self, direction)
local windows = self.windows
local drawings = self.drawings
local ui = self.ui
if not windows then
windows = self.window_filter:getWindows(hs.window.filter.sortByFocusedLast)
self.windows = windows
end
local nwindows = #windows or 0
if nwindows == 0 then return end
self.ui.title_text_style = {
font = ui.font_name,
size = ui.font_size,
color = ui.text_color,
lineBreak = 'truncateTail',
}
local selected = self.selected
if not selected then
local temp_frame = hsgeom(0,0,1,1)
for n = 1,nwindows do
local dr = {}
dr.icon = hsdrawing.image(temp_frame, UNAVAILABLE)
dr.title_rect = hsdrawing.rectangle(temp_frame)
:setFillColor(ui.title_background_color)
:setStroke(false)
dr.title_text = hsdrawing.text(temp_frame, ' ')
:setTextStyle(self.ui.title_text_style)
drawings[n] = dr
end
set_frames(nwindows, drawings, ui)
draw(windows, drawings, ui)
selected = 1
self.modsTimer = hstimer.waitWhile(
mods_pressed,
function() exit(self) end,
MODS_INTERVAL
)
end
selected = selected + direction
if selected <= 0 then
selected = nwindows
elseif selected > nwindows then
selected = 1
end
self.selected = selected
show_selected(selected, drawings)
end
function simple_switcher:next() return show(self, 1) end
function simple_switcher:previous() return show(self, -1) end
local ui_defaults = {
text_color = rgb(1,1,1),
font_name = "Monaco",
font_size = 22,
background_color = rgba(0.2, 0.2, 0.08, 0.8),
highlight_color = rgba(0.6, 0.6, 0.3, 0.5),
title_background_color = rgb(0,0,0),
item_height = 64,
title_width = 600,
-- TODO: thumbnails
}
function simple_switcher.new(ui)
if not ui then ui = {} end
local self = setmetatable(
{drawings={}},
{__index=simple_switcher,__gc=gc}
)
self.window_filter = hs.window.filter.new():setCurrentSpace(true)
self.drawings.screen_frame = nil
self.screen_watcher = hs.screen.watcher.new(
function() self.drawings.screen_frame = nil end
):start()
self.ui = setmetatable(ui, {__index=ui_defaults})
return self
end
return simple_switcher
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment