From 90b14a3df02dc39b41464532f0d5791b3aeb3711 Mon Sep 17 00:00:00 2001 From: Alexander Miller Date: Thu, 2 Apr 2026 12:02:07 -0400 Subject: [PATCH 1/2] Fix stack detection and icon lookup on newer macOS --- conf.lua | 15 +++- stackline/query.lua | 180 ++++++++++++++++++++++++++++++++++++++----- stackline/window.lua | 68 +++++++++++++--- 3 files changed, 232 insertions(+), 31 deletions(-) diff --git a/conf.lua b/conf.lua index 8b03835..68608d7 100644 --- a/conf.lua +++ b/conf.lua @@ -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 } diff --git a/stackline/query.lua b/stackline/query.lua index d5287f0..b96d111 100644 --- a/stackline/query.lua +++ b/stackline/query.lua @@ -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) -- {{{ @@ -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 @@ -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) @@ -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 @@ -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 (+/-) @@ -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) diff --git a/stackline/window.lua b/stackline/window.lua index 82a6eb5..023dda5 100644 --- a/stackline/window.lua +++ b/stackline/window.lua @@ -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 @@ -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) -- {{{ From 225dd7cba613b5a57e5d9b7934aa2bc3bc2ed764 Mon Sep 17 00:00:00 2001 From: Alexander Miller Date: Thu, 2 Apr 2026 12:08:47 -0400 Subject: [PATCH 2/2] Add a local Lua test harness --- Makefile | 4 + test/conf_spec.lua | 33 +++++++ test/helper.lua | 208 +++++++++++++++++++++++++++++++++++++++++ test/query_spec.lua | 218 +++++++++++++++++++++++++++++++++++++++++++ test/run.lua | 53 +++++++++++ test/window_spec.lua | 149 +++++++++++++++++++++++++++++ 6 files changed, 665 insertions(+) create mode 100644 Makefile create mode 100644 test/conf_spec.lua create mode 100644 test/helper.lua create mode 100644 test/query_spec.lua create mode 100644 test/run.lua create mode 100644 test/window_spec.lua diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ec82040 --- /dev/null +++ b/Makefile @@ -0,0 +1,4 @@ +.PHONY: test + +test: + lua test/run.lua diff --git a/test/conf_spec.lua b/test/conf_spec.lua new file mode 100644 index 0000000..d07452b --- /dev/null +++ b/test/conf_spec.lua @@ -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, +} diff --git a/test/helper.lua b/test/helper.lua new file mode 100644 index 0000000..0d53d96 --- /dev/null +++ b/test/helper.lua @@ -0,0 +1,208 @@ +local M = {} + +if not string.split then + function string:split(p) + local sep = p or "%s" + local parts = {} + local start = 1 + + while true do + local first, last = self:find(sep, start) + if not first then + table.insert(parts, self:sub(start)) + break + end + + table.insert(parts, self:sub(start, first - 1)) + start = last + 1 + end + + return parts + end +end + +local function is_array(tbl) + return type(tbl) == "table" and rawget(tbl, 1) ~= nil +end + +function M.noop() +end + +function M.logger() + return { + i = M.noop, + w = M.noop, + e = M.noop, + d = M.noop, + setLogLevel = M.noop, + } +end + +function M.basic_u() + local u = {} + + function u.each(tbl, fn) + for key, value in pairs(tbl or {}) do + fn(value, key) + end + end + + function u.map(tbl, fn) + local result = {} + + if is_array(tbl) then + for index, value in ipairs(tbl) do + result[index] = fn(value, index) + end + else + for key, value in pairs(tbl or {}) do + result[key] = fn(value, key) + end + end + + return result + end + + function u.filter(tbl, fn) + local result = {} + + if is_array(tbl) then + for _, value in ipairs(tbl) do + if fn(value) then + table.insert(result, value) + end + end + else + for key, value in pairs(tbl or {}) do + if fn(value, key) then + result[key] = value + end + end + end + + return result + end + + function u.groupBy(tbl, key_or_fn) + local result = {} + for _, value in ipairs(tbl or {}) do + local key = type(key_or_fn) == "function" + and key_or_fn(value) + or value[key_or_fn] + + if result[key] == nil then + result[key] = {} + end + table.insert(result[key], value) + end + return result + end + + function u.greaterThan(n) + return function(tbl) + return #tbl > n + end + end + + function u.length(tbl) + local count = 0 + for _ in pairs(tbl or {}) do + count = count + 1 + end + return count + end + + function u.values(tbl) + local values = {} + for _, value in pairs(tbl or {}) do + table.insert(values, value) + end + return values + end + + function u.task_cb(fn) + return function(stdout) + return fn(stdout) + end + end + + function u.partial(fn, ...) + local preset = {...} + return function(...) + local args = {} + for _, value in ipairs(preset) do + table.insert(args, value) + end + for _, value in ipairs({...}) do + table.insert(args, value) + end + return fn(table.unpack(args)) + end + end + + function u.roundToNearest(round_to, num) + return num - num % round_to + end + + function u.equal(a, b) + return M.deep_equal(a, b) + end + + function u.p(...) + return ... + end + + return u +end + +function M.count_entries(tbl) + local count = 0 + for _ in pairs(tbl or {}) do + count = count + 1 + end + return count +end + +function M.deep_equal(a, b) + if type(a) ~= type(b) then + return false + end + + if type(a) ~= "table" then + return a == b + end + + for key, value in pairs(a) do + if not M.deep_equal(value, b[key]) then + return false + end + end + + for key in pairs(b) do + if a[key] == nil then + return false + end + end + + return true +end + +function M.assert_equal(actual, expected, message) + if actual ~= expected then + error(message or string.format("expected %s, got %s", tostring(expected), tostring(actual)), 2) + end +end + +function M.assert_true(value, message) + if not value then + error(message or "expected condition to be true", 2) + end +end + +function M.reset_modules(...) + for _, module_name in ipairs({...}) do + package.loaded[module_name] = nil + end +end + +return M diff --git a/test/query_spec.lua b/test/query_spec.lua new file mode 100644 index 0000000..5349b8f --- /dev/null +++ b/test/query_spec.lua @@ -0,0 +1,218 @@ +local helper = require("test.helper") + +local function make_hs_window(id, stack_id, app_name) + return { + id = function() + return id + end, + stackId = stack_id, + stackIdFzy = stack_id, + appName = app_name or ("App" .. tostring(id)), + } +end + +local function install_query_env(opts) + local task_invocations = {} + local notifications = {} + local ingest_calls = {} + local hs_window_by_id = opts.hs_window_by_id or {} + + _G.u = helper.basic_u() + _G.c = { + features = { + fzyFrameDetect = { + enabled = false, + }, + }, + } + + _G.hs = { + logger = { + new = function() + return helper.logger() + end, + }, + fs = { + attributes = function(path, attr) + if attr == "mode" then + return (opts.path_modes or {})[path] + end + end, + }, + notify = { + new = function(_, attrs) + table.insert(notifications, attrs) + return { + send = helper.noop, + } + end, + }, + spaces = { + activeSpaces = function() + return opts.active_spaces or {} + end, + }, + window = { + get = function(id) + return hs_window_by_id[id] + end, + }, + task = { + new = function(path, callback, args) + local invocation = { + path = path, + args = args, + } + table.insert(task_invocations, invocation) + return { + start = function() + callback(opts.yabai_response) + return true + end, + } + end, + }, + } + + _G.stackline = { + config = { + get = function(_, path) + if path == "paths.yabai" then + return opts.configured_yabai_path + end + end, + }, + wf = { + getWindows = function() + return opts.filter_windows or {} + end, + }, + manager = { + getSummary = function(_, external) + if external then + return { + numStacks = helper.count_entries(external), + } + end + + return { + numStacks = opts.current_num_stacks or 0, + } + end, + ingest = function(_, stacks, by_app, should_clean) + table.insert(ingest_calls, { + stacks = stacks, + by_app = by_app, + should_clean = should_clean, + }) + end, + }, + window = { + new = function(_, hs_win) + local id = hs_win:id() + local stack_id = hs_win.stackId or hs_win.stackIdFzy or "stack" + return { + id = id, + app = hs_win.appName or ("App" .. tostring(id)), + stackId = stack_id, + stackIdFzy = hs_win.stackIdFzy or stack_id, + } + end, + }, + } + + helper.reset_modules("stackline.query") + local query = require("stackline.query") + + return { + query = query, + task_invocations = task_invocations, + notifications = notifications, + ingest_calls = ingest_calls, + } +end + +return { + test_query_resolves_directory_yabai_path = function() + local env = install_query_env({ + configured_yabai_path = "/opt/homebrew/opt/yabai", + path_modes = { + ["/opt/homebrew/opt/yabai"] = "directory", + ["/opt/homebrew/opt/yabai/bin/yabai"] = "file", + }, + filter_windows = { + make_hs_window(1, "stack-a", "Alpha"), + make_hs_window(2, "stack-a", "Beta"), + }, + yabai_response = { + { + id = 1, + ["stack-index"] = 1, + ["is-minimized"] = false, + ["is-hidden"] = false, + ["is-native-fullscreen"] = false, + subrole = "AXStandardWindow", + }, + { + id = 2, + ["stack-index"] = 2, + ["is-minimized"] = false, + ["is-hidden"] = false, + ["is-native-fullscreen"] = false, + subrole = "AXStandardWindow", + }, + }, + }) + + env.query.run({forceRedraw = true}) + + helper.assert_equal(env.task_invocations[1].path, "/opt/homebrew/opt/yabai/bin/yabai") + helper.assert_equal(helper.count_entries(env.ingest_calls[1].stacks), 1) + helper.assert_equal(env.ingest_calls[1].should_clean, false) + end, + + test_query_rebuilds_stack_from_yabai_ids_when_filter_misses_windows = function() + local env = install_query_env({ + configured_yabai_path = "/opt/homebrew/bin/yabai", + path_modes = { + ["/opt/homebrew/bin/yabai"] = "file", + }, + filter_windows = {}, + active_spaces = { + display1 = 7, + }, + hs_window_by_id = { + [11] = make_hs_window(11, "stack-z", "Safari"), + [12] = make_hs_window(12, "stack-z", "Firefox"), + }, + yabai_response = { + { + id = 11, + space = 7, + ["stack-index"] = 1, + ["is-minimized"] = false, + ["is-hidden"] = false, + ["is-native-fullscreen"] = false, + subrole = "AXStandardWindow", + }, + { + id = 12, + space = 7, + ["stack-index"] = 2, + ["is-minimized"] = false, + ["is-hidden"] = false, + ["is-native-fullscreen"] = false, + subrole = "AXStandardWindow", + }, + }, + }) + + env.query.run({forceRedraw = true}) + + helper.assert_equal(helper.count_entries(env.ingest_calls[1].stacks), 1) + local stack = env.ingest_calls[1].stacks["stack-z"] + helper.assert_true(stack ~= nil, "expected fallback stack to be rebuilt from yabai window ids") + helper.assert_equal(#stack, 2) + helper.assert_true(env.ingest_calls[1].by_app.Safari ~= nil, "expected app grouping to be rebuilt too") + end, +} diff --git a/test/run.lua b/test/run.lua new file mode 100644 index 0000000..1a1c923 --- /dev/null +++ b/test/run.lua @@ -0,0 +1,53 @@ +local root = assert(os.getenv("PWD"), "PWD must be set when running tests") +package.path = table.concat({ + root .. "/?.lua", + root .. "/?/init.lua", + root .. "/test/?.lua", + package.path, +}, ";") + +local suites = { + "test.conf_spec", + "test.query_spec", + "test.window_spec", +} + +local total = 0 +local failures = {} + +for _, suite_name in ipairs(suites) do + local suite = require(suite_name) + local test_names = {} + + for name in pairs(suite) do + if name:match("^test_") then + table.insert(test_names, name) + end + end + + table.sort(test_names) + + for _, test_name in ipairs(test_names) do + total = total + 1 + io.write(string.format("RUN %s.%s\n", suite_name, test_name)) + local ok, err = xpcall(suite[test_name], debug.traceback) + if ok then + io.write("PASS\n") + else + io.write("FAIL\n") + table.insert(failures, { + name = suite_name .. "." .. test_name, + error = err, + }) + end + end +end + +io.write(string.format("\n%d tests, %d failures\n", total, #failures)) + +if #failures > 0 then + for _, failure in ipairs(failures) do + io.write(string.format("\n[%s]\n%s\n", failure.name, failure.error)) + end + os.exit(1) +end diff --git a/test/window_spec.lua b/test/window_spec.lua new file mode 100644 index 0000000..babcbcc --- /dev/null +++ b/test/window_spec.lua @@ -0,0 +1,149 @@ +local helper = require("test.helper") + +local function make_frame(x, y, w, h) + local frame = {x = x, y = y, w = w, h = h} + function frame:floor() + return self + end + return frame +end + +local function make_app(opts) + return { + name = function() + return opts.name + end, + bundleID = function() + return opts.bundle_id + end, + path = function() + return opts.path + end, + } +end + +local function make_window(app) + return { + title = function() + return "Example" + end, + application = function() + return app + end, + id = function() + return 99 + end, + frame = function() + return make_frame(0, 0, 400, 300) + end, + screen = function() + return { + id = function() + return 1 + end, + } + end, + } +end + +local function load_window_module(opts) + local bundle_calls = 0 + local file_calls = 0 + local app_lookup_calls = 0 + + _G.u = helper.basic_u() + _G.stackline = { + config = { + get = function(_, path) + if path == "features.fzyFrameDetect.fuzzFactor" then + return 30 + end + end, + }, + } + _G.hs = { + logger = { + new = function() + return helper.logger() + end, + }, + image = { + imageFromAppBundle = function(bundle_id) + bundle_calls = bundle_calls + 1 + if opts.bundle_images then + return opts.bundle_images[bundle_id] + end + end, + iconForFile = function(path) + file_calls = file_calls + 1 + if opts.file_images then + return opts.file_images[path] + end + end, + }, + application = { + get = function(name) + app_lookup_calls = app_lookup_calls + 1 + return opts.named_apps and opts.named_apps[name] + end, + }, + } + + helper.reset_modules("stackline.window") + local Window = require("stackline.window") + + return { + Window = Window, + bundle_calls = function() + return bundle_calls + end, + file_calls = function() + return file_calls + end, + app_lookup_calls = function() + return app_lookup_calls + end, + } +end + +return { + test_icon_lookup_uses_live_app_bundle_and_caches_result = function() + local env = load_window_module({ + bundle_images = { + ["com.example.Test"] = "bundle-image", + }, + }) + local app = make_app({ + name = "TestApp", + bundle_id = "com.example.Test", + path = "/Applications/Test.app", + }) + + local window = env.Window:new(make_window(app)) + helper.assert_equal(window:iconFromAppName(), "bundle-image") + helper.assert_equal(window:iconFromAppName(), "bundle-image") + helper.assert_equal(env.bundle_calls(), 1) + helper.assert_equal(env.app_lookup_calls(), 0) + end, + + test_icon_lookup_falls_back_to_named_application_lookup = function() + local env = load_window_module({ + bundle_images = { + ["com.apple.Safari"] = "safari-image", + }, + named_apps = { + Safari = make_app({ + name = "Safari", + bundle_id = "com.apple.Safari", + }), + }, + }) + local app = make_app({ + name = "Safari", + }) + + local window = env.Window:new(make_window(app)) + helper.assert_equal(window:iconFromAppName(), "safari-image") + helper.assert_equal(env.app_lookup_calls(), 1) + end, +}