Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.PHONY: test

test:
lua test/run.lua
15 changes: 14 additions & 1 deletion conf.lua
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,21 @@ c.appearance = {}
c.features = {}
c.advanced = {}

local function defaultYabaiPath() -- {{{
for _, path in ipairs({
'/opt/homebrew/bin/yabai',
'/usr/local/bin/yabai',
}) do
if hs.fs.attributes(path, 'mode') == 'file' then
return path
end
end

return '/usr/local/bin/yabai'
end -- }}}

-- Paths
c.paths.yabai = '/usr/local/bin/yabai'
c.paths.yabai = defaultYabaiPath()

-- Appearance
c.appearance.color = { white = 0.90 } -- Indicator background color, e.g., {red = 0.5, blue = 0 }
Expand Down
180 changes: 161 additions & 19 deletions stackline/query.lua
Original file line number Diff line number Diff line change
@@ -1,15 +1,82 @@
local log = hs.logger.new('query', 'info')
log.i('Loading module: query')

local warned = {}

local function notifyOnce(key, title, text) -- {{{
if warned[key] then return end
warned[key] = true
hs.notify.new(nil, {
title = title,
informativeText = text,
withdrawAfter = 10,
}):send()
end -- }}}

local function pathMode(path) -- {{{
return path and hs.fs.attributes(path, 'mode')
end -- }}}

local function resolveYabaiPath() -- {{{
local configuredPath = stackline.config:get'paths.yabai'
local candidates = {}

if configuredPath then
table.insert(candidates, configuredPath)
if pathMode(configuredPath) == 'directory' then
table.insert(candidates, configuredPath .. '/bin/yabai')
end
end

u.each({
'/opt/homebrew/bin/yabai',
'/usr/local/bin/yabai',
'/opt/homebrew/opt/yabai/bin/yabai',
'/usr/local/opt/yabai/bin/yabai',
}, function(path)
table.insert(candidates, path)
end)

for _, path in ipairs(candidates) do
if pathMode(path) == 'file' then
if configuredPath and path ~= configuredPath then
local msg = ('Configured path "%s" is not executable; using "%s" instead.'):format(
configuredPath,
path
)
log.w(msg)
notifyOnce('resolvedYabaiPath', 'Stackline adjusted yabai path', msg)
end
return path
end
end

local msg = configuredPath
and ('Configured path "%s" is not an executable yabai binary.'):format(configuredPath)
or 'No yabai path is configured and no default install location was found.'

log.e(msg)
notifyOnce('missingYabaiPath', 'Stackline cannot find yabai', msg)
end -- }}}

local function yabai(command, callback) -- {{{
callback = callback or function(x) return x end
command = '-m ' .. command

hs.task.new(
stackline.config:get'paths.yabai',
local yabaiPath = resolveYabaiPath()
if not yabaiPath then return end

local task = hs.task.new(
yabaiPath,
u.task_cb(callback), -- wrap callback in json decoder
command:split(' ')
):start()
)

if not task or not task:start() then
local msg = ('Unable to launch yabai using "%s".'):format(yabaiPath)
log.e(msg)
notifyOnce('launchYabaiFailed', 'Stackline could not run yabai', msg)
end
end -- }}}

local function stackIdMapper(yabaiWindow) -- {{{
Expand All @@ -34,24 +101,14 @@ local function getStackedWinIds(byStack) -- {{{
return stackedWinIds
end -- }}}

local function groupWindows(ws) -- {{{
-- Given windows from hs.window.filter:
-- 1. Create stackline window objects
-- 2. Group wins by `stackId` prop (aka top-left frame coords)
-- 3. If at least one such group, also group wins by app (to workaround hs bug unfocus event bug)
local byStack
local function groupStacklineWindows(windows) -- {{{
local byApp

local windows = u.map(ws, function(w)
return stackline.window:new(w)
end)

-- See 'stackId' def @ /window.lua:233
local groupKey = c.features.fzyFrameDetect.enabled
and 'stackIdFzy'
or 'stackId'

byStack = u.filter(
local byStack = u.filter(
u.groupBy(windows, groupKey),
u.greaterThan(1)) -- stacks have >1 window, so ignore 'groups' of 1

Expand All @@ -67,6 +124,20 @@ local function groupWindows(ws) -- {{{
return byStack, byApp
end -- }}}

local function groupWindows(ws) -- {{{
-- Given windows from hs.window.filter:
-- 1. Create stackline window objects
-- 2. Group wins by `stackId` prop (aka top-left frame coords)
-- 3. If at least one such group, also group wins by app (to workaround hs bug unfocus event bug)
local windows = {}

for _, hsWin in pairs(ws or {}) do
table.insert(windows, stackline.window:new(hsWin))
end

return groupStacklineWindows(windows)
end -- }}}

local function removeGroupedWin(win, byStack) -- {{{
-- remove given window if it's present in byStack windows
return u.map(byStack, function(stack)
Expand All @@ -81,10 +152,11 @@ local function mergeWinStackIdxs(byStack, winStackIdxs) -- {{{

local function assignStackIndex(win)
local stackIdx = winStackIdxs[tostring(win.id)]
if stackIdx == 0 then
if not stackIdx or stackIdx == 0 then
-- Remove windows with stackIdx == 0. Such windows overlap exactly with
-- other (potentially stacked) windows, and so are grouped with them,
-- but they are NOT stacked according to yabai.
-- but they are NOT stacked according to yabai. Missing entries are
-- also treated as unstacked, since yabai is the source of truth.
-- Windows that belong to a *real* stack have stackIdx > 0.
byStack = removeGroupedWin(win, byStack)
end
Expand All @@ -101,6 +173,62 @@ local function mergeWinStackIdxs(byStack, winStackIdxs) -- {{{

end -- }}}

local function countStackWindows(byStack) -- {{{
local count = 0
for _, stack in pairs(byStack or {}) do
count = count + #stack
end
return count
end -- }}}

local function activeSpaceLookup() -- {{{
local lookup = {}
local activeSpaces = hs.spaces.activeSpaces() or {}

for _, spaceId in pairs(activeSpaces) do
lookup[spaceId] = true
lookup[tostring(spaceId)] = true
end

return lookup
end -- }}}

local function getActiveStackedYabaiWindows(yabaiWindows) -- {{{
local activeSpaces = activeSpaceLookup()
local restrictToActiveSpaces = next(activeSpaces) ~= nil

return u.filter(yabaiWindows or {}, function(win)
return type(win) == 'table'
and (win['stack-index'] or 0) > 0
and win['is-minimized'] == false
and win['is-hidden'] == false
and win['is-native-fullscreen'] == false
and win['subrole'] == 'AXStandardWindow'
and (
not restrictToActiveSpaces
or activeSpaces[win.space]
or activeSpaces[tostring(win.space)]
)
end)
end -- }}}

local function groupWindowsFromYabai(yabaiWindows) -- {{{
local windows = {}

for _, yabaiWin in pairs(getActiveStackedYabaiWindows(yabaiWindows)) do
local hsWin = hs.window.get(yabaiWin.id)
if hsWin then
local win = stackline.window:new(hsWin)
win.stackIdx = yabaiWin['stack-index']
table.insert(windows, win)
else
log.w(('Unable to resolve hs.window for yabai window %s'):format(yabaiWin.id))
end
end

return groupStacklineWindows(windows)
end -- }}}

local function shouldRestack(new) -- {{{
-- Analyze byStack to determine if a stack refresh is needed
-- • change num stacks (+/-)
Expand Down Expand Up @@ -146,9 +274,23 @@ local function run(opts) -- {{{

yabai(yabai_cmd, function(yabaiRes)
local winStackIdxs = stackIdMapper(yabaiRes)
local activeStackedWins = getActiveStackedYabaiWindows(yabaiRes)
local mergedStacks = mergeWinStackIdxs(byStack, winStackIdxs)
local mergedAppWins = byApp

if countStackWindows(mergedStacks) < #activeStackedWins then
log.w('yabai reports stacked windows on an active space, but hs.window.filter did not expose all of them; falling back to yabai window IDs')
notifyOnce(
'yabaiWindowFallback',
'Stackline is using a yabai fallback',
'Hammerspoon did not expose all stacked windows, so Stackline rebuilt the stack from yabai window IDs.'
)
mergedStacks, mergedAppWins = groupWindowsFromYabai(yabaiRes)
end

stackline.manager:ingest( -- hand over to stackmanager
mergeWinStackIdxs(byStack, winStackIdxs), -- Add the stack indexes from yabai to byStack
byApp,
mergedStacks, -- Add the stack indexes from yabai to byStack
mergedAppWins,
spaceHasStacks
)
end)
Expand Down
68 changes: 57 additions & 11 deletions stackline/window.lua
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,26 @@ log.i('Loading module: window')
local Window = {}

function Window:new(hsWin) -- {{{
local appObj = hsWin:application()
local appName = appObj and appObj:name()
local appBundle = appObj and appObj:bundleID()
local appPath = appObj and appObj:path()
local stackIdResult = self:makeStackId(hsWin)
local ws = {
title = hsWin:title(), -- window title
app = hsWin:application():name(), -- app name (string)
id = hsWin:id(), -- window id (string) NOTE: HS win.id == yabai win.id
frame = hsWin:frame(), -- x,y,w,h of window (table)
stackId = stackIdResult.stackId, -- "{{x}|{y}|{w}|{h}" e.g., "35|63|1185|741" (string)
topLeft = stackIdResult.topLeft, -- "{{x}|{y}" e.g., "35|63" (string)
stackIdFzy = stackIdResult.fzyFrame, -- "{{x}|{y}" e.g., "35|63" (string)
_win = hsWin, -- hs.window object (table)
title = hsWin:title(), -- window title
app = appName or appBundle or appPath or ('window:' .. hsWin:id()),
appName = appName,
appBundle = appBundle,
appPath = appPath,
id = hsWin:id(), -- window id (string) NOTE: HS win.id == yabai win.id
frame = hsWin:frame(), -- x,y,w,h of window (table)
stackId = stackIdResult.stackId, -- "{{x}|{y}|{w}|{h}" e.g., "35|63|1185|741" (string)
topLeft = stackIdResult.topLeft, -- "{{x}|{y}" e.g., "35|63" (string)
stackIdFzy = stackIdResult.fzyFrame, -- "{{x}|{y}" e.g., "35|63" (string)
_win = hsWin, -- hs.window object (table)
screen = hsWin:screen():id(),
indicator = nil, -- the canvas element (table)
indicator = nil, -- the canvas element (table)
iconImage = nil,
}
setmetatable(ws, self)
self.__index = self
Expand Down Expand Up @@ -319,8 +327,46 @@ function Window:getShadowAttrs() -- {{{
end -- }}}

function Window:iconFromAppName() -- {{{
appBundle = hs.appfinder.appFromName(self.app):bundleID()
return hs.image.imageFromAppBundle(appBundle)
if self.iconImage ~= nil then
return self.iconImage or nil
end

local function imageFromApp(app)
if not app then return nil end

local bundleID = app:bundleID()
if bundleID then
local image = hs.image.imageFromAppBundle(bundleID)
if image then return image end
end

local appPath = app:path()
if appPath then
local image = hs.image.iconForFile(appPath)
if image then return image end
end
end

local image = imageFromApp(self._win:application())

if not image and self.appBundle then
image = hs.image.imageFromAppBundle(self.appBundle)
end

if not image and self.appPath then
image = hs.image.iconForFile(self.appPath)
end

if not image and self.appName then
image = imageFromApp(hs.application.get(self.appName))
end

if not image then
log.w(('Unable to resolve icon for window %s (%s)'):format(self.id, self.app))
end

self.iconImage = image or false
return image
end -- }}}

function Window:makeStackId(hsWin) -- {{{
Expand Down
33 changes: 33 additions & 0 deletions test/conf_spec.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
local helper = require("test.helper")

local function load_conf(attributes)
_G.hs = {
fs = {
attributes = function(path, attr)
if attr == "mode" then
return attributes[path]
end
end,
},
}
_G.c = nil
return dofile("conf.lua")
end

return {
test_conf_prefers_homebrew_arm_path = function()
local conf = load_conf({
["/opt/homebrew/bin/yabai"] = "file",
})

helper.assert_equal(conf.paths.yabai, "/opt/homebrew/bin/yabai")
end,

test_conf_falls_back_to_usr_local = function()
local conf = load_conf({
["/usr/local/bin/yabai"] = "file",
})

helper.assert_equal(conf.paths.yabai, "/usr/local/bin/yabai")
end,
}
Loading