From ba70ea8a02ed09b383fcac522df9b1123c8baa15 Mon Sep 17 00:00:00 2001 From: adamwagner Date: Sun, 2 Aug 2020 07:56:55 -0700 Subject: [PATCH 01/33] format: auto-formatted per luaformat config. Functions folded with 'set fdm=marker' in vim --- utils/table-utils.lua | 1350 +++++++++++++++++++++-------------------- 1 file changed, 692 insertions(+), 658 deletions(-) diff --git a/utils/table-utils.lua b/utils/table-utils.lua index 7915f53..3f21174 100644 --- a/utils/table-utils.lua +++ b/utils/table-utils.lua @@ -13,189 +13,199 @@ local setmetatable = setmetatable local M = {} package.loaded[...] = M if setfenv and type(setfenv) == "function" then - setfenv(1,M) -- Lua 5.1 + setfenv(1, M) -- Lua 5.1 else - _ENV = M -- Lua 5.2+ + _ENV = M -- Lua 5.2+ end _VERSION = "1.20.07.18" - -- Function to convert a table to a string -- Metatables not followed -- Unless key is a number it will be taken and converted to a string -function t2s(t) - -- local levels = 0 - local rL = {cL = 1} -- Table to track recursion into nested tables (cL = current recursion level) - rL[rL.cL] = {} - local result = {} - do - rL[rL.cL]._f,rL[rL.cL]._s,rL[rL.cL]._var = pairs(t) - --result[#result + 1] = "{\n"..string.rep("\t",levels+1) - result[#result + 1] = "{" -- Non pretty version - rL[rL.cL].t = t - while true do - local k,v = rL[rL.cL]._f(rL[rL.cL]._s,rL[rL.cL]._var) - rL[rL.cL]._var = k - if k==nil and rL.cL == 1 then - break - elseif k==nil then - -- go up in recursion level - -- If condition for pretty printing - -- if result[#result]:sub(-1,-1) == "," then - -- result[#result] = result[#result]:sub(1,-3) -- remove the tab and the comma - -- else - -- result[#result] = result[#result]:sub(1,-2) -- just remove the tab - -- end - result[#result + 1] = "}," -- non pretty version - -- levels = levels - 1 - rL.cL = rL.cL - 1 - rL[rL.cL+1] = nil - --rL[rL.cL].str = rL[rL.cL].str..",\n"..string.rep("\t",levels+1) - else - -- Handle the key and value here - if type(k) == "number" or type(k) == "boolean" then - result[#result + 1] = "["..tostring(k).."]=" - elseif type(k) == "table" then - result[#result + 1] = "["..t2s(k).."]=" - else - local kp = tostring(k) - if kp:match([["]]) then - result[#result + 1] = "["..[[']]..kp..[[']].."]=" - else - result[#result + 1] = "["..[["]]..kp..[["]].."]=" - end - end - if type(v) == "table" then - -- Check if this is not a recursive table - local goDown = true - for i = 1, rL.cL do - if v==rL[i].t then - -- This is recursive do not go down - goDown = false - break - end - end - if goDown then - -- Go deeper in recursion - -- levels = levels + 1 - rL.cL = rL.cL + 1 - rL[rL.cL] = {} - rL[rL.cL]._f,rL[rL.cL]._s,rL[rL.cL]._var = pairs(v) - --result[#result + 1] = "{\n"..string.rep("\t",levels+1) - result[#result + 1] = "{" -- non pretty version - rL[rL.cL].t = v - else - --result[#result + 1] = "\""..tostring(v).."\",\n"..string.rep("\t",levels+1) - result[#result + 1] = "\""..tostring(v).."\"," -- non pretty version - end - elseif type(v) == "number" or type(v) == "boolean" then - --result[#result + 1] = tostring(v)..",\n"..string.rep("\t",levels+1) - result[#result + 1] = tostring(v).."," -- non pretty version - else - --result[#result + 1] = string.format("%q",tostring(v))..",\n"..string.rep("\t",levels+1) - result[#result + 1] = string.format("%q",tostring(v)).."," -- non pretty version - end -- if type(v) == "table" then ends - end -- if not rL[rL.cL]._var and rL.cL == 1 then ends - end -- while true ends here - end -- do ends - -- If condition for pretty printing - -- if result[#result]:sub(-1,-1) == "," then - -- result[#result] = result[#result]:sub(1,-3) -- remove the tab and the comma - -- else - -- result[#result] = result[#result]:sub(1,-2) -- just remove the tab - -- end - result[#result + 1] = "}" -- non pretty version - return table.concat(result) -end +function t2s(t) -- {{{ + -- local levels = 0 + local rL = {cL = 1} -- Table to track recursion into nested tables (cL = current recursion level) + rL[rL.cL] = {} + local result = {} + do + rL[rL.cL]._f, rL[rL.cL]._s, rL[rL.cL]._var = pairs(t) + -- result[#result + 1] = "{\n"..string.rep("\t",levels+1) + result[#result + 1] = "{" -- Non pretty version + rL[rL.cL].t = t + while true do + local k, v = rL[rL.cL]._f(rL[rL.cL]._s, rL[rL.cL]._var) + rL[rL.cL]._var = k + if k == nil and rL.cL == 1 then + break + elseif k == nil then + -- go up in recursion level + -- If condition for pretty printing + -- if result[#result]:sub(-1,-1) == "," then + -- result[#result] = result[#result]:sub(1,-3) -- remove the tab and the comma + -- else + -- result[#result] = result[#result]:sub(1,-2) -- just remove the tab + -- end + result[#result + 1] = "}," -- non pretty version + -- levels = levels - 1 + rL.cL = rL.cL - 1 + rL[rL.cL + 1] = nil + -- rL[rL.cL].str = rL[rL.cL].str..",\n"..string.rep("\t",levels+1) + else + -- Handle the key and value here + if type(k) == "number" or type(k) == "boolean" then + result[#result + 1] = "[" .. tostring(k) .. "]=" + elseif type(k) == "table" then + result[#result + 1] = "[" .. t2s(k) .. "]=" + else + local kp = tostring(k) + if kp:match([["]]) then + result[#result + 1] = + "[" .. [[']] .. kp .. [[']] .. "]=" + else + result[#result + 1] = + "[" .. [["]] .. kp .. [["]] .. "]=" + end + end + if type(v) == "table" then + -- Check if this is not a recursive table + local goDown = true + for i = 1, rL.cL do + if v == rL[i].t then + -- This is recursive do not go down + goDown = false + break + end + end + if goDown then + -- Go deeper in recursion + -- levels = levels + 1 + rL.cL = rL.cL + 1 + rL[rL.cL] = {} + rL[rL.cL]._f, rL[rL.cL]._s, rL[rL.cL]._var = pairs(v) + -- result[#result + 1] = "{\n"..string.rep("\t",levels+1) + result[#result + 1] = "{" -- non pretty version + rL[rL.cL].t = v + else + -- result[#result + 1] = "\""..tostring(v).."\",\n"..string.rep("\t",levels+1) + result[#result + 1] = "\"" .. tostring(v) .. "\"," -- non pretty version + end + elseif type(v) == "number" or type(v) == "boolean" then + -- result[#result + 1] = tostring(v)..",\n"..string.rep("\t",levels+1) + result[#result + 1] = tostring(v) .. "," -- non pretty version + else + -- result[#result + 1] = string.format("%q",tostring(v))..",\n"..string.rep("\t",levels+1) + result[#result + 1] = + string.format("%q", tostring(v)) .. "," -- non pretty version + end -- if type(v) == "table" then ends + end -- if not rL[rL.cL]._var and rL.cL == 1 then ends + end -- while true ends here + end -- do ends + -- If condition for pretty printing + -- if result[#result]:sub(-1,-1) == "," then + -- result[#result] = result[#result]:sub(1,-3) -- remove the tab and the comma + -- else + -- result[#result] = result[#result]:sub(1,-2) -- just remove the tab + -- end + result[#result + 1] = "}" -- non pretty version + return table.concat(result) +end -- }}} -- Function to convert a table to a string with indentation for pretty printing -- Metatables not followed -- Unless key is a number it will be taken and converted to a string -function t2spp(t) - local levels = 0 - local rL = {cL = 1} -- Table to track recursion into nested tables (cL = current recursion level) - rL[rL.cL] = {} - local result = {} - do - rL[rL.cL]._f,rL[rL.cL]._s,rL[rL.cL]._var = pairs(t) - result[#result + 1] = "{\n"..string.rep("\t",levels+1) - --result[#result + 1] = "{" -- Non pretty version - rL[rL.cL].t = t - while true do - local k,v = rL[rL.cL]._f(rL[rL.cL]._s,rL[rL.cL]._var) - rL[rL.cL]._var = k - if k == nil and rL.cL == 1 then - break - elseif k == nil then - -- go up in recursion level - -- If condition for pretty printing - if result[#result]:sub(-1,-1) == "," then - result[#result] = result[#result]:sub(1,-3) -- remove the tab and the comma - else - result[#result] = result[#result]:sub(1,-2) -- just remove the tab - end - --result[#result + 1] = "}," -- non pretty version - levels = levels - 1 - rL.cL = rL.cL - 1 - rL[rL.cL+1] = nil - result[#result + 1] = "},\n"..string.rep("\t",levels+1) -- for pretty printing - else - -- Handle the key and value here - if type(k) == "number" or type(k) == "boolean" then - result[#result + 1] = "["..tostring(k).."]=" - elseif type(k) == "table" then - result[#result + 1] = "["..t2spp(k).."]=" - else - local kp = tostring(k) - if kp:match([["]]) then - result[#result + 1] = "["..[[']]..kp..[[']].."]=" - else - result[#result + 1] = "["..[["]]..kp..[["]].."]=" - end - end - if type(v) == "table" then - -- Check if this is not a recursive table - local goDown = true - for i = 1, rL.cL do - if v==rL[i].t then - -- This is recursive do not go down - goDown = false - break - end - end - if goDown then - -- Go deeper in recursion - levels = levels + 1 - rL.cL = rL.cL + 1 - rL[rL.cL] = {} - rL[rL.cL]._f,rL[rL.cL]._s,rL[rL.cL]._var = pairs(v) - result[#result + 1] = "{\n"..string.rep("\t",levels+1) -- For pretty printing - --result[#result + 1] = "{" -- non pretty version - rL[rL.cL].t = v - else - result[#result + 1] = "\""..tostring(v).."\",\n"..string.rep("\t",levels+1) -- For pretty printing - --result[#result + 1] = "\""..tostring(v).."\"," -- non pretty version - end - elseif type(v) == "number" or type(v) == "boolean" then - result[#result + 1] = tostring(v)..",\n"..string.rep("\t",levels+1) -- For pretty printing - --result[#result + 1] = tostring(v).."," -- non pretty version - else - result[#result + 1] = string.format("%q",tostring(v))..",\n"..string.rep("\t",levels+1) -- For pretty printing - --result[#result + 1] = string.format("%q",tostring(v)).."," -- non pretty version - end -- if type(v) == "table" then ends - end -- if not rL[rL.cL]._var and rL.cL == 1 then ends - end -- while true ends here - end -- do ends - -- If condition for pretty printing - if result[#result]:sub(-1,-1) == "," then - result[#result] = result[#result]:sub(1,-3) -- remove the tab and the comma - else - result[#result] = result[#result]:sub(1,-2) -- just remove the tab - end - result[#result + 1] = "}" - return table.concat(result) -end +function t2spp(t) -- {{{ + local levels = 0 + local rL = {cL = 1} -- Table to track recursion into nested tables (cL = current recursion level) + rL[rL.cL] = {} + local result = {} + do + rL[rL.cL]._f, rL[rL.cL]._s, rL[rL.cL]._var = pairs(t) + result[#result + 1] = "{\n" .. string.rep("\t", levels + 1) + -- result[#result + 1] = "{" -- Non pretty version + rL[rL.cL].t = t + while true do + local k, v = rL[rL.cL]._f(rL[rL.cL]._s, rL[rL.cL]._var) + rL[rL.cL]._var = k + if k == nil and rL.cL == 1 then + break + elseif k == nil then + -- go up in recursion level + -- If condition for pretty printing + if result[#result]:sub(-1, -1) == "," then + result[#result] = result[#result]:sub(1, -3) -- remove the tab and the comma + else + result[#result] = result[#result]:sub(1, -2) -- just remove the tab + end + -- result[#result + 1] = "}," -- non pretty version + levels = levels - 1 + rL.cL = rL.cL - 1 + rL[rL.cL + 1] = nil + result[#result + 1] = "},\n" .. string.rep("\t", levels + 1) -- for pretty printing + else + -- Handle the key and value here + if type(k) == "number" or type(k) == "boolean" then + result[#result + 1] = "[" .. tostring(k) .. "]=" + elseif type(k) == "table" then + result[#result + 1] = "[" .. t2spp(k) .. "]=" + else + local kp = tostring(k) + if kp:match([["]]) then + result[#result + 1] = + "[" .. [[']] .. kp .. [[']] .. "]=" + else + result[#result + 1] = + "[" .. [["]] .. kp .. [["]] .. "]=" + end + end + if type(v) == "table" then + -- Check if this is not a recursive table + local goDown = true + for i = 1, rL.cL do + if v == rL[i].t then + -- This is recursive do not go down + goDown = false + break + end + end + if goDown then + -- Go deeper in recursion + levels = levels + 1 + rL.cL = rL.cL + 1 + rL[rL.cL] = {} + rL[rL.cL]._f, rL[rL.cL]._s, rL[rL.cL]._var = pairs(v) + result[#result + 1] = + "{\n" .. string.rep("\t", levels + 1) -- For pretty printing + -- result[#result + 1] = "{" -- non pretty version + rL[rL.cL].t = v + else + result[#result + 1] = + "\"" .. tostring(v) .. "\",\n" .. + string.rep("\t", levels + 1) -- For pretty printing + -- result[#result + 1] = "\""..tostring(v).."\"," -- non pretty version + end + elseif type(v) == "number" or type(v) == "boolean" then + result[#result + 1] = + tostring(v) .. ",\n" .. string.rep("\t", levels + 1) -- For pretty printing + -- result[#result + 1] = tostring(v).."," -- non pretty version + else + result[#result + 1] = + string.format("%q", tostring(v)) .. ",\n" .. + string.rep("\t", levels + 1) -- For pretty printing + -- result[#result + 1] = string.format("%q",tostring(v)).."," -- non pretty version + end -- if type(v) == "table" then ends + end -- if not rL[rL.cL]._var and rL.cL == 1 then ends + end -- while true ends here + end -- do ends + -- If condition for pretty printing + if result[#result]:sub(-1, -1) == "," then + result[#result] = result[#result]:sub(1, -3) -- remove the tab and the comma + else + result[#result] = result[#result]:sub(1, -2) -- just remove the tab + end + result[#result + 1] = "}" + return table.concat(result) +end -- }}} -- Function to convert a table to string following the recursive tables also -- Metatables are not followed @@ -210,517 +220,541 @@ end -- 8. table -- The table to string and string to table conversion will maintain the following types: -- nil, boolean, number, string, table --- The other three types (function, userdata and thread) get their tostring values stored and end up as a string ID. -function t2sr(t) - if type(t) ~= 'table' then return nil, 'Expected table parameter' end - local rL = {cL = 1} -- Table to track recursion into nested tables (cL = current recursion level) - rL[rL.cL] = {} - local tabIndex = {} -- Table to store a list of tables indexed into a string and their variable name - local latestTab = 0 - local result = {} - do - rL[rL.cL]._f,rL[rL.cL]._s,rL[rL.cL]._var = pairs(t) -- Start the key value traveral for the table and store the iterator returns - result[#result + 1] = 't0={}' -- t0 would be the main table - --rL[rL.cL].str = 't0={}' - rL[rL.cL].t = t -- Table to stringify at this level - rL[rL.cL].tabIndex = 0 - tabIndex[t] = rL[rL.cL].tabIndex - while true do - local key - local k,v = rL[rL.cL]._f(rL[rL.cL]._s,rL[rL.cL]._var) -- Get the 1st key and value from the iterator in k,v - rL[rL.cL]._var = k - if k == nil and rL.cL == 1 then - break -- All done! - elseif k == nil then - -- go up in recursion level - --rL[rL.cL-1].str = rL[rL.cL-1].str..'\\n'..rL[rL.cL].str - rL.cL = rL.cL - 1 - if rL[rL.cL].vNotDone then - -- We were converting a key to string since that was a table. Now do the same for the value at this level - key = 't'..rL[rL.cL].tabIndex..'[t'..tostring(rL[rL.cL+1].tabIndex)..']' - --rL[rL.cL].str = rL[rL.cL].str..'\\n'..key..'=' - result[#result + 1] = "\n"..key.."=" - v = rL[rL.cL].vNotDone - end - rL[rL.cL+1] = nil - else - -- Handle the key and value here - if type(k) == 'number' or type(k) == 'boolean' then - key = 't'..rL[rL.cL].tabIndex..'['..tostring(k)..']' - --rL[rL.cL].str = rL[rL.cL].str..'\\n'..key..'=' - result[#result + 1] = "\n"..key.."=" - elseif type(k) == 'string' then - key = 't'..rL[rL.cL].tabIndex..'.'..tostring(k) - --rL[rL.cL].str = rL[rL.cL].str..'\\n'..key..'=' - result[#result + 1] = "\n"..key.."=" - elseif type(k) == 'table' then - -- Table key - -- Check if the table already exists - if tabIndex[k] then - key = 't'..rL[rL.cL].tabIndex..'[t'..tabIndex[k]..']' - --rL[rL.cL].str = rL[rL.cL].str..'\\n'..key..'=' - result[#result + 1] = "\n"..key.."=" - else - -- Go deeper to stringify this table - latestTab = latestTab + 1 - --rL[rL.cL].str = rL[rL.cL].str..'\\nt'..tostring(latestTab)..'={}' - result[#result + 1] = "\nt"..tostring(latestTab).."={}" - rL[rL.cL].vNotDone = v - rL.cL = rL.cL + 1 - rL[rL.cL] = {} - rL[rL.cL]._f,rL[rL.cL]._s,rL[rL.cL]._var = pairs(k) - rL[rL.cL].tabIndex = latestTab - rL[rL.cL].t = k - --rL[rL.cL].str = '' - tabIndex[k] = rL[rL.cL].tabIndex - end -- if tabIndex[k] then ends - else - -- k is of the type function, userdata or thread - key = 't'..rL[rL.cL].tabIndex..'.'..tostring(k) - --rL[rL.cL].str = rL[rL.cL].str..'\\n'..key..'=' - result[#result + 1] = "\n"..key.."=" - end -- if type(k)ends - end -- if not k and rL.cL == 1 then ends - if key then - rL[rL.cL].vNotDone = nil - if type(v) == 'table' then - -- Check if this table is already indexed - if tabIndex[v] then - --rL[rL.cL].str = rL[rL.cL].str..'t'..tabIndex[v] - result[#result + 1] = 't'..tabIndex[v] - else - -- Go deeper in recursion - latestTab = latestTab + 1 - --rL[rL.cL].str = rL[rL.cL].str..'{}' - --rL[rL.cL].str = rL[rL.cL].str..'\\nt'..tostring(latestTab)..'='..key - result[#result + 1] = "{}\nt"..tostring(latestTab)..'='..key -- New table - rL.cL = rL.cL + 1 - rL[rL.cL] = {} - rL[rL.cL]._f,rL[rL.cL]._s,rL[rL.cL]._var = pairs(v) - rL[rL.cL].tabIndex = latestTab - rL[rL.cL].t = v - --rL[rL.cL].str = '' - tabIndex[v] = rL[rL.cL].tabIndex - end - elseif type(v) == 'number' then - --rL[rL.cL].str = rL[rL.cL].str..tostring(v) - result[#result + 1] = tostring(v) - elseif type(v) == 'boolean' then - --rL[rL.cL].str = rL[rL.cL].str..tostring(v) - result[#result + 1] = tostring(v) - else - --rL[rL.cL].str = rL[rL.cL].str..string.format('%q',tostring(v)) - result[#result + 1] = string.format('%q',tostring(v)) - end -- if type(v) == "table" then ends - end -- if key then ends - end -- while true ends here - end -- do ends - --return rL[rL.cL].str - return table.concat(result) -end - +-- The other three types (function, userdata and thread) get their tostring +-- values stored and end up as a string ID. +function t2sr(t) -- {{{ + if type(t) ~= 'table' then + return nil, 'Expected table parameter' + end + local rL = {cL = 1} -- Table to track recursion into nested tables (cL = current recursion level) + rL[rL.cL] = {} + local tabIndex = {} -- Table to store a list of tables indexed into a string and their variable name + local latestTab = 0 + local result = {} + do + rL[rL.cL]._f, rL[rL.cL]._s, rL[rL.cL]._var = pairs(t) -- Start the key value traveral for the table and store the iterator returns + result[#result + 1] = 't0={}' -- t0 would be the main table + -- rL[rL.cL].str = 't0={}' + rL[rL.cL].t = t -- Table to stringify at this level + rL[rL.cL].tabIndex = 0 + tabIndex[t] = rL[rL.cL].tabIndex + while true do + local key + local k, v = rL[rL.cL]._f(rL[rL.cL]._s, rL[rL.cL]._var) -- Get the 1st key and value from the iterator in k,v + rL[rL.cL]._var = k + if k == nil and rL.cL == 1 then + break -- All done! + elseif k == nil then + -- go up in recursion level + -- rL[rL.cL-1].str = rL[rL.cL-1].str..'\\n'..rL[rL.cL].str + rL.cL = rL.cL - 1 + if rL[rL.cL].vNotDone then + -- We were converting a key to string since that was a table. Now do the same for the value at this level + key = 't' .. rL[rL.cL].tabIndex .. '[t' .. + tostring(rL[rL.cL + 1].tabIndex) .. ']' + -- rL[rL.cL].str = rL[rL.cL].str..'\\n'..key..'=' + result[#result + 1] = "\n" .. key .. "=" + v = rL[rL.cL].vNotDone + end + rL[rL.cL + 1] = nil + else + -- Handle the key and value here + if type(k) == 'number' or type(k) == 'boolean' then + key = 't' .. rL[rL.cL].tabIndex .. '[' .. tostring(k) .. ']' + -- rL[rL.cL].str = rL[rL.cL].str..'\\n'..key..'=' + result[#result + 1] = "\n" .. key .. "=" + elseif type(k) == 'string' then + key = 't' .. rL[rL.cL].tabIndex .. '.' .. tostring(k) + -- rL[rL.cL].str = rL[rL.cL].str..'\\n'..key..'=' + result[#result + 1] = "\n" .. key .. "=" + elseif type(k) == 'table' then + -- Table key + -- Check if the table already exists + if tabIndex[k] then + key = + 't' .. rL[rL.cL].tabIndex .. '[t' .. tabIndex[k] .. + ']' + -- rL[rL.cL].str = rL[rL.cL].str..'\\n'..key..'=' + result[#result + 1] = "\n" .. key .. "=" + else + -- Go deeper to stringify this table + latestTab = latestTab + 1 + -- rL[rL.cL].str = rL[rL.cL].str..'\\nt'..tostring(latestTab)..'={}' + result[#result + 1] = + "\nt" .. tostring(latestTab) .. "={}" + rL[rL.cL].vNotDone = v + rL.cL = rL.cL + 1 + rL[rL.cL] = {} + rL[rL.cL]._f, rL[rL.cL]._s, rL[rL.cL]._var = pairs(k) + rL[rL.cL].tabIndex = latestTab + rL[rL.cL].t = k + -- rL[rL.cL].str = '' + tabIndex[k] = rL[rL.cL].tabIndex + end -- if tabIndex[k] then ends + else + -- k is of the type function, userdata or thread + key = 't' .. rL[rL.cL].tabIndex .. '.' .. tostring(k) + -- rL[rL.cL].str = rL[rL.cL].str..'\\n'..key..'=' + result[#result + 1] = "\n" .. key .. "=" + end -- if type(k)ends + end -- if not k and rL.cL == 1 then ends + if key then + rL[rL.cL].vNotDone = nil + if type(v) == 'table' then + -- Check if this table is already indexed + if tabIndex[v] then + -- rL[rL.cL].str = rL[rL.cL].str..'t'..tabIndex[v] + result[#result + 1] = 't' .. tabIndex[v] + else + -- Go deeper in recursion + latestTab = latestTab + 1 + -- rL[rL.cL].str = rL[rL.cL].str..'{}' + -- rL[rL.cL].str = rL[rL.cL].str..'\\nt'..tostring(latestTab)..'='..key + result[#result + 1] = + "{}\nt" .. tostring(latestTab) .. '=' .. key -- New table + rL.cL = rL.cL + 1 + rL[rL.cL] = {} + rL[rL.cL]._f, rL[rL.cL]._s, rL[rL.cL]._var = pairs(v) + rL[rL.cL].tabIndex = latestTab + rL[rL.cL].t = v + -- rL[rL.cL].str = '' + tabIndex[v] = rL[rL.cL].tabIndex + end + elseif type(v) == 'number' then + -- rL[rL.cL].str = rL[rL.cL].str..tostring(v) + result[#result + 1] = tostring(v) + elseif type(v) == 'boolean' then + -- rL[rL.cL].str = rL[rL.cL].str..tostring(v) + result[#result + 1] = tostring(v) + else + -- rL[rL.cL].str = rL[rL.cL].str..string.format('%q',tostring(v)) + result[#result + 1] = string.format('%q', tostring(v)) + end -- if type(v) == "table" then ends + end -- if key then ends + end -- while true ends here + end -- do ends + -- return rL[rL.cL].str + return table.concat(result) +end -- }}} -- Function to convert a string containing a lua table to a lua table object -function s2t(str) - local fileFunc - local safeenv = {} - if loadstring and setfenv then - fileFunc = loadstring("t="..str) - setfenv(f,safeenv) - else - fileFunc = load("t="..str,"stringToTable","t",safeenv) - end - local err,msg = pcall(fileFunc) - if not err or not safeenv.t or type(safeenv.t) ~= "table" then - return nil,msg or type(safeenv.t) ~= "table" and "Not a table" - end - return safeenv.t -end +function s2t(str) -- {{{ + local fileFunc + local safeenv = {} + if loadstring and setfenv then + fileFunc = loadstring("t=" .. str) + setfenv(f, safeenv) + else + fileFunc = load("t=" .. str, "stringToTable", "t", safeenv) + end + local err, msg = pcall(fileFunc) + if not err or not safeenv.t or type(safeenv.t) ~= "table" then + return nil, msg or type(safeenv.t) ~= "table" and "Not a table" + end + return safeenv.t +end -- }}} --- Function to convert a string containing a lua recursive table (from t2sr) to a lua table object -function s2tr(str) - local fileFunc - local safeenv = {} - if loadstring and setfenv then - fileFunc = loadstring(str) - setfenv(f,safeenv) - else - fileFunc = load(str,"stringToTable","t",safeenv) - end - local err,msg = pcall(fileFunc) - if not err or not safeenv.t0 or type(safeenv.t0) ~= "table" then - return nil,msg or type(safeenv.t0) ~= "table" and "Not a table" - end - return safeenv.t0 -end +-- Function to convert a string containing a lua recursive table (from t2sr) to +-- a lua table object +function s2tr(str) -- {{{ + local fileFunc + local safeenv = {} + if loadstring and setfenv then + fileFunc = loadstring(str) + setfenv(f, safeenv) + else + fileFunc = load(str, "stringToTable", "t", safeenv) + end + local err, msg = pcall(fileFunc) + if not err or not safeenv.t0 or type(safeenv.t0) ~= "table" then + return nil, msg or type(safeenv.t0) ~= "table" and "Not a table" + end + return safeenv.t0 +end -- }}} -- Merge arrays t1 to t2 -- if duplicates flag is false then duplicates are skipped --- if isduplicate is a given function then that is used to check whether the value of t1 and value of t2 are duplicate using a call like this: +-- if isduplicate is a given function then that is used to check whether the +-- value of t1 and value of t2 are duplicate using a call like this: -- isduplicate(t1[i],t2[j]) -- returns table t2 -function mergeArrays(t1,t2,duplicates,isduplicate) - isduplicate = (type(isduplicate)=="function" and isduplicate) or function(v1,v2) - return v1==v2 - end - for i = 1,#t1 do - local add = true - if not duplicates then - -- Check if this is a duplicate - for j = 1,#t2 do - if isduplicate(t1[i],t2[j]) then - add = false - break - end - end - end - if add then - table.insert(t2, t1[i]) - end - end - return t2 -end +function mergeArrays(t1, t2, duplicates, isduplicate) -- {{{ + isduplicate = (type(isduplicate) == "function" and isduplicate) or + function(v1, v2) + return v1 == v2 + end + for i = 1, #t1 do + local add = true + if not duplicates then + -- Check if this is a duplicate + for j = 1, #t2 do + if isduplicate(t1[i], t2[j]) then + add = false + break + end + end + end + if add then + table.insert(t2, t1[i]) + end + end + return t2 +end -- }}} -- Function to check whether value v is in array t1 --- if equal is a given function then equal is called with a value from the table and the value to compare. If it returns true then the values are considered equal -function inArray(t1,v,equal) - equal = (type(equal)=="function" and equal) or function(v1,v2) - return v1==v2 - end - for i = 1,#t1 do - if equal(t1[i],v) then - return i -- Value v found in t1 at ith location - end - end - return false -- Value v not in t1 -end +-- if equal is a given function then equal is called with a value from the table +-- and the value to compare. If it returns true then the values are considered +-- equal +function inArray(t1, v, equal) -- {{{ + equal = (type(equal) == "function" and equal) or function(v1, v2) + return v1 == v2 + end + for i = 1, #t1 do + if equal(t1[i], v) then + return i -- Value v found in t1 at ith location + end + end + return false -- Value v not in t1 +end -- }}} -function emptyTable(t) - for k,v in pairs(t) do - t[k] = nil - end - return true -end +function emptyTable(t) -- {{{ + for k, v in pairs(t) do + t[k] = nil + end + return true +end -- }}} -function emptyArray(t) - for i = 1,#t do - t[i] = nil - end - return true -end +function emptyArray(t) -- {{{ + for i = 1, #t do + t[i] = nil + end + return true +end -- }}} -local WEAKK = {__mode="k"} -local WEAKV = {__mode="v"} +local WEAKK = {__mode = "k"} +local WEAKV = {__mode = "v"} -- Copy table t1 to t2 overwriting any common keys -- If full is true then copy is recursively going down into nested tables -- returns t2 and mapping of source to destination and destination to source tables -function copyTable(t1,t2,full,map,tabDone) - map = map or { - s2d=setmetatable({},WEAKK), - d2s=setmetatable({},WEAKV) - } - map.s2d[t1] = t2 -- s2d contains mapping of source table tables to destination tables - map.d2s[t2] = t1 -- d2s contains mapping of destination table tables to source tables - tabDone = tabDone or {[t1]=t2} -- To keep track of recursive tables - for k,v in pairs(t1) do - if type(v) == "number" or type(v) == "string" or type(v) == "boolean" or type(v) == "function" or type(v) == "thread" or type(v) == "userdata" then - if type(k) == "table" then - if full then - local kp - if not tabDone[k] then - kp = {} - tabDone[k] = kp - copyTable(k,kp,true,map,tabDone) - map.d2s[kp] = k - map.s2d[k] = kp - else - kp = tabDone[k] - end - t2[kp] = v - else - t2[k] = v - end - else - t2[k] = v - end - else - -- type(v) = ="table" - if full then - if type(k) == "table" then - local kp - if not tabDone[k] then - kp = {} - tabDone[k] = kp - copyTable(k,kp,true,map,tabDone) - map.d2s[kp] = k - map.s2d[k] = kp - else - kp = tabDone[k] - end - t2[kp] = {} - if not tabDone[v] then - tabDone[v] = t2[kp] - copyTable(v,t2[kp],true,map,tabDone) - map.d2s[t2[kp]] = v - map.s2d[v] = t2[kp] - else - t2[kp] = tabDone[v] - end - else - t2[k] = {} - if not tabDone[v] then - tabDone[v] = t2[k] - copyTable(v,t2[k],true,map,tabDone) - map.d2s[t2[k]] = v - map.s2d[v] = t2[k] - else - t2[k] = tabDone[v] - end - end - else - t2[k] = v - end - end - end - return t2,map -end +function copyTable(t1, t2, full, map, tabDone) -- {{{ + map = map or {s2d = setmetatable({}, WEAKK), d2s = setmetatable({}, WEAKV)} + map.s2d[t1] = t2 -- s2d contains mapping of source table tables to destination tables + map.d2s[t2] = t1 -- d2s contains mapping of destination table tables to source tables + tabDone = tabDone or {[t1] = t2} -- To keep track of recursive tables + for k, v in pairs(t1) do + if type(v) == "number" or type(v) == "string" or type(v) == "boolean" or + type(v) == "function" or type(v) == "thread" or type(v) == + "userdata" then + if type(k) == "table" then + if full then + local kp + if not tabDone[k] then + kp = {} + tabDone[k] = kp + copyTable(k, kp, true, map, tabDone) + map.d2s[kp] = k + map.s2d[k] = kp + else + kp = tabDone[k] + end + t2[kp] = v + else + t2[k] = v + end + else + t2[k] = v + end + else + -- type(v) = ="table" + if full then + if type(k) == "table" then + local kp + if not tabDone[k] then + kp = {} + tabDone[k] = kp + copyTable(k, kp, true, map, tabDone) + map.d2s[kp] = k + map.s2d[k] = kp + else + kp = tabDone[k] + end + t2[kp] = {} + if not tabDone[v] then + tabDone[v] = t2[kp] + copyTable(v, t2[kp], true, map, tabDone) + map.d2s[t2[kp]] = v + map.s2d[v] = t2[kp] + else + t2[kp] = tabDone[v] + end + else + t2[k] = {} + if not tabDone[v] then + tabDone[v] = t2[k] + copyTable(v, t2[k], true, map, tabDone) + map.d2s[t2[k]] = v + map.s2d[v] = t2[k] + else + t2[k] = tabDone[v] + end + end + else + t2[k] = v + end + end + end + return t2, map +end -- }}} --- Function to compare 2 tables. Returns nil if they are not equal in value or do not have the same recursive link structure +-- Function to compare 2 tables. Returns nil if they are not equal in value or +-- do not have the same recursive link structure -- Recursive tables are allowed -function compareTables(t1,t2,traversing) - if not t2 then - return false - end - traversing = traversing or {} - traversing[t1] = t2 -- t1 is being traversed to match it to t2 - local donet2 = {} -- To mark which keys are taken - for k,v in pairs(t1) do - --print(k,v) - if type(v) == "number" or type(v) == "string" or type(v) == "boolean" or type(v) == "function" or type(v) == "thread" or type(v) == "userdata" then - if type(k) == "table" then - -- Find a matching key - local found - for k2,v2 in pairs(t2) do - if not donet2[k2] and type(k2) == "table" then - -- Check if k2 is already traversed or is being traversed - local traversal - for k3,v3 in pairs(traversing) do - if v3 == k2 then - traversal = k3 - break - end - end - if not traversal then - if compareTables(k,k2,traversing) and v2 == v then - found = k2 - break - end - elseif traversal==k and v2 == v then - found = k2 - break - end - end - end - if not found then - return false - end - donet2[found] = true - else - if v ~= t2[k] then - return false - end - donet2[k] = true - end - else - -- type(v) = ="table" - --print("-------->Going In "..tostring(v)) - if type(k) == "table" then - -- Find a matching key - local found - for k2,v2 in pairs(t2) do - if not donet2[k2] and type(k2) == "table" then - -- Check if k2 is already traversed or is being traversed - local traversal - for k3,v3 in pairs(traversing) do - if v3 == k2 then - traversal = k3 - break - end - end - if not traversal then - if compareTables(k,k2,traversing) and v2 == v then - found = k2 - break - end - elseif traversal==k and v2 == v then - found = k2 - break - end - end - end - if not found then - return false - end - donet2[found] = true - else - -- k is not a table - if not traversing[v] then - if not compareTables(v,t2[k],traversing) then - return false - end - else - -- This is a recursive table so it should match - if traversing[v] ~= t2[k] then - return false - end - end - donet2[k] = true - end - end - end - -- Check if any keys left in t2 - for k,v in pairs(t2) do - if not donet2[k] then - return false -- extra stuff in t2 - end - end - traversing[t1] = nil - return true -end +function compareTables(t1, t2, traversing) -- {{{ + if not t2 then + return false + end + traversing = traversing or {} + traversing[t1] = t2 -- t1 is being traversed to match it to t2 + local donet2 = {} -- To mark which keys are taken + for k, v in pairs(t1) do + -- print(k,v) + if type(v) == "number" or type(v) == "string" or type(v) == "boolean" or + type(v) == "function" or type(v) == "thread" or type(v) == + "userdata" then + if type(k) == "table" then + -- Find a matching key + local found + for k2, v2 in pairs(t2) do + if not donet2[k2] and type(k2) == "table" then + -- Check if k2 is already traversed or is being traversed + local traversal + for k3, v3 in pairs(traversing) do + if v3 == k2 then + traversal = k3 + break + end + end + if not traversal then + if compareTables(k, k2, traversing) and v2 == v then + found = k2 + break + end + elseif traversal == k and v2 == v then + found = k2 + break + end + end + end + if not found then + return false + end + donet2[found] = true + else + if v ~= t2[k] then + return false + end + donet2[k] = true + end + else + -- type(v) = ="table" + -- print("-------->Going In "..tostring(v)) + if type(k) == "table" then + -- Find a matching key + local found + for k2, v2 in pairs(t2) do + if not donet2[k2] and type(k2) == "table" then + -- Check if k2 is already traversed or is being traversed + local traversal + for k3, v3 in pairs(traversing) do + if v3 == k2 then + traversal = k3 + break + end + end + if not traversal then + if compareTables(k, k2, traversing) and v2 == v then + found = k2 + break + end + elseif traversal == k and v2 == v then + found = k2 + break + end + end + end + if not found then + return false + end + donet2[found] = true + else + -- k is not a table + if not traversing[v] then + if not compareTables(v, t2[k], traversing) then + return false + end + else + -- This is a recursive table so it should match + if traversing[v] ~= t2[k] then + return false + end + end + donet2[k] = true + end + end + end + -- Check if any keys left in t2 + for k, v in pairs(t2) do + if not donet2[k] then + return false -- extra stuff in t2 + end + end + traversing[t1] = nil + return true +end -- }}} -local setnil = {} -- Marker table for diff to set nil +local setnil = {} -- Marker table for diff to set nil -- Function to patch table t with the diff provided to convert it to the next table -- diff is a structure as returned by the diffTable function -function patch(t,diff) - local tabDone = {[t]=true} - for k,v in pairs(diff[t]) do - if v == setnil then - t[k] = nil - else - t[k] = v - end - end - -- Any other table keys in diff are the child tables in t so go through them and patch them - for k,v in pairs(diff) do - if k ~= t and type(k) == "table" and not tabDone[k] then - for k1,v1 in pairs(v) do - if v1 == setnil then - k[k1] = nil - else - k[k1] = v1 - end - end - end - end - return t -end +function patch(t, diff) -- {{{ + local tabDone = {[t] = true} + for k, v in pairs(diff[t]) do + if v == setnil then + t[k] = nil + else + t[k] = v + end + end + -- Any other table keys in diff are the child tables in t so go through them and patch them + for k, v in pairs(diff) do + if k ~= t and type(k) == "table" and not tabDone[k] then + for k1, v1 in pairs(v) do + if v1 == setnil then + k[k1] = nil + else + k[k1] = v1 + end + end + end + end + return t +end -- }}} --- Function to return the diff patch of t2-t1. The patch when applied to t1 will make it equal in value to t2 such that compareTables will return true +-- Function to return the diff patch of t2-t1. The patch when applied to t1 will +-- make it equal in value to t2 such that compareTables will return true -- Use the patch function the apply the patch --- map is the table that can provide mapping of any table in t2 to a table in t1 i.e. they can be considered the referring to the same table i.e. that table in t2 after the patch operation would be the same in value as the table in t1 that the map defines but its address will still be the address it was in t2. If there is no mapping for the table found then the same table is looked up at that level to match. But if there is a same table then the diff for that table is obviously 0 --- NOTE: a diff object is temporary and cannot be saved for a later session(This is because of setnil being unique to a session). To save it is better to serialize and save t1 and t2 using t2s functions -function diffTable(t1,t2,map,tabDone,diff) - map = map or { - [t2]=t1 - } - tabDone = tabDone or {[t2]=true} -- To keep track of recursive tables - diff = diff or {} - local diffDirty - diff[t1] = diff[t1] or {} - local keyTabs = {} - -- To convert t1 to t2 let us iterate over all elements of t2 first - for k,v in pairs(t2) do - -- There are 8 types in Lua (except nil and table we check everything here - if type(v) ~= "table" then -- - if type(k) == "table" then - -- Check if there is a mapping else the mapping in t1 is k - local kt1 = k - if map[k] then - kt1 = map[k] - -- Get diff of kt1 and k - if not tabDone[k] then - tabDone[k]= true - diffTable(kt1,k,map,tabDone,diff) - diffDirty = diffDirty or diff[kt1] - end - end - keyTabs[kt1] = k - if t1[kt1] == nil or t1[kt1] ~= v then - diff[t1][kt1] = v - diffDirty = true - end - else -- if type(k) == "table" then else - -- Neither v is a table not k is a table - if t1[k] ~= v then - diff[t1][k] = v - diffDirty = true - end - end -- if type(k) == "table" then ends - else --if type(v) ~= "table" then - -- v == "table" - if type(k) == "table" then - -- Both v and k are tables - local kt1 = k - if map[k] then - kt1 = map[k] - if not tabDone[k] then - tabDone[k] = true - diffTable(kt1,k,map,tabDone,diff) - diffDirty = diffDirty or diff[kt1] - end - end - keyTabs[kt1] = k - local vt1 = v - if map[v] then - vt1 = map[v] - if not tabDone[v] then - tabDone[v] = true - diffTable(vt1,v,map,tabDone,diff) - diffDirty = diffDirty or diff[vt1] - end - end - if t1[kt1] == nil or t1[kt1] ~= vt1 then - diff[t1][kt1] = vt1 - diffDirty = true - end - else - local vt1 = v - if map[v] then - vt1 = map[v] - -- Get the diff of vt1 and v - if not tabDone[v] then - tabDone[v] = true - diffTable(vt1,v,map,tabDone,diff) - diffDirty = diffDirty or diff[vt1] - end - end - if t1[k] == nil or t1[k] ~= vt1 then - diff[t1][k] = vt1 - diffDirty = true - end - end - end --if type(v) ~= "table" then ends - end -- for k,v in pairs(t2) do ends - -- Now to find extra stuff in t1 which should be removed - for k,v in pairs(t1) do - if type(k) ~= "table" then - if t2[k] == nil then - diff[t1][k] = setnil - diffDirty = true - end - else - -- k is a table - -- get the t2 counterpart if it was found - if not keyTabs[k] then - diff[t1][k] = setnil - diffDirty = true - end - end - end - if not diffDirty then diff[t1] = nil end - return diffDirty and diff -end +-- map is the table that can provide mapping of any table in t2 to a table in t1 +-- i.e. they can be considered the referring to the same table i.e. that table +-- in t2 after the patch operation would be the same in value as the table in t1 +-- that the map defines but its address will still be the address it was in t2. +-- If there is no mapping for the table found then the same table is looked up +-- at that level to match. But if there is a same table then the diff for that +-- table is obviously 0 + +-- NOTE: a diff object is temporary and cannot be saved for a later session(This +-- is because of setnil being unique to a session). To save it is better to +-- serialize and save t1 and t2 using t2s functions +function diffTable(t1, t2, map, tabDone, diff) -- {{{ + map = map or {[t2] = t1} + tabDone = tabDone or {[t2] = true} -- To keep track of recursive tables + diff = diff or {} + local diffDirty + diff[t1] = diff[t1] or {} + local keyTabs = {} + -- To convert t1 to t2 let us iterate over all elements of t2 first + for k, v in pairs(t2) do + -- There are 8 types in Lua (except nil and table we check everything here + if type(v) ~= "table" then -- + if type(k) == "table" then + -- Check if there is a mapping else the mapping in t1 is k + local kt1 = k + if map[k] then + kt1 = map[k] + -- Get diff of kt1 and k + if not tabDone[k] then + tabDone[k] = true + diffTable(kt1, k, map, tabDone, diff) + diffDirty = diffDirty or diff[kt1] + end + end + keyTabs[kt1] = k + if t1[kt1] == nil or t1[kt1] ~= v then + diff[t1][kt1] = v + diffDirty = true + end + else -- if type(k) == "table" then else + -- Neither v is a table not k is a table + if t1[k] ~= v then + diff[t1][k] = v + diffDirty = true + end + end -- if type(k) == "table" then ends + else -- if type(v) ~= "table" then + -- v == "table" + if type(k) == "table" then + -- Both v and k are tables + local kt1 = k + if map[k] then + kt1 = map[k] + if not tabDone[k] then + tabDone[k] = true + diffTable(kt1, k, map, tabDone, diff) + diffDirty = diffDirty or diff[kt1] + end + end + keyTabs[kt1] = k + local vt1 = v + if map[v] then + vt1 = map[v] + if not tabDone[v] then + tabDone[v] = true + diffTable(vt1, v, map, tabDone, diff) + diffDirty = diffDirty or diff[vt1] + end + end + if t1[kt1] == nil or t1[kt1] ~= vt1 then + diff[t1][kt1] = vt1 + diffDirty = true + end + else + local vt1 = v + if map[v] then + vt1 = map[v] + -- Get the diff of vt1 and v + if not tabDone[v] then + tabDone[v] = true + diffTable(vt1, v, map, tabDone, diff) + diffDirty = diffDirty or diff[vt1] + end + end + if t1[k] == nil or t1[k] ~= vt1 then + diff[t1][k] = vt1 + diffDirty = true + end + end + end -- if type(v) ~= "table" then ends + end -- for k,v in pairs(t2) do ends + -- Now to find extra stuff in t1 which should be removed + for k, v in pairs(t1) do + if type(k) ~= "table" then + if t2[k] == nil then + diff[t1][k] = setnil + diffDirty = true + end + else + -- k is a table + -- get the t2 counterpart if it was found + if not keyTabs[k] then + diff[t1][k] = setnil + diffDirty = true + end + end + end + if not diffDirty then + diff[t1] = nil + end + return diffDirty and diff +end -- }}} From 6edc81b01ed4556355f39f36147a8ccf1d455ded Mon Sep 17 00:00:00 2001 From: adamwagner Date: Sun, 2 Aug 2020 08:20:33 -0700 Subject: [PATCH 02/33] WIP: Working toward addressing #8 in stackline/query.lua, which replaces bin/yabai-get-stacks. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The goal of this file is to eliminate the need to 'shell out' to yabai to query window data needed to render stackline, which would address https://github.com/AdamWagner/stackline/issues/8. The main problem with relying on yabai is that a 0.03s sleep is required in the yabai script to ensure that the changes that triggered hammerspoon's window event subscriber are, in fact, represented in the query response from yabai. There are probably secondary downsides, such as overall performance, and specifically *yabai* performance (I've noticed that changing focus is slower when lots of yabai queries are happening simultaneously). ┌────────┐ │ Status │ └────────┘ We're not yet using any of the code in this file to actually render the indiators or query ata — all of that is still achieved via the "old" methods. However, this file IS being required by ./core.lua and runs one every window focus event, and the resulting "stack" data is printed to the hammerspoon console. The stack data structure differs from that used in ./stack.lua enough that it won't work as a drop-in replacement. I think that's fine (and it wouldn't be worth attempting to make this a non-breaking change, esp. since zero people rely on it as of 2020-08-02. ┌──────┐ │ Next │ └──────┘ - [ ] Integrate appropriate functionality in this file into the Stack module - [ ] Update key Stack module functions to have basic compatiblity with the new data structure - [ ] Simplify / refine Stack functions to leverage the benefits of having access to the hs.window module for each tracked window - [ ] Integrate appropriate functionality in this file into the Core module - [ ] … see if there's anything left and decide where it should live ┌───────────┐ │ WIP NOTES │ └───────────┘ Much of the functionality in this file should either be integrated into stack.lua or core.lua — I don't think a new file is needed. Rather than calling out to the script ../bin/yabai-get-stacks, we're using hammerspoon's mature (if complicated) hs.window.filter and hs.window modules to achieve the same goal natively within hammerspon. There might be other benefits in addition to fixing the problems that inspired tracked by stackline, which will probably make it easier to implement enhancements that we haven't even considered yet. This approach should also be easier to maintain, *and* we get to drop the jq dependency! --- stackline/WIP-query.lua | 167 +++++++++++++ stackline/core.lua | 2 + stackline/window.lua | 28 --- utils/underscore.lua | 514 +++++++++++++++++++++------------------- utils/utils.lua | 46 ++-- 5 files changed, 466 insertions(+), 291 deletions(-) create mode 100644 stackline/WIP-query.lua diff --git a/stackline/WIP-query.lua b/stackline/WIP-query.lua new file mode 100644 index 0000000..c2f4291 --- /dev/null +++ b/stackline/WIP-query.lua @@ -0,0 +1,167 @@ +local _ = require 'stackline.utils.utils' +local spaces = require("hs._asm.undocumented.spaces") +local screen = require 'hs.screen' +local u = require 'stackline.utils.underscore' +local fnutils = require("hs.fnutils") + +--[[ +The goal of this file is to eliminate the need to 'shell out' to yabai to query +window data needed to render stackline, which would address +https://github.com/AdamWagner/stackline/issues/8. The main problem with relying +on yabai is that a 0.03s sleep is required in the yabai script to ensure that +the changes that triggered hammerspoon's window event subscriber are, in fact, +represented in the query response from yabai. There are probably secondary +downsides, such as overall performance, and specifically *yabai* performance +(I've noticed that changing focus is slower when lots of yabai queries are +happening simultaneously). + +┌────────┐ +│ Status │ +└────────┘ +We're not yet using any of the code in this file to actually render the +indiators or query ata — all of that is still achieved via the "old" methods. + +However, this file IS being required by ./core.lua and runs one every window focus +event, and the resulting "stack" data is printed to the hammerspoon console. + +The stack data structure differs from that used in ./stack.lua enough that it +won't work as a drop-in replacement. I think that's fine (and it wouldn't be +worth attempting to make this a non-breaking change, esp. since zero people rely +on it as of 2020-08-02. + +┌──────┐ +│ Next │ +└──────┘ +- [ ] Integrate appropriate functionality in this file into the Stack module +- [ ] Update key Stack module functions to have basic compatiblity with the new data structure +- [ ] Simplify / refine Stack functions to leverage the benefits of having access to the hs.window module for each tracked window +- [ ] Integrate appropriate functionality in this file into the Core module +- [ ] … see if there's anything left and decide where it should live + +┌───────────┐ +│ WIP NOTES │ +└───────────┘ +Much of the functionality in this file should either be integrated into +stack.lua or core.lua — I don't think a new file is needed. + +Rather than calling out to the script ../bin/yabai-get-stacks, we're using +hammerspoon's mature (if complicated) hs.window.filter and hs.window modules to +achieve the same goal natively within hammerspon. + +There might be other benefits in addition to fixing the problems that inspired +#8: We get "free" access to the *hammerspoon* window module in the window data +tracked by stackline, which will probably make it easier to implement +enhancements that we haven't even considered yet. This approach should also be +easier to maintain, *and* we get to drop the jq dependency! + +--]] + +local wfd = hs.window.filter.new():setOverrideFilter{ -- {{{ + visible = true, -- (i.e. not hidden and not minimized) + fullscreen = false, + currentSpace = true, + allowRoles = 'AXStandardWindow', +} -- }}} + +function getSpaces() -- {{{ + return fnutils.mapCat(screen.allScreens(), function(s) + return spaces.layout()[s:spacesUUID()] + end) +end -- }}} + +function getActiveSpaceIndex() -- {{{ + local s = getSpaces() + local activeSpace = spaces.activeSpace() + return _.indexOf(s, activeSpace) +end -- }}} + +function makeStackId(win, winSpaceId) -- {{{ + -- generate stackId from spaceId & frame values + -- example: "302|35|63|1185|741" + local frame = win:frame():floor() + local x = frame.x + local y = frame.y + local w = frame.w + local h = frame.h + return table.concat({winSpaceId, x, y, w, h}, '|') +end -- }}} + +function mapWin(hsWindow) -- {{{ + return { + stackId = makeStackId(hsWindow, hsWindow:spaces()[1]), + id = hsWindow:id(), + x = hsWindow:frame().x, + y = hsWindow:frame().y, + app = hsWindow:application():name(), + title = hsWindow:title(), + frame = hsWindow:frame(), + _win = hsWindow, + } +end -- }}} + +function lenGreaterThanOne(t) + return #t > 1 +end + +function makeStacksFromWindows(ws) -- {{{ + local windows = u.map(ws, mapWin) + local groupedWindows = _.groupBy(windows, 'stackId') + -- stacks contain more than one window, + -- so ignore groups with only 1 window + local stacks = hs.fnutils.filter(groupedWindows, lenGreaterThanOne) + return stacks +end -- }}} + +function winToHs(win) + return win._win +end + +function stackOccluded(stack) -- {{{ + -- FIXES: When a stack that has "zoom-parent": 1 occludes another stack, the + -- occluded stack's indicators shouldn't be displaed + -- https://github.com/AdamWagner/stackline/issues/11 + + -- Returns true if any non-stack window occludes the stack's frame. + -- This can occur when an unstacked window is zoomed to cover a stack. + -- In this situation, we want to *hide* the occluded stack's indicators + -- TODO: Convert to Stack instance method (wouldn't need to pass in the 'stack' arg) + + function notInStack(hsWindow) + local stackWindowsHs = u.map(u.values(stack), winToHs) + local isInStack = u.include(stackWindowsHs, hsWindow) + return not isInStack + end + + -- NOTE: under.filter works with tables + -- _.filter only works with "list-like" tables + local nonStackWindows = u.filter(wfd:getWindows(), notInStack) + + function isStackInside(nonStackWindow) + local stackFrame = stack[1]._win:frame() + return stackFrame:inside(nonStackWindow:frame()) + end + + return u.any(_.map(nonStackWindows, isStackInside)) +end -- }}} + +-- luacheck: ignore +function stacksOccluded(stacks) -- {{{ + -- NOTE: This *could* be a simple one-liner + local occludedStacks = _.map(stacks, stackOccluded) + _.pheader('occluded stacks:') + _.p(occludedStacks) + return occludedStacks +end -- }}} + +function windowsCurrentSpace() -- {{{ + local ws = wfd:getWindows() + local stacks = makeStacksFromWindows(ws) + _.pheader('STACKS!') + _.p(stacks, 3) + stacksOccluded(stacks) +end -- }}} + +wfd:subscribe(hs.window.filter.windowFocused, windowsCurrentSpace) + +windowsCurrentSpace() + diff --git a/stackline/core.lua b/stackline/core.lua index 8a7ba37..1c987c3 100644 --- a/stackline/core.lua +++ b/stackline/core.lua @@ -45,3 +45,5 @@ wf:subscribe(win_removed, (function(_win, _app, event) wsi:update(true) end)) +require 'stackline.stackline.query' + diff --git a/stackline/window.lua b/stackline/window.lua index d91265e..642434c 100644 --- a/stackline/window.lua +++ b/stackline/window.lua @@ -53,34 +53,6 @@ function Window.__eq(a, b) -- {{{ return isEqual end -- }}} --- metatable testing {{{ -local Test = {} -function Test:new(name, age) - local test = {name = name, age = age} - - local mmt = { - __add = function(a, b) - return a.age + b.age - end, - __eq = function(a, b) - return a.age == b.age - end, - } - setmetatable(test, mmt) - return test -end - -local amy = Test:new('amy', 18) -local adam = Test:new('adam', 33) -local carl = Test:new('carl', 18) - -print('amy equals adam?', amy == adam) -print('amy equals carl?', amy == carl) -print('amy plus adam?', (amy + adam)) -print('amy plus carl?', (amy + carl)) -print('amy plus amy?', (amy + amy)) --- }}} - -- TODO: ↑ Convert to .__eq metatable function Window:setNeedsUpdated(extant) -- {{{ local isEqual = _.isEqual(existComp, currComp) diff --git a/utils/underscore.lua b/utils/underscore.lua index 7415190..a9522ca 100644 --- a/utils/underscore.lua +++ b/utils/underscore.lua @@ -20,43 +20,43 @@ -- WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -- FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -- OTHER DEALINGS IN THE SOFTWARE. - --- Underscore is a set of utility functions for dealing with -- iterators, arrays, tables, and functions. - -local Underscore = { funcs = {} } +local Underscore = {funcs = {}} Underscore.__index = Underscore function Underscore.__call(_, value) - return Underscore:new(value) + return Underscore:new(value) end function Underscore:new(value, chained) - return setmetatable({ _val = value, chained = chained or false }, self) + return setmetatable({_val = value, chained = chained or false}, self) end function Underscore.iter(list_or_iter) - if type(list_or_iter) == "function" then return list_or_iter end - - return coroutine.wrap(function() - for i=1,#list_or_iter do - coroutine.yield(list_or_iter[i]) - end - end) + if type(list_or_iter) == "function" then + return list_or_iter + end + + return coroutine.wrap(function() + for i = 1, #list_or_iter do + coroutine.yield(list_or_iter[i]) + end + end) end function Underscore.range(start_i, end_i, step) - if end_i == nil then - end_i = start_i - start_i = 1 - end - step = step or 1 - local range_iter = coroutine.wrap(function() - for i=start_i, end_i, step do - coroutine.yield(i) - end - end) - return Underscore:new(range_iter) + if end_i == nil then + end_i = start_i + start_i = 1 + end + step = step or 1 + local range_iter = coroutine.wrap(function() + for i = start_i, end_i, step do + coroutine.yield(i) + end + end) + return Underscore:new(range_iter) end --- Identity function. This function looks useless, but is used throughout Underscore as a default. @@ -66,322 +66,352 @@ end -- @usage _.identity("foo") -- => "foo" function Underscore.identity(value) - return value + return value end -- chaining function Underscore:chain() - self.chained = true - return self + self.chained = true + return self end function Underscore:value() - return self._val + return self._val end -- iter function Underscore.funcs.each(list, func) - for i in Underscore.iter(list) do - func(i) - end - return list + for i in Underscore.iter(list) do + func(i) + end + return list end function Underscore.funcs.map(list, func) - local mapped = {} - for i in Underscore.iter(list) do - mapped[#mapped+1] = func(i) - end - return mapped + local mapped = {} + for i in Underscore.iter(list) do + mapped[#mapped + 1] = func(i) + end + return mapped end -function Underscore.funcs.reduce(list, memo, func) - for i in Underscore.iter(list) do - memo = func(memo, i) - end - return memo +function Underscore.funcs.reduce(list, memo, func) + for i in Underscore.iter(list) do + memo = func(memo, i) + end + return memo end function Underscore.funcs.detect(list, func) - for i in Underscore.iter(list) do - if func(i) then return i end - end - return nil + for i in Underscore.iter(list) do + if func(i) then + return i + end + end + return nil end function Underscore.funcs.select(list, func) - local selected = {} - for i in Underscore.iter(list) do - if func(i) then selected[#selected+1] = i end - end - return selected + local selected = {} + for i in Underscore.iter(list) do + if func(i) then + selected[#selected + 1] = i + end + end + return selected end function Underscore.funcs.reject(list, func) - local selected = {} - for i in Underscore.iter(list) do - if not func(i) then selected[#selected+1] = i end - end - return selected + local selected = {} + for i in Underscore.iter(list) do + if not func(i) then + selected[#selected + 1] = i + end + end + return selected end function Underscore.funcs.all(list, func) - func = func or Underscore.identity - - -- TODO what should happen with an empty list? - for i in Underscore.iter(list) do - if not func(i) then return false end - end - return true + func = func or Underscore.identity + + -- TODO what should happen with an empty list? + for i in Underscore.iter(list) do + if not func(i) then + return false + end + end + return true end function Underscore.funcs.any(list, func) - func = func or Underscore.identity + func = func or Underscore.identity - -- TODO what should happen with an empty list? - for i in Underscore.iter(list) do - if func(i) then return true end - end - return false + -- TODO what should happen with an empty list? + for i in Underscore.iter(list) do + if func(i) then + return true + end + end + return false end function Underscore.funcs.include(list, value) - for i in Underscore.iter(list) do - if i == value then return true end - end - return false + for i in Underscore.iter(list) do + if i == value then + return true + end + end + return false end function Underscore.funcs.invoke(list, function_name, ...) - local args = {...} - Underscore.funcs.each(list, function(i) i[function_name](i, unpack(args)) end) - return list + local args = {...} + Underscore.funcs.each(list, function(i) + i[function_name](i, unpack(args)) + end) + return list end function Underscore.funcs.pluck(list, propertyName) - return Underscore.funcs.map(list, function(i) return i[propertyName] end) + return Underscore.funcs.map(list, function(i) + return i[propertyName] + end) end function Underscore.funcs.min(list, func) - func = func or Underscore.identity - - return Underscore.funcs.reduce(list, { item = nil, value = nil }, function(min, item) - if min.item == nil then - min.item = item - min.value = func(item) - else - local value = func(item) - if value < min.value then - min.item = item - min.value = value - end - end - return min - end).item + func = func or Underscore.identity + + return Underscore.funcs.reduce(list, {item = nil, value = nil}, + function(min, item) + if min.item == nil then + min.item = item + min.value = func(item) + else + local value = func(item) + if value < min.value then + min.item = item + min.value = value + end + end + return min + end).item end function Underscore.funcs.max(list, func) - func = func or Underscore.identity - - return Underscore.funcs.reduce(list, { item = nil, value = nil }, function(max, item) - if max.item == nil then - max.item = item - max.value = func(item) - else - local value = func(item) - if value > max.value then - max.item = item - max.value = value - end - end - return max - end).item + func = func or Underscore.identity + + return Underscore.funcs.reduce(list, {item = nil, value = nil}, + function(max, item) + if max.item == nil then + max.item = item + max.value = func(item) + else + local value = func(item) + if value > max.value then + max.item = item + max.value = value + end + end + return max + end).item end function Underscore.funcs.to_array(list) - local array = {} - for i in Underscore.iter(list) do - array[#array+1] = i - end - return array + local array = {} + for i in Underscore.iter(list) do + array[#array + 1] = i + end + return array end function Underscore.funcs.reverse(list) - local reversed = {} - for i in Underscore.iter(list) do - table.insert(reversed, 1, i) - end - return reversed + local reversed = {} + for i in Underscore.iter(list) do + table.insert(reversed, 1, i) + end + return reversed end function Underscore.funcs.sort(iter, comparison_func) - local array = iter - if type(iter) == "function" then - array = Underscore.funcs.to_array(iter) - end - table.sort(array, comparison_func) - return array + local array = iter + if type(iter) == "function" then + array = Underscore.funcs.to_array(iter) + end + table.sort(array, comparison_func) + return array end -- arrays function Underscore.funcs.first(array, n) - if n == nil then - return array[1] - else - local first = {} - n = math.min(n,#array) - for i=1,n do - first[i] = array[i] - end - return first - end + if n == nil then + return array[1] + else + local first = {} + n = math.min(n, #array) + for i = 1, n do + first[i] = array[i] + end + return first + end end function Underscore.funcs.rest(array, index) - index = index or 2 - local rest = {} - for i=index,#array do - rest[#rest+1] = array[i] - end - return rest + index = index or 2 + local rest = {} + for i = index, #array do + rest[#rest + 1] = array[i] + end + return rest end function Underscore.funcs.slice(array, start_index, length) - local sliced_array = {} - - start_index = math.max(start_index, 1) - local end_index = math.min(start_index+length-1, #array) - for i=start_index, end_index do - sliced_array[#sliced_array+1] = array[i] - end - return sliced_array + local sliced_array = {} + + start_index = math.max(start_index, 1) + local end_index = math.min(start_index + length - 1, #array) + for i = start_index, end_index do + sliced_array[#sliced_array + 1] = array[i] + end + return sliced_array end function Underscore.funcs.flatten(array) - local all = {} - - for ele in Underscore.iter(array) do - if type(ele) == "table" then - local flattened_element = Underscore.funcs.flatten(ele) - Underscore.funcs.each(flattened_element, function(e) all[#all+1] = e end) - else - all[#all+1] = ele - end - end - return all + local all = {} + + for ele in Underscore.iter(array) do + if type(ele) == "table" then + local flattened_element = Underscore.funcs.flatten(ele) + Underscore.funcs.each(flattened_element, function(e) + all[#all + 1] = e + end) + else + all[#all + 1] = ele + end + end + return all end function Underscore.funcs.push(array, item) - table.insert(array, item) - return array + table.insert(array, item) + return array end function Underscore.funcs.pop(array) - return table.remove(array) + return table.remove(array) end function Underscore.funcs.shift(array) - return table.remove(array, 1) + return table.remove(array, 1) end function Underscore.funcs.unshift(array, item) - table.insert(array, 1, item) - return array + table.insert(array, 1, item) + return array end function Underscore.funcs.join(array, separator) - return table.concat(array, separator) + return table.concat(array, separator) end -- objects function Underscore.funcs.keys(obj) - local keys = {} - for k,v in pairs(obj) do - keys[#keys+1] = k - end - return keys + local keys = {} + for k, v in pairs(obj) do + keys[#keys + 1] = k + end + return keys end function Underscore.funcs.values(obj) - local values = {} - for k,v in pairs(obj) do - values[#values+1] = v - end - return values + local values = {} + for k, v in pairs(obj) do + values[#values + 1] = v + end + return values end function Underscore.funcs.extend(destination, source) - for k,v in pairs(source) do - destination[k] = v - end - return destination + for k, v in pairs(source) do + destination[k] = v + end + return destination end function Underscore.funcs.is_empty(obj) - return next(obj) == nil + return next(obj) == nil end -- Originally based on penlight's deepcompare() -- http://luaforge.net/projects/penlight/ function Underscore.funcs.is_equal(o1, o2, ignore_mt) - local ty1 = type(o1) - local ty2 = type(o2) - if ty1 ~= ty2 then return false end - - -- non-table types can be directly compared - if ty1 ~= 'table' then return o1 == o2 end - - -- as well as tables which have the metamethod __eq - local mt = getmetatable(o1) - if not ignore_mt and mt and mt.__eq then return o1 == o2 end - - local is_equal = Underscore.funcs.is_equal - - for k1,v1 in pairs(o1) do - local v2 = o2[k1] - if v2 == nil or not is_equal(v1,v2, ignore_mt) then return false end - end - for k2,v2 in pairs(o2) do - local v1 = o1[k2] - if v1 == nil then return false end - end - return true + local ty1 = type(o1) + local ty2 = type(o2) + if ty1 ~= ty2 then + return false + end + + -- non-table types can be directly compared + if ty1 ~= 'table' then + return o1 == o2 + end + + -- as well as tables which have the metamethod __eq + local mt = getmetatable(o1) + if not ignore_mt and mt and mt.__eq then + return o1 == o2 + end + + local is_equal = Underscore.funcs.is_equal + + for k1, v1 in pairs(o1) do + local v2 = o2[k1] + if v2 == nil or not is_equal(v1, v2, ignore_mt) then + return false + end + end + for k2, v2 in pairs(o2) do + local v1 = o1[k2] + if v1 == nil then + return false + end + end + return true end -- functions function Underscore.funcs.compose(...) - local function call_funcs(funcs, ...) - if #funcs > 1 then - return funcs[1](call_funcs(_.rest(funcs), ...)) - else - return funcs[1](...) - end - end - - local funcs = {...} - return function(...) - return call_funcs(funcs, ...) - end + local function call_funcs(funcs, ...) + if #funcs > 1 then + return funcs[1](call_funcs(_.rest(funcs), ...)) + else + return funcs[1](...) + end + end + + local funcs = {...} + return function(...) + return call_funcs(funcs, ...) + end end function Underscore.funcs.wrap(func, wrapper) - return function(...) - return wrapper(func, ...) - end + return function(...) + return wrapper(func, ...) + end end function Underscore.funcs.curry(func, argument) - return function(...) - return func(argument, ...) - end + return function(...) + return func(argument, ...) + end end -function Underscore.functions() - return Underscore.keys(Underscore.funcs) +function Underscore.functions() + return Underscore.keys(Underscore.funcs) end -- add aliases @@ -398,26 +428,28 @@ Underscore.funcs.head = Underscore.funcs.first Underscore.funcs.tail = Underscore.funcs.rest local function wrap_functions_for_oo_support() - local function value_and_chained(value_or_self) - local chained = false - if getmetatable(value_or_self) == Underscore then - chained = value_or_self.chained - value_or_self = value_or_self._val - end - return value_or_self, chained - end - - local function value_or_wrap(value, chained) - if chained then value = Underscore:new(value, true) end - return value - end - - for fn, func in pairs(Underscore.funcs) do - Underscore[fn] = function(obj_or_self, ...) - local obj, chained = value_and_chained(obj_or_self) - return value_or_wrap(func(obj, ...), chained) - end - end + local function value_and_chained(value_or_self) + local chained = false + if getmetatable(value_or_self) == Underscore then + chained = value_or_self.chained + value_or_self = value_or_self._val + end + return value_or_self, chained + end + + local function value_or_wrap(value, chained) + if chained then + value = Underscore:new(value, true) + end + return value + end + + for fn, func in pairs(Underscore.funcs) do + Underscore[fn] = function(obj_or_self, ...) + local obj, chained = value_and_chained(obj_or_self) + return value_or_wrap(func(obj, ...), chained) + end + end end wrap_functions_for_oo_support() diff --git a/utils/utils.lua b/utils/utils.lua index e4d0491..166c20d 100644 --- a/utils/utils.lua +++ b/utils/utils.lua @@ -266,28 +266,9 @@ function utils.deep_print(value, indent, no_newline) end end -function utils.p(...) - -- Auto-inspect non-string values when printing for debugging: - -- Example 1: - -- p('# to be cleaned: ', table.length(self.tabStacks)) - -- -> "# to be cleaned: 2" - -- Example 2: - -- p('keys be cleaned: ', table.keys(self.tabStacks)) - -- -> "keys be cleaned: { "6207671375", "63771631066207041183" }" - - result = {} - - -- How to handle variable arguments - -- https://www.lua.org/pil/5.2.html - for i = 1, select("#", ...) do - local x = select(i, ...) - if type(x) == 'string' then - table.insert(result, x) - else - table.insert(result, hs.inspect(x)) - end - end - print(table.unpack(result), '\n') +function utils.p(data, howDeep) + howDeep = howDeep or 3 + print(hs.inspect(data, {depth = howDeep})) end function utils.pdivider(str) @@ -302,4 +283,25 @@ function utils.pheader(str) print("========================================") end +-- FROM: https://github.com/pyrodogg/AdventOfCode/blob/1ff5baa57c0a6a86c40f685ba6ab590bd50c2148/2019/lua/util.lua#L149 +function utils.groupBy(t, f) + local res = {} + for _k, v in pairs(t) do + local g + if type(f) == 'function' then + g = f(v) + elseif type(f) == 'string' and v[f] ~= nil then + g = v[f] + else + error('Invalid group parameter [' .. f .. ']') + end + + if res[g] == nil then + res[g] = {} + end + table.insert(res[g], v) + end + return res +end + return utils From 261a0d1672f6f3d708e7d8976d02000735a6641f Mon Sep 17 00:00:00 2001 From: adamwagner Date: Mon, 3 Aug 2020 23:13:19 -0700 Subject: [PATCH 03/33] WIP: basic working state with lots of cruft to clean up and edge cases to fix --- bin/yabai-get-stack-idx | 15 +++ notes.md | 65 ++++++++++ stack.lua.bak | 219 +++++++++++++++++++++++++++++++ stackline/WIP-query.lua | 206 ++++++++++++++++++++++------- stackline/core.lua | 154 +++++++++++++++++----- stackline/stack.lua | 281 +++++++++++++++++++++++++++------------- stackline/window.lua | 95 +++++++++----- utils/heap.lua | 181 ++++++++++++++++++++++++++ utils/utils.lua | 22 +++- 9 files changed, 1029 insertions(+), 209 deletions(-) create mode 100755 bin/yabai-get-stack-idx create mode 100644 stack.lua.bak create mode 100644 utils/heap.lua diff --git a/bin/yabai-get-stack-idx b/bin/yabai-get-stack-idx new file mode 100755 index 0000000..60bb24a --- /dev/null +++ b/bin/yabai-get-stack-idx @@ -0,0 +1,15 @@ +#!/usr/local/bin/dash + +/usr/local/bin/yabai -m query --windows --space \ + | /usr/local/bin/jq --raw-output --compact-output --monochrome-output 'map({"\(.id)": .["stack-index"]}) | reduce .[] as $item ({}; . + $item)' + + +# WIN_ID="46041" +# WIN_ID="$1" + +# IDX=$(/usr/local/bin/yabai -m query --windows --window "$WIN_ID" \ +# | /usr/local/bin/jq --raw-output --compact-output --monochrome-output '.["stack-index"]') + +# /Users/adamwagner/Programming/dotfiles/scripts/notify "IDX for $WIN_ID IS $IDX" + +# echo -n "$IDX" diff --git a/notes.md b/notes.md index e9c5f0b..f1c84e8 100644 --- a/notes.md +++ b/notes.md @@ -1,8 +1,17 @@ # stackline development notes ## Inspiration +[hhtwm/init.lua](https//github.com/szymonkaliski/hhtwm/blob/master/hhtwm/init.lua) + +Probably the best source of inspo, esp. the module.cache = {…} implementation. + +[megalithic/window.lua](https://github.com/megalithic/dotfiles/blob/master/hammerspoon/hammerspoon.symlink/ext/window.lua) + + - [/utils/wm/window-handlers.lua](https://github.com/megalithic/dotfiles/blob/master/hammerspoon/hammerspoon.symlink/utils/wm/window-handlers.lua) + - [/utils/wm/init.lua](https://github.com/megalithic/dotfiles/blob/master/hammerspoon/hammerspoon.symlink/utils/wm/init.lua) [window_set.lua](https://github.com/macrael/panes/blob/master/Panes.spoon/window_set.lua) + Seems similar to what I'm doing, but it didn't run w/ SpoonInstall so I haven't used it yet See how they manage window indicators: @@ -25,6 +34,62 @@ See how they manage window indicators: - [colorboard.lua](https://github.com/CommandPost/CommandPost/blob/develop/src/plugins/finalcutpro/touchbar/widgets/colorboard.lua): CommandPost Colorboard The opposite of above, this is *complicated*! Lots of state management, but not sure how applicable it is for me. - [statuslets.lua](https://github.com/cmsj/hammerspoon-config/blob/master/statuslets.lua): statuslets + +## Caching & queing + +[pyericz/LuaWorkQueue](https://github.com/pyericz/LuaWorkQueue/tree/master/src) +A work queue implementation written in Lua. + +[darkwark/queue-lua](https://github.com/darkwark/queue-lua) +Queue implementation for Lua and PICO-8 +Newer (2020) + +[hewenning/Lua-Container](https://github.com/hewenning/Lua-Container/blob/master/Container.lua) +🌏 Implement various containers, stack, queue, priority queue, heap, A* algorithms, etc. through Lua. + +[KurtLoeffler/Lua_CachePool](https://github.com/KurtLoeffler/Lua_CachePool) +A lua library for creating pools of cached objects. + + +## Working with async in Hammerspoon + +`hs.task` is async. There are 2 ways to deal with this: + +1. `hs.timer.waitUntil(pollingCallback, func)` +2. `hs.task.new(…):start():waitUntilUNex()` + +The 1st polls a callback to check if the expected result of the async task has +materialized. +The 2nd makes `hs.task` synchronous. + +The docs strongly discourage use of the 2nd approach, but as long as there isn't +background work that could be done while waiting (there isn't in the use case +I'm thinking of), then it should be slightly _faster_ than polling since the +callback will fire immediately when the task completes. It also saves the cycles +needed to poll in the first place. + +```lua +-- Wait until the win.stackIdx is set from async shell script + hs.task.new("/usr/local/bin/dash", function(_code, stdout, stderr) + callback(stdout) + end, {cmd, args}):start():waitUntilExit() + + -- NOTE: timer.waitUntil is like 'await' in javascript + hs.timer.waitUntil(winIdxIsSet, function() return data end) + +-- Checker func to confirm that win.stackIdx is set +-- For hs.timer.waitUntil +-- NOTE: Temporarily using hs.task:waitUntilExit() to accomplish the +-- same thing +function winIdxIsSet() + if win.stackIdx ~= nil then + return true + end +end +``` + + + ## Using hs.canvas Yet another project has a similar take: diff --git a/stack.lua.bak b/stack.lua.bak new file mode 100644 index 0000000..d29096a --- /dev/null +++ b/stack.lua.bak @@ -0,0 +1,219 @@ +local _ = require 'stackline.utils.utils' +local Window = require 'stackline.stackline.window' +local u = require 'stackline.utils.underscore' +local tut = require 'stackline.utils.table-utils' +local under = require 'stackline.utils.underscore' + +local query = require 'stackline.stackline.WIP-query' + +local log = hs.logger.new('[stack]', 'debug') + +local Stack = {} + +-- TODO: include hs.task functionality from core.lua in the Stack module directly + +function Stack:toggleIcons() -- {{{ + self.showIcons = not self.showIcons + Stack.update() +end -- }}} + +-- function Stack:each_win_id(fn) -- {{{ +-- _.each(self.tabStacks, function(stack) +-- -- hs.alert.show('running each win id') +-- -- _.pheader('stack') +-- -- _.p(stack) +-- -- _.p(under.values(stack)) +-- local winIds = _.map(under.values(stack), function(w) +-- return w.id +-- end) +-- -- print(hs.inspect(winIds)) + +-- for i = 1, #winIds do +-- -- ┌────────────────────┐ +-- -- the main event! +-- -- └────────────────────┘ +-- -- hs.alert.show(winIds[i]) + +-- fn(winIds[i]) -- Call the `fn` provided with win ID + +-- -- hs.alert.show('inside final loop') + +-- -- DEBUG +-- -- print(hs.inspect(winIds)) +-- -- print(winIds[i]) +-- end +-- end) +-- end -- }}} + +function Stack:findWindow(wid) -- {{{ + -- NOTE: A window must be *in* a stack to be found with this method! + for _idx, stack in pairs(self.tabStacks) do + extantWin = stack[wid] + if extantWin then + return extantWin + end + end +end -- }}} + +function Stack:cleanup() -- {{{ + -- _.p('# to be cleaned: ', _.length(self.tabStacks)) + -- _.p('keys be cleaned: ', _.keys(self.tabStacks)) + + for key, stack in pairs(self.tabStacks) do + -- DEBUG: {{{ + -- _.p(stack) + -- _.pheader('stack keys') + -- _.p(_.map(stack, function(w) + -- return _.pick(w, {'id', 'app'}) + -- end)) }}} + + -- For each window, clear canvas element + _.each(stack, function(w) + -- _.pheader('window indicator in cleanup') + -- print(w.indicator) + w.indicator:delete() + end) + + self.tabStacks[key] = nil + end +end -- }}} + +_.pheader('running again!') +local cache = {} + +function Stack:newStack(stack, stackId) -- {{{ + -- DEBUG {{{ + -- _.pheader('cache') + -- _.p(cache) + + _.pheader('NEWSTACK') + -- print('stack window #:', #stack) + -- print('stack ID: ', stackId) }}} + local extantStack = self.tabStacks[stackId] + self.tabStacks[stackId] = extandStack or {} + + for winIdx, w in pairs(stack) do + if not extantStack then -- it's the first run {{{ + _.pheader('First run') + local win = Window:new(w) + + -- win:setStackIdx() {{{ + -- NOTE: blocking task + -- TODO: retrieve all window stack indexes in one yabai query and + -- weave into windows instead of calling each window }}} + + win:process(self.showIcons, winIdx) + win.indicator:show() + + self.tabStacks[stackId][win.id] = win + + win.cacheWin = win + win.cacheWin.focus = win:isFocused() + cache[win.id] = win -- }}} + else -- if extantStack *does* exist {{{ + local extantWin = extantStack[w.id].cacheWin + local win = Window:new(w) + + local noExtantWin = (type(extantWin) == 'nil') + local sameFocus = (extantWin.focus == win:isFocused()) + local shouldRedrawIndicator = (noExtantWin or not sameFocus) + + print('should redraw? ', shouldRedrawIndicator) + + if shouldRedrawIndicator then + extantWin.indicator:delete() + win:process(self.showIcons, winIdx) + win.indicator:show() + win.stackId = stackId -- set stackId on win for easy lookup later + self.tabStacks[stackId][win.id] = win + end + end -- if extantStack exists }}} + end +end -- }}} + +function Stack:ingest(windowData) -- {{{ + for stackId, stackWindows in pairs(windowData) do + Stack:newStack(stackWindows, stackId) + end +end -- }}} + +function Stack:update(shouldClean) -- {{{ + if shouldClean then -- {{{ + _.pheader('running cleanup') + Stack:cleanup() + end -- }}} + + query:windowsCurrentSpace() -- calls Stack:ingest when ready + -- Stack:ingest(newState) + + -- DEBUG {{{ + -- print('\n\n\n\n') + -- _.pheader('self.tabStack after update') + -- self:get() + -- _.pheader('focused windows') + -- _.p(hs.fnutils.map(self:get(), function(stack) + -- _.each(stack, function(w) + -- print(w.id, ' is ', w.focused) + -- end) + -- end)) + -- print('\n\n\n\n') + + -- OLD --------------------------------------------------------------------- + -- -- _.pheader('value of "shouldClean:"') + -- -- _.p(shouldClean) + -- -- print('\n\n') + -- if shouldClean then + -- _.pheader('running cleanup') + -- Stack:cleanup() + -- end + + -- local yabai_get_stacks = 'stackline/bin/yabai-get-stacks' + + -- hs.task.new("/usr/local/bin/dash", function(_code, stdout) + -- local windowData = hs.json.decode(stdout) + -- Stack:ingest(windowData) + -- end, {yabai_get_stacks}):start() }}} + +end -- }}} + +function Stack:get(shouldPrint) -- {{{ + if shouldPrint then + _.p(self.tabStacks, 3) + end + return self.tabStacks +end -- }}} + +function Stack:newStackManager() -- {{{ + self.tabStacks = {} + self.showIcons = false + return { + ingest = function(windowData) + return self:ingest(windowData) + end, + get = function() + return self:get() + end, + getCache = function() + return cache + end, + update = self.update, + cleanup = function() + return self:cleanup() + end, + toggleIcons = function() + return self:toggleIcons() + end, + findWindow = function(wid) + return self:findWindow(wid) + end, + each_win = function(wid) + return self:each_win_id(wid) + end, + get_win_str = function() + return Stack.win_str + end, + } +end -- }}} + +return Stack + diff --git a/stackline/WIP-query.lua b/stackline/WIP-query.lua index c2f4291..d5633b9 100644 --- a/stackline/WIP-query.lua +++ b/stackline/WIP-query.lua @@ -4,7 +4,23 @@ local screen = require 'hs.screen' local u = require 'stackline.utils.underscore' local fnutils = require("hs.fnutils") ---[[ +-- stackline modules +local Window = require 'stackline.stackline.window' + +-- shortcuts +local map = hs.fnutils.map + +function clone(table) + _clone = {} + i, v = next(table, nil) + while i do + _clone[i] = v + i, v = next(table, i) + end + return _clone +end + +--[[ {{{ NOTES The goal of this file is to eliminate the need to 'shell out' to yabai to query window data needed to render stackline, which would address https://github.com/AdamWagner/stackline/issues/8. The main problem with relying @@ -54,26 +70,15 @@ tracked by stackline, which will probably make it easier to implement enhancements that we haven't even considered yet. This approach should also be easier to maintain, *and* we get to drop the jq dependency! ---]] +-- }}} --]] +-- Private functions local wfd = hs.window.filter.new():setOverrideFilter{ -- {{{ visible = true, -- (i.e. not hidden and not minimized) fullscreen = false, currentSpace = true, allowRoles = 'AXStandardWindow', -} -- }}} - -function getSpaces() -- {{{ - return fnutils.mapCat(screen.allScreens(), function(s) - return spaces.layout()[s:spacesUUID()] - end) -end -- }}} - -function getActiveSpaceIndex() -- {{{ - local s = getSpaces() - local activeSpace = spaces.activeSpace() - return _.indexOf(s, activeSpace) -end -- }}} +}:setSortOrder(hs.window.filter.sortByCreated) -- }}} function makeStackId(win, winSpaceId) -- {{{ -- generate stackId from spaceId & frame values @@ -86,37 +91,52 @@ function makeStackId(win, winSpaceId) -- {{{ return table.concat({winSpaceId, x, y, w, h}, '|') end -- }}} +function lenGreaterThanOne(t) -- {{{ + return #t > 1 +end -- }}} + +function winToHs(win) -- {{{ + return win._win +end -- }}} + +local Query = {} +Query.focusedWindow = nil + +function Query:getWinStackIdxs() -- {{{ + local scriptPath = hs.configdir .. '/stackline/bin/yabai-get-stack-idx' + hs.task.new("/usr/local/bin/dash", function(_code, stdout, _stderr) + local winStackIdxs = hs.json.decode(stdout) + self.winStackIdxs = winStackIdxs + -- print('stack idxs are ', hs.inspect(winStackIdxs)) + end, {scriptPath}):start() +end -- }}} + function mapWin(hsWindow) -- {{{ - return { + local winData = { stackId = makeStackId(hsWindow, hsWindow:spaces()[1]), id = hsWindow:id(), - x = hsWindow:frame().x, - y = hsWindow:frame().y, app = hsWindow:application():name(), - title = hsWindow:title(), + -- title = hsWindow:title(), frame = hsWindow:frame(), _win = hsWindow, + focused = (Query.focusedWindow == hsWindow), } + return Window:new(winData) end -- }}} -function lenGreaterThanOne(t) - return #t > 1 -end - -function makeStacksFromWindows(ws) -- {{{ - local windows = u.map(ws, mapWin) - local groupedWindows = _.groupBy(windows, 'stackId') - -- stacks contain more than one window, - -- so ignore groups with only 1 window - local stacks = hs.fnutils.filter(groupedWindows, lenGreaterThanOne) - return stacks +function Query.getSpaces() -- {{{ + return fnutils.mapCat(screen.allScreens(), function(s) + return spaces.layout()[s:spacesUUID()] + end) end -- }}} -function winToHs(win) - return win._win -end +function Query.getActiveSpaceIndex() -- {{{ + local s = Query.getSpaces() + local activeSpace = spaces.activeSpace() + return _.indexOf(s, activeSpace) +end -- }}} -function stackOccluded(stack) -- {{{ +function Query.stackOccluded(stack) -- {{{ -- FIXES: When a stack that has "zoom-parent": 1 occludes another stack, the -- occluded stack's indicators shouldn't be displaed -- https://github.com/AdamWagner/stackline/issues/11 @@ -144,24 +164,114 @@ function stackOccluded(stack) -- {{{ return u.any(_.map(nonStackWindows, isStackInside)) end -- }}} +function Query:makeStacksFromWindows(ws) -- {{{ + Query.focusedWindow = hs.window.focusedWindow() + local windows = u.map(ws, mapWin) + + -- DEBUG {{{ + -- print('\n\n\n\n\n\n') + -- _.pheader('Query.makestacksfromwindows') + -- _.p(windows, 2) }}} + + local groupedWindows = _.groupBy(windows, 'stackId') + + -- TODO: since we already need to shell out to yabai, we *could* do this by + -- intersecting windows with those that have a stack index + + -- stacks contain more than one window, + -- so ignore groups with only 1 window + stacks = hs.fnutils.filter(groupedWindows, lenGreaterThanOne) + self.stacks = stacks +end -- }}} + -- luacheck: ignore -function stacksOccluded(stacks) -- {{{ +function Query:setOccludedStacks(stacks) -- {{{ -- NOTE: This *could* be a simple one-liner - local occludedStacks = _.map(stacks, stackOccluded) - _.pheader('occluded stacks:') - _.p(occludedStacks) - return occludedStacks + local occludedStacks = _.map(stacks, Query.stackOccluded) + self.occludedStacks = occludedStacks + -- print('occluded stacks: ', hs.inspect(self.occludedStacks)) end -- }}} -function windowsCurrentSpace() -- {{{ - local ws = wfd:getWindows() - local stacks = makeStacksFromWindows(ws) - _.pheader('STACKS!') - _.p(stacks, 3) - stacksOccluded(stacks) -end -- }}} +function Query:winStackIdxsAreSet() + _.pheader('polling called') + local areSet = self.winStackIdxs ~= nil + -- print('ARE SET: ', areSet) + return areSet +end + +function Query:mergeWinStackIdxs() + hs.fnutils.each(self.stacks, function(stack) + hs.fnutils.each(stack, function(win) + -- print(win.id) + win.stackIdx = self.winStackIdxs[tostring(win.id)] + end) + end) +end + +local count = 0 + +function Query:windowsCurrentSpace() -- {{{ + self:getWinStackIdxs() -- set self.winStackIdxs (async shell call to yabai) + self:makeStacksFromWindows(wfd:getWindows()) -- set self.stacks + self:setOccludedStacks(self.stacks) -- set self.occludedStacks + + _.pheader('wfd:getWindows() in query') + for _idx, win in pairs(wfd:getWindows()) do + print(win:application():title(), ":", win:title()) + end + + -- Don't return until the yabai query is returned + function checkWinStackIdxsDone() -- {{{ + -- _.pheader('check stack idxs done') + -- Careful! These timers accumulate, tho it's less noticable with a fast polling interval + -- TODO: Find a way to cancel this if it's called again before completing? + return self:winStackIdxsAreSet() + end -- }}} + + function whenStackIdxDone() + + -- Add the stack indexes from yabai to the hs window data + self:mergeWinStackIdxs() + + -- _.p(self.stacks) -wfd:subscribe(hs.window.filter.windowFocused, windowsCurrentSpace) + local cloneStack = map(clone(self.stacks), function(stack) + local _stack = clone(stack) -windowsCurrentSpace() + -- TODO: Decide whether the timestamp field is necessary, and if so, + -- store in temp var & delete key before looping over windows, then + -- restore at end. + -- NOTE: Removed to defer solving the problem of ignoring the + -- timestamp field for now. + -- → → _stack.timestamp = hs.timer.absoluteTime() + + return _stack + end) + + -- FIXME: self.stacks is being past to ingest as a *reference)out*, so + -- changes in the Stack module affect self.stacks + + -- After trying many different deepcopy methods, that path seemed unworkable. + -- Next, I thoughtabout using metatables? But this also seems not quite right. + -- https://stackoverflow.com/questions/18177101/hiding-a-lua-metatable-and-only-exposing-an-objects-attributes + -- It does make me think of a todo, tho: + -- NOTE: Found that hs.fnutils has a copy function! hs.fnutils.copy. Found in https://github.com/CommandPost/CommandPost/blob/develop/src/extensions/cp/watcher/init.lua + -- TODO: Create an actual "Stack" class that represents a single stack. + -- The current "Stack" class is *actually* a stack manager. + + -- NOTE: This is only being called ONCE per change (as desired), but + -- wsi: + count = count + 1 + print('Query module calling stack:ingest (' .. count .. ' times total)') + + -- NOTE: must require here to avoid circular dependency + -- & "Too many C levels" error + require('stackline.stackline.stack'):ingest(cloneStack) -- hand over to the Stack module + -- local stacks = wsi.ingest(self.stacks) + end + + local pollingInterval = 0.1 + hs.timer.waitUntil(checkWinStackIdxsDone, whenStackIdxDone, pollingInterval) +end -- }}} +return Query diff --git a/stackline/core.lua b/stackline/core.lua index 1c987c3..3997f19 100644 --- a/stackline/core.lua +++ b/stackline/core.lua @@ -5,45 +5,127 @@ local tut = require 'stackline.utils.table-utils' -- This file is trash: lowercase globals, copy/paste duplication in -- update_stack_data_redraw() just to pass 'shouldClean':true :( +-- CHANGELOG: +-- DONE: convert to use wsi.update method + +local wf = hs.window.filter + wsi = Stack:newStackManager() -wf = hs.window.filter.default - -local win_added = { - hs.window.filter.windowCreated, - hs.window.filter.windowUnhidden, - hs.window.filter.windowUnminimized, -} - -local win_removed = { - hs.window.filter.windowDestroyed, - hs.window.filter.windowHidden, - hs.window.filter.windowMinimized, - hs.window.filter.windowMoved, -} - --- NOTE: windowMoved captures movement OR resize events -local win_changed = { - hs.window.filter.windowFocused, - hs.window.filter.windowUnfocused, - hs.window.filter.windowFullscreened, - hs.window.filter.windowUnfullscreened, -} - --- TODO: convert to use wsi.update method --- ./stack.lua:15 + +local win_added = { -- {{{ + wf.windowCreated, + wf.windowUnhidden, + wf.windowUnminimized, +} -- }}} +local win_changed = { -- {{{ + + -- TODO: rather than subscribing to windowFocused here, do it only for + -- windows within a stack. This will shorten the update process for focus + -- changes, since we *only* need to update the indicators, not query for new + -- window state entirely. + -- wf.windowFocused, + -- wf.windowUnfocused, + + wf.windowFullscreened, + wf.windowUnfullscreened, + + -- NOTE: windowMoved captures movement OR resize events + wf.windowMoved, +} -- }}} + +-- combine added & changed events local added_changed = tut.mergeArrays(win_added, win_changed) -wf:subscribe(added_changed, (function(_win, _app, event) - _.pheader(event) - wsi:update() -end)) +local win_removed = { -- {{{ + wf.windowDestroyed, + wf.windowHidden, + wf.windowMinimized, + wf.windowNotInCurrentSpace, +} -- }}} + +local wfd = wf.new():setOverrideFilter{ -- {{{ + visible = true, -- (i.e. not hidden and not minimized) + fullscreen = false, + currentSpace = true, + allowRoles = 'AXStandardWindow', +} -- }}} + +-- TO CONFIRM: Compared to calling wsi.update() directly in wf:subscribe +-- callback, even a delay of "0" appears to coalesce events as desired. +-- NOTE: alternative: https://github.com/CommandPost/CommandPost/blob/develop/src/extensions/cp/deferred/init.lua +-- This extension makes it simple to defer multiple actions after a delay from the initial execution. +-- Unlike `hs.timer.delayed`, the delay will not be extended +-- with subsequent `run()` calls, but the delay will trigger again if `run()` is called again later. +local queryWindowState = hs.timer.delayed.new(0.10, function() + wsi.update() +end) + +-- ┌──────────────────────────────────┐ +-- │ Query window state subscriptions │ +-- └──────────────────────────────────┘ +-- callback args: window, app, event +wfd:subscribe(added_changed, function() + -- wsi.update() + queryWindowState:start() +end) + +wfd:subscribe(win_removed, function() + -- TODO: implement cleanup in a better way + -- wsi:update(true) + queryWindowState:start() +end) + +-- call once on load +wsi.update() + +-- ┌─────────────────────────────────┐ +-- │ Update indicators subscriptions │ +-- └─────────────────────────────────┘ + +function indicatorActivate(hsWin) -- {{{ + local id = hsWin:id() + print('Focused', hsWin:application():name(), id) + + local win = wsi.findWindow(id) + + -- DEBUG {{{ + -- print('found window:', win.id) + -- print('indicator:', win.indicator, '\n\n') + -- print('Focused curr focus', win:isFocused()) + -- }}} + + -- NOTE: redraws indicator + -- TODO: rename win:process() to improve clarity! + win:process() + + -- NOTE: experiment to keep indicators in sync despite HS bug in which + -- windowUnfocused is not called when switching between two windows of the + -- same app :< + -- https://github.com/Hammerspoon/hammerspoon/issues/2400 + -- wsi.redrawAllIndicators() +end -- }}} + +function indicatorDeactivate(hsWin) -- {{{ + local id = hsWin:id() + print('Unfocused', hsWin:application():name(), id) + + local win = wsi.findWindow(id) + + -- DEBUG {{{ + -- print('found window:', win.id) + -- print('Unfocused curr focus', win:isFocused()) + -- print('indicator:', win.indicator, '\n\n') + -- }}} + + -- NOTE: redraws indicator + -- TODO: rename win:process() to improve clarity! + win:process() +end -- }}} -wf:subscribe(win_removed, (function(_win, _app, event) - _.pheader(event) - -- look(win) - -- print(app) - wsi:update(true) -end)) +wfd:subscribe(wf.windowFocused, indicatorActivate) -require 'stackline.stackline.query' +-- HS BUG! windowUnfocused is not called when switching between windows of the +-- same app - it's ONLY called when switching between windows of different apps +-- https://github.com/Hammerspoon/hammerspoon/issues/2400 +wfd:subscribe({wf.windowNotVisible, wf.windowUnfocused}, indicatorDeactivate) diff --git a/stackline/stack.lua b/stackline/stack.lua index b2eb4a5..dc032ae 100644 --- a/stackline/stack.lua +++ b/stackline/stack.lua @@ -1,71 +1,100 @@ local _ = require 'stackline.utils.utils' local Window = require 'stackline.stackline.window' +local u = require 'stackline.utils.underscore' local tut = require 'stackline.utils.table-utils' local under = require 'stackline.utils.underscore' -local Stack = {} +local query = require 'stackline.stackline.WIP-query' + +-- local log = hs.logger.new('[stack]', 'debug') --- TODO: include hs.task functionality from core.lua in the Stack module directly +-- shortcuts +local map = hs.fnutils.map + +local Stack = {} function Stack:toggleIcons() -- {{{ self.showIcons = not self.showIcons - Stack.update() + self:redrawAllIndicators() end -- }}} -function Stack:each_win_id(fn) -- {{{ - _.each(self.tabStacks, function(stack) - -- hs.alert.show('running each win id') - _.pheader('stack') - -- _.p(stack) - -- _.p(under.values(stack)) - local winIds = _.map(under.values(stack), function(w) - return w.id - end) - print(hs.inspect(winIds)) +function Stack:redrawIndicator(win) -- {{{ + -- NOTE: to be given Stack:eachWin() as argument + print('calling redraw indicator') + win:process() +end -- }}} - for i = 1, #winIds do - -- ┌────────────────────┐ - -- the main event! - -- └────────────────────┘ - -- hs.alert.show(winIds[i]) +function Stack:eachWin(fn) -- {{{ + for _stackId, stack in pairs(self.tabStacks) do + for _idx, win in pairs(stack) do + fn(win) + end + end +end -- }}} - fn(winIds[i]) -- Call the `fn` provided with win ID +function Stack:redrawAllIndicators() + self:eachWin(function(win) + self:redrawIndicator(win) + end) +end - -- hs.alert.show('inside final loop') +-- function Stack:each_win_id(fn) -- {{{ +-- _.each(self.tabStacks, function(stack) +-- -- hs.alert.show('running each win id') +-- -- _.pheader('stack') +-- -- _.p(stack) +-- -- _.p(under.values(stack)) +-- local winIds = _.map(under.values(stack), function(w) +-- return w.id +-- end) +-- -- print(hs.inspect(winIds)) - -- DEBUG - print(hs.inspect(winIds)) - -- print(winIds[i]) - end - end) -end -- }}} +-- for i = 1, #winIds do +-- -- ┌────────────────────┐ +-- -- the main event! +-- -- └────────────────────┘ +-- -- hs.alert.show(winIds[i]) + +-- fn(winIds[i]) -- Call the `fn` provided with win ID + +-- -- hs.alert.show('inside final loop') + +-- -- DEBUG +-- -- print(hs.inspect(winIds)) +-- -- print(winIds[i]) +-- end +-- end) +-- end -- }}} --- NOTE: A window must be *in* a stack to be found with this method! function Stack:findWindow(wid) -- {{{ - for _idx, stack in pairs(self.tabStacks) do - extantWin = stack[wid] - if extantWin then - return extantWin + -- NOTE: A window must be *in* a stack to be found with this method! + -- print('…searching for win id:', wid) + for _stackId, stack in pairs(self.tabStacks) do + for _idx, win in pairs(stack) do + -- print('curr win id:', win.id) + if win.id == wid then + return win + end end end end -- }}} function Stack:cleanup() -- {{{ - _.p('# to be cleaned: ', _.length(self.tabStacks)) - _.p('keys be cleaned: ', _.keys(self.tabStacks)) + -- _.p('# to be cleaned: ', _.length(self.tabStacks)) + -- _.p('keys be cleaned: ', _.keys(self.tabStacks)) for key, stack in pairs(self.tabStacks) do - -- DEBUG: + -- DEBUG: {{{ -- _.p(stack) - _.pheader('stack keys') - _.p(_.map(stack, function(w) - return _.pick(w, {'id', 'app'}) - end)) + -- _.pheader('stack keys') + -- _.p(_.map(stack, function(w) + -- return _.pick(w, {'id', 'app'}) + -- end)) }}} -- For each window, clear canvas element _.each(stack, function(w) - _.pheader('window indicator in cleanup') - print(w.indicator) + -- _.pheader('window indicator in cleanup') + -- print(w.indicator) w.indicator:delete() end) @@ -73,77 +102,141 @@ function Stack:cleanup() -- {{{ end end -- }}} +i = 1 +local heap = require 'stackline.utils.heap' -- {{{ +h = heap.valueheap { + cmp = function(a, b) + return a.timestamp < b.timestamp + end, +} -- }}} + +function stackDiff() -- {{{ + local curr = h:pop() + local last = h:pop() + + _.pheader('stack diff:') + print("current: ", curr.timestamp) + print("last: ", last.timestamp) + print("diff: ", curr.timestamp - last.timestamp) + -- for _winIdx, w in pairs(curr) do + -- print(hs.inspect(w, {depth = 1})) + -- end +end -- }}} + +local clearConsoleAfterNewStack = hs.timer.delayed.new(5, + hs.console.clearConsole) + function Stack:newStack(stack, stackId) -- {{{ - print('stack data #:', #stack) - print('stack ID: ', stackId) - local extantStack = self.tabStacks[stackId] - if not extantStack then - self.tabStacks[stackId] = {} - end + -- clearConsoleAfterNewStack:start() + print('new stack:', stackId, 'w/', #stack, 'wins') - for k, w in pairs(stack) do - if not extantStack then - print('First run') - local win = Window:new(w) - win:process(self.showIcons, k) - win.indicator:show() - -- _.p(win) - win.stackId = stackId -- set stackId on win for easy lookup later - self.tabStacks[stackId][win.id] = win - - else - local extantWin = extantStack[w.id] - local win = Window:new(w) - - if (type(extantWin) == 'nil') or - not (extantWin.focused == win.focused) then - print('Needs updated:', extantWin.app) - extantWin.indicator:delete() - win:process(self.showIcons, k) - win.indicator:show() - -- _.p(win) - win.stackId = stackId -- set stackId on win for easy lookup later - self.tabStacks[stackId][win.id] = win - end - end - end + -- UPDATE: tentative solution found in hs.timer.delayed + -- FIXME: The problem with this is that there are multiple event + -- subscribers, and so Stack.update() is being called multiple times per + -- actual "change" event, so typically the last two stacks in diff() are the same. + -- ACTUALLY — is this fine? Much of the time, a stack WOULD be the same, + -- even if events *were* debounced / consolidated. The diff will tell the + -- story. The timestamp just ensrues that they stay in the right order. + -- FTNOTE: Well, it's not "fine" — it's very bad from a performance POV. + -- "Fine" above was only in reference to WRT to detecting state changes & + -- reacting appropriately. + + self.tabStacks[stackId] = stack + self:redrawAllIndicators() + + -- for _winIdx, w in pairs(stack) do + -- print(hs.inspect(w, {depth = 1})) + -- end end -- }}} function Stack:ingest(windowData) -- {{{ - _.each(windowData, function(winGroup) - local stackId = table.concat(_.map(winGroup, function(w) - return w.id - end), '') - print(stackId) - Stack:newStack(winGroup, stackId) - end) + + -- TODO: track stacks in heap here instead of in Stack:newStack() + -- IDEA: which MIGHT solve the problem of the problematic timestamp key — we + -- could delete it before "ingestion"! + -- NOTE: disabled until solution to timestamp key is implemented + + -- h:push(stack) + -- print("heap length: ", h:length()) + -- if h:length() > 2 then + -- stackDiff() + -- end + + for stackId, stackWindows in pairs(windowData) do + Stack:newStack(stackWindows, stackId) + end end -- }}} function Stack:update(shouldClean) -- {{{ - - _.pheader('value of "shouldClean:"') - _.p(shouldClean) - print('\n\n') - if shouldClean then + if shouldClean then -- {{{ _.pheader('running cleanup') Stack:cleanup() - end + end -- }}} + + query:windowsCurrentSpace() -- calls Stack:ingest when ready + -- Stack:ingest(newState) + + -- DEBUG {{{ + -- print('\n\n\n\n') + -- _.pheader('self.tabStack after update') + -- self:get() + -- _.pheader('focused windows') + -- _.p(map(self:get(), function(stack) + -- _.each(stack, function(w) + -- print(w.id, ' is ', w.focused) + -- end) + -- end)) + -- print('\n\n\n\n') + + -- OLD --------------------------------------------------------------------- + -- -- _.pheader('value of "shouldClean:"') + -- -- _.p(shouldClean) + -- -- print('\n\n') + -- if shouldClean then + -- _.pheader('running cleanup') + -- Stack:cleanup() + -- end + + -- local yabai_get_stacks = 'stackline/bin/yabai-get-stacks' - local yabai_get_stacks = 'stackline/bin/yabai-get-stacks' + -- hs.task.new("/usr/local/bin/dash", function(_code, stdout) + -- local windowData = hs.json.decode(stdout) + -- Stack:ingest(windowData) + -- end, {yabai_get_stacks}):start() }}} - hs.task.new("/usr/local/bin/dash", function(_code, stdout) - local windowData = hs.json.decode(stdout) - Stack:ingest(windowData) - end, {yabai_get_stacks}):start() end -- }}} -function Stack:newStackManager() +function Stack:get(shouldPrint) -- {{{ + if shouldPrint then + _.p(self.tabStacks, 3) + end + return self.tabStacks +end -- }}} + +function Stack:getShowIconsState(Print) -- {{{ + return self.showIcons +end -- }}} + +function Stack:newStackManager() -- {{{ self.tabStacks = {} self.showIcons = false + + -- TODO: is it really necessary to expose methods like this? Or could other + -- modules just invoke the instance method, e.g., wsi:get() if we used + -- setmetatable to merge Stack methods with instance ala .window.lua:52 return { ingest = function(windowData) return self:ingest(windowData) end, + get = function() + return self:get() + end, + getShowIconsState = function() + return self:getShowIconsState() + end, + getCache = function() + return cache + end, update = self.update, cleanup = function() return self:cleanup() @@ -160,7 +253,11 @@ function Stack:newStackManager() get_win_str = function() return Stack.win_str end, + redrawAllIndicators = function() + return self:redrawAllIndicators() + end, } -end +end -- }}} return Stack + diff --git a/stackline/window.lua b/stackline/window.lua index 642434c..f940f43 100644 --- a/stackline/window.lua +++ b/stackline/window.lua @@ -23,15 +23,30 @@ function metatbl.__index(intbl, key) -- {{{ end end -- }}} +function Window:setStackIdx() -- {{{ + -- FIXME: Too slow. Probably want to query all windows on space, pluck out + -- their stack indexes with jq, & send to hammerspoon to merge with windows. + + -- _.pheader('running setStackIdx for: ' .. self.id) + local scriptPath = hs.configdir .. '/stackline/bin/yabai-get-stack-idx' + hs.task.new("/usr/local/bin/dash", function(_code, stdout, stderr) + local stackIdx = tonumber(stdout) + self.stackIdx = stackIdx + -- print('stack idx for ', self.id, ' is ', stackIdx) + end, {scriptPath, tostring(self.id)}):start():waitUntilExit() +end -- }}} + -- luacheck: ignore function Window:new(w) -- {{{ local ws = { - id = w.id, -- window id - app = w.app, -- 0 if unfocused, 1 if focused - focused = w.focused == 1, -- convert 0,1 into boolean 0 if unfocused, 1 if focused - frame = w.frame, -- x,y,w,h of window - frameFlat = w.frameFlat, -- x|y of window - indicator = nil, -- the canvas element + app = w.app, -- app name (string) + id = w.id, -- window id (string) NOTE: the ID is the same as yabai! So we could interopt if we need to + title = w.title, -- window title (string) + _win = w._win, -- hs.window object (table) + frame = w.frame, -- x,y,w,h of window (table) + -- stackIdx = w.stackIdx, -- from yabai :( + stackId = w.stackId, -- "{spaceId}|{x}|{y}|{w}|{h}" e.g., "302|35|63|1185|741" (string) + indicator = nil, -- the canvas element (table) } setmetatable(ws, self) @@ -39,40 +54,51 @@ function Window:new(w) -- {{{ return ws end -- }}} -function Window.__eq(a, b) -- {{{ - -- FIXME: unused as of 2020-07-31 - local t1 = a.id - local t2 = b.id - print(a.id, a.focused) - print(t2, b.focused) - local existComp = {id = a.id, frame = a.frameFlat, focused = a.focused} - local currComp = {id = b.id, frame = b.frameFlat, focused = b.focused} - -- _.p('A Compare:', existComp) - -- _.p('B Compare:', currComp) - local isEqual = _.isEqual(existComp, currComp) - return isEqual +function Window:isFocused() -- {{{ + local focusedWin = hs.window.focusedWindow() + if focusedWin == nil then + return false + end + local isFocused = self.id == focusedWin:id() + return isFocused end -- }}} +-- function Window.__eq(a, b) -- {{{ +-- -- FIXME: unused as of 2020-07-31 +-- local t1 = a.id +-- local t2 = b.id +-- print('Window.__eq metamethod called:', a.id, a.focused, ' < VS: > ', t2, +-- b.focused) +-- local existComp = {id = a.id, frame = a.frameFlat, focused = a.focused} +-- local currComp = {id = b.id, frame = b.frameFlat, focused = b.focused} +-- -- _.p('A Compare:', existComp) +-- -- _.p('B Compare:', currComp) +-- local isEqual = _.isEqual(existComp, currComp) +-- return isEqual +-- end -- }}} + -- TODO: ↑ Convert to .__eq metatable function Window:setNeedsUpdated(extant) -- {{{ local isEqual = _.isEqual(existComp, currComp) self.needsUpdated = not isEqual end -- }}} -function Window:process(showIcons, currTabIdx) -- {{{ +function Window:process(Icons) -- {{{ -- Config - self.showIcons = showIcons + local showIcons = wsi.getShowIconsState() local unfocused_color = {white = 0.9, alpha = 0.30} local focused_color = {white = 0.9, alpha = 0.99} local padding = 4 local aspectRatio = 5 local size = 25 - local width = self.showIcons and size or (size / aspectRatio) + local width = showIcons and size or (size / aspectRatio) + + local currTabIdx = self.stackIdx + local xval = self.frame.x - (width + padding) - local shit = self.frame.x - (width + padding) self.canvas_frame = { - x = shit, + x = xval, y = self.frame.y + 2, w = self.frame.w, h = self.frame.h, @@ -85,10 +111,12 @@ function Window:process(showIcons, currTabIdx) -- {{{ h = size, } + local focused = self:isFocused() + self.color_opts = { - bg = self.focused and focused_color or unfocused_color, - canvasAlpha = self.focused and 1 or 0.2, - imageAlpha = self.focused and 1 or 0.4, + bg = focused and focused_color or unfocused_color, + canvasAlpha = focused and 1 or 0.2, + imageAlpha = focused and 1 or 0.4, } self:draw_indicator() @@ -101,25 +129,30 @@ function Window:iconFromAppName() -- {{{ end -- }}} function Window:draw_indicator() -- {{{ + if self.indicator then + self.indicator:delete() + end + self.indicator = hs.canvas.new(self.canvas_frame) local width = self.indicator_rect.w - self.indicator:appendElements({ + self.indicator:appendElements{ type = "rectangle", action = "fill", fillColor = self.color_opts.bg, frame = self.indicator_rect, roundedRectRadii = {xRadius = 2.0, yRadius = 2.0}, - }) + } - if self.showIcons then - self.indicator:appendElements({ + if wsi.getShowIconsState() then + self.indicator:appendElements{ type = "image", image = self:iconFromAppName(), frame = self.indicator_rect, imageAlpha = self.color_opts.imageAlpha, - }) + } end + self.indicator:show() end -- }}} return Window diff --git a/utils/heap.lua b/utils/heap.lua new file mode 100644 index 0000000..f39b920 --- /dev/null +++ b/utils/heap.lua @@ -0,0 +1,181 @@ +-- ----------------------------------------------------------------------------- +-- FROM: https://github.com/luapower/heap/blob/master/heap.lua +-- ----------------------------------------------------------------------------- +-- Priority queue implemented as a binary heap. +-- Written by Cosmin Apreutesei. Public Domain. +if not ... then + require 'heap_test'; + return +end + +local ffi -- init on demand so that the module can be used without luajit +local assert, floor = assert, math.floor + +-- heap algorithm working over abstract API that counts from one. + +local function heap(add, remove, swap, length, cmp) + + local function moveup(child) + local parent = floor(child / 2) + while child > 1 and cmp(child, parent) do + swap(child, parent) + child = parent + parent = floor(child / 2) + end + return child + end + + local function movedown(parent) + local last = length() + local child = parent * 2 + while child <= last do + if child + 1 <= last and cmp(child + 1, child) then + child = child + 1 -- sibling is smaller + end + if not cmp(child, parent) then + break + end + swap(parent, child) + parent = child + child = parent * 2 + end + return parent + end + + local function push(...) + add(...) + return moveup(length()) + end + + local function pop(i) + swap(i, length()) + remove() + movedown(i) + end + + local function rebalance(i) + if moveup(i) == i then + movedown(i) + end + end + + return push, pop, rebalance +end + +-- cdata heap working over a cdata array + +local function cdataheap(h) + ffi = ffi or require 'ffi' + assert(h and h.size, 'size expected') + assert(h.size >= 2, 'size too small') + assert(h.ctype, 'ctype expected') + local ctype = ffi.typeof(h.ctype) + h.data = h.data or ffi.new(ffi.typeof('$[?]', ctype), h.size) + local t, n, maxn = h.data, h.length or 0, h.size - 1 + local function add(v) + n = n + 1; + t[n] = v + end + local function rem() + n = n - 1 + end + local function swap(i, j) + t[0] = t[i]; + t[i] = t[j]; + t[j] = t[0] + end + local function length() + return n + end + local cmp = h.cmp and function(i, j) + return h.cmp(t[i], t[j]) + end or function(i, j) + return t[i] < t[j] + end + local push, pop, rebalance = heap(add, rem, swap, length, cmp) + + local function get(i, box) + assert(i >= 1 and i <= n, 'invalid index') + if box then + box[0] = t[i] + else + return ffi.new(ctype, t[i]) + end + end + function h:push(v) + assert(n < maxn, 'buffer overflow') + push(v) + end + function h:pop(i, box) + assert(n > 0, 'buffer underflow') + local v = get(i or 1, box) + pop(i or 1) + return v + end + function h:peek(i, box) + return get(i or 1, box) + end + function h:replace(i, v) + assert(i >= 1 and i <= n, 'invalid index') + t[i] = v + rebalance(i) + end + h.length = length + + return h +end + +-- value heap working over a Lua table + +local function valueheap(h) + h = h or {} + local t, n = h, #h + local function add(v) + n = n + 1; + t[n] = v + end + local function rem() + t[n] = nil; + n = n - 1 + end + local function swap(i, j) + t[i], t[j] = t[j], t[i] + end + local function length() + return n + end + local cmp = h.cmp and function(i, j) + return h.cmp(t[i], t[j]) + end or function(i, j) + return t[i] < t[j] + end + local push, pop, rebalance = heap(add, rem, swap, length, cmp) + + local function get(i) + assert(i >= 1 and i <= n, 'invalid index') + return t[i] + end + function h:push(v) + assert(v ~= nil, 'invalid value') + push(v) + end + function h:pop(i) + assert(n > 0, 'buffer underflow') + local v = get(i or 1) + pop(i or 1) + return v + end + function h:peek(i) + return get(i or 1) + end + function h:replace(i, v) + assert(i >= 1 and i <= n, 'invalid index') + t[i] = v + rebalance(i) + end + h.length = length + + return h +end + +return {heap = heap, valueheap = valueheap, cdataheap = cdataheap} diff --git a/utils/utils.lua b/utils/utils.lua index 166c20d..5e17678 100644 --- a/utils/utils.lua +++ b/utils/utils.lua @@ -1,3 +1,6 @@ +-- OTHERS ---------------------------------------------------------------------- +-- https://github.com/luapower/glue/blob/master/glue.lua +-- ----------------------------------------------------------------------------- utils = {} utils.map = hs.fnutils.map @@ -227,8 +230,8 @@ function utils.print_r(root) local new_key = name .. "." .. key cache[v] = new_key tinsert(temp, "+" .. key .. - _dump(v, space .. (next(t, k) and "|" or " ") .. - srep(" ", #key), new_key)) + _dump(v, space .. (next(t, k) and "|" or " ") .. + srep(" ", #key), new_key)) else tinsert(temp, "+" .. key .. " [" .. tostring(v) .. "]") end @@ -304,4 +307,19 @@ function utils.groupBy(t, f) return res end +function utils.tableCopyShallow(orig) + -- FROM: https://github.com/XavierCHN/go/blob/master/game/go/scripts/vscripts/utils/table.lua + local orig_type = type(orig) + local copy + if orig_type == 'table' then + copy = {} + for orig_key, orig_value in pairs(orig) do + copy[orig_key] = orig_value + end + else -- number, string, boolean, etc + copy = orig + end + return copy +end + return utils From fd0ed5af7f5761e61fb71fffd6670fd6e0295b6d Mon Sep 17 00:00:00 2001 From: adamwagner Date: Thu, 6 Aug 2020 07:42:34 -0700 Subject: [PATCH 04/33] Bring over some changes from #13 --- stackline/core.lua | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/stackline/core.lua b/stackline/core.lua index 3997f19..257df53 100644 --- a/stackline/core.lua +++ b/stackline/core.lua @@ -1,22 +1,30 @@ +require("hs.ipc") + local _ = require 'stackline.utils.utils' local Stack = require 'stackline.stackline.stack' local tut = require 'stackline.utils.table-utils' --- This file is trash: lowercase globals, copy/paste duplication in --- update_stack_data_redraw() just to pass 'shouldClean':true :( - --- CHANGELOG: --- DONE: convert to use wsi.update method +function getOrSet(key, val) + local existingVal = hs.settings.get(key) + if existingVal == nil then + hs.settings.set(key, val) + return val + end + return existingVal +end local wf = hs.window.filter -wsi = Stack:newStackManager() +local showIcons = getOrSet("showIcons", false) + +wsi = Stack:newStackManager(showIcons) local win_added = { -- {{{ wf.windowCreated, wf.windowUnhidden, wf.windowUnminimized, } -- }}} + local win_changed = { -- {{{ -- TODO: rather than subscribing to windowFocused here, do it only for From 4436d40debce77df52c4171b9bb1ab92251b7a7a Mon Sep 17 00:00:00 2001 From: adamwagner Date: Thu, 6 Aug 2020 08:06:58 -0700 Subject: [PATCH 05/33] Bring over indicator styling improvements from #13 --- stackline/window.lua | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/stackline/window.lua b/stackline/window.lua index f940f43..3c8c713 100644 --- a/stackline/window.lua +++ b/stackline/window.lua @@ -89,17 +89,19 @@ function Window:process(Icons) -- {{{ local unfocused_color = {white = 0.9, alpha = 0.30} local focused_color = {white = 0.9, alpha = 0.99} local padding = 4 + local iconPadding = 4 local aspectRatio = 5 - local size = 25 + local size = 32 - local width = showIcons and size or (size / aspectRatio) + local offsetY = 2 + local offsetX = 4 + local width = showIcons and size or (size / aspectRatio) local currTabIdx = self.stackIdx - local xval = self.frame.x - (width + padding) self.canvas_frame = { - x = xval, - y = self.frame.y + 2, + x = self.frame.x - (width + offsetX), + y = self.frame.y + offsetY, w = self.frame.w, h = self.frame.h, } @@ -111,6 +113,13 @@ function Window:process(Icons) -- {{{ h = size, } + self.icon_rect = { + x = iconPadding, + y = self.indicator_rect.y + iconPadding, + w = self.indicator_rect.w - (iconPadding * 2), + h = self.indicator_rect.h - (iconPadding * 2), + } + local focused = self:isFocused() self.color_opts = { @@ -135,23 +144,29 @@ function Window:draw_indicator() -- {{{ self.indicator = hs.canvas.new(self.canvas_frame) + local showIcons = wsi.getShowIconsState() local width = self.indicator_rect.w + + -- TODO: configurable roundness radius for icons & pills + local radius = showIcons and (self.indicator_rect.w / 4.0) or 3.0 + self.indicator:appendElements{ type = "rectangle", action = "fill", fillColor = self.color_opts.bg, frame = self.indicator_rect, - roundedRectRadii = {xRadius = 2.0, yRadius = 2.0}, + roundedRectRadii = {xRadius = radius, yRadius = radius}, } - if wsi.getShowIconsState() then + if showIcons then self.indicator:appendElements{ type = "image", image = self:iconFromAppName(), - frame = self.indicator_rect, + frame = self.icon_rect, imageAlpha = self.color_opts.imageAlpha, } end + self.indicator:show() end -- }}} From 0a931225bcd09246c0eae01e30b97582a5c52f7b Mon Sep 17 00:00:00 2001 From: adamwagner Date: Thu, 6 Aug 2020 09:07:23 -0700 Subject: [PATCH 06/33] Move self.colorOpts into Window:drawIndicator() so that the latter can be called in place of Window:process() to redraw a window. --- stackline/window.lua | 46 ++++++++++++++++++++++---------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/stackline/window.lua b/stackline/window.lua index 3c8c713..dfb3a8a 100644 --- a/stackline/window.lua +++ b/stackline/window.lua @@ -83,21 +83,21 @@ function Window:setNeedsUpdated(extant) -- {{{ self.needsUpdated = not isEqual end -- }}} -function Window:process(Icons) -- {{{ +function Window:setupIndicator(Icons) -- {{{ -- Config local showIcons = wsi.getShowIconsState() - local unfocused_color = {white = 0.9, alpha = 0.30} - local focused_color = {white = 0.9, alpha = 0.99} - local padding = 4 - local iconPadding = 4 - local aspectRatio = 5 - local size = 32 + self.unfocused_color = {white = 0.9, alpha = 0.30} + self.focused_color = {white = 0.9, alpha = 0.99} + self.padding = 4 + self.iconPadding = 4 + self.aspectRatio = 5 + self.size = 32 - local offsetY = 2 - local offsetX = 4 + self.offsetY = 2 + self.offsetX = 4 - local width = showIcons and size or (size / aspectRatio) - local currTabIdx = self.stackIdx + self.width = showIcons and size or (size / aspectRatio) + self.currTabIdx = self.stackIdx self.canvas_frame = { x = self.frame.x - (width + offsetX), @@ -120,15 +120,7 @@ function Window:process(Icons) -- {{{ h = self.indicator_rect.h - (iconPadding * 2), } - local focused = self:isFocused() - - self.color_opts = { - bg = focused and focused_color or unfocused_color, - canvasAlpha = focused and 1 or 0.2, - imageAlpha = focused and 1 or 0.4, - } - - self:draw_indicator() + self:drawIndicator() end -- }}} @@ -137,7 +129,8 @@ function Window:iconFromAppName() -- {{{ return hs.image.imageFromAppBundle(appBundle) end -- }}} -function Window:draw_indicator() -- {{{ +function Window:drawIndicator() -- {{{ + -- print('calling drawIndicator for window', self.app, self.id) if self.indicator then self.indicator:delete() end @@ -149,11 +142,18 @@ function Window:draw_indicator() -- {{{ -- TODO: configurable roundness radius for icons & pills local radius = showIcons and (self.indicator_rect.w / 4.0) or 3.0 + local focused = self:isFocused() + + self.colorOpts = { + bg = focused and focused_color or unfocused_color, + canvasAlpha = focused and 1 or 0.2, + imageAlpha = focused and 1 or 0.4, + } self.indicator:appendElements{ type = "rectangle", action = "fill", - fillColor = self.color_opts.bg, + fillColor = self.colorOpts.bg, frame = self.indicator_rect, roundedRectRadii = {xRadius = radius, yRadius = radius}, } @@ -163,7 +163,7 @@ function Window:draw_indicator() -- {{{ type = "image", image = self:iconFromAppName(), frame = self.icon_rect, - imageAlpha = self.color_opts.imageAlpha, + imageAlpha = self.colorOpts.imageAlpha, } end From a6a4dd3aec6e60aacb78c897200b96fa4183dd98 Mon Sep 17 00:00:00 2001 From: adamwagner Date: Thu, 6 Aug 2020 09:23:07 -0700 Subject: [PATCH 07/33] A small bit of much-needed cleanup --- stackline/core.lua | 91 +++++++++++++++----------------------------- stackline/stack.lua | 87 +++++++++--------------------------------- stackline/window.lua | 53 ++++++++++++++------------ 3 files changed, 77 insertions(+), 154 deletions(-) diff --git a/stackline/core.lua b/stackline/core.lua index 257df53..13a9f43 100644 --- a/stackline/core.lua +++ b/stackline/core.lua @@ -3,20 +3,8 @@ require("hs.ipc") local _ = require 'stackline.utils.utils' local Stack = require 'stackline.stackline.stack' local tut = require 'stackline.utils.table-utils' - -function getOrSet(key, val) - local existingVal = hs.settings.get(key) - if existingVal == nil then - hs.settings.set(key, val) - return val - end - return existingVal -end - local wf = hs.window.filter -local showIcons = getOrSet("showIcons", false) - wsi = Stack:newStackManager(showIcons) local win_added = { -- {{{ @@ -26,19 +14,9 @@ local win_added = { -- {{{ } -- }}} local win_changed = { -- {{{ - - -- TODO: rather than subscribing to windowFocused here, do it only for - -- windows within a stack. This will shorten the update process for focus - -- changes, since we *only* need to update the indicators, not query for new - -- window state entirely. - -- wf.windowFocused, - -- wf.windowUnfocused, - wf.windowFullscreened, wf.windowUnfullscreened, - - -- NOTE: windowMoved captures movement OR resize events - wf.windowMoved, + wf.windowMoved, -- NOTE: windowMoved captures movement OR resize events } -- }}} -- combine added & changed events @@ -90,50 +68,41 @@ wsi.update() -- │ Update indicators subscriptions │ -- └─────────────────────────────────┘ -function indicatorActivate(hsWin) -- {{{ - local id = hsWin:id() - print('Focused', hsWin:application():name(), id) - - local win = wsi.findWindow(id) - - -- DEBUG {{{ - -- print('found window:', win.id) - -- print('indicator:', win.indicator, '\n\n') - -- print('Focused curr focus', win:isFocused()) - -- }}} - - -- NOTE: redraws indicator - -- TODO: rename win:process() to improve clarity! - win:process() +-- DONE: rather than subscribing to windowFocused here, do it only for +-- windows within a stack. This will shorten the update process for focus +-- changes, since we *only* need to update the indicators, not query for new +-- window state entirely. +-- wf.windowFocused, +-- wf.windowUnfocused, - -- NOTE: experiment to keep indicators in sync despite HS bug in which - -- windowUnfocused is not called when switching between two windows of the - -- same app :< - -- https://github.com/Hammerspoon/hammerspoon/issues/2400 - -- wsi.redrawAllIndicators() -end -- }}} +-- DONE: Parameterize Activate / Deactivate by reading event -function indicatorDeactivate(hsWin) -- {{{ +function redrawWinIndicator(hsWin, appName, event) local id = hsWin:id() - print('Unfocused', hsWin:application():name(), id) - - local win = wsi.findWindow(id) - - -- DEBUG {{{ - -- print('found window:', win.id) - -- print('Unfocused curr focus', win:isFocused()) - -- print('indicator:', win.indicator, '\n\n') - -- }}} - - -- NOTE: redraws indicator - -- TODO: rename win:process() to improve clarity! - win:process() -end -- }}} + print(event:gsub('window', ''), appName, id) + + -- Lookup *stacked* window by ID + -- If not found, then the focused/unfocused window is not stacked, + -- and this is a no-op. + local stackedWin = wsi.findWindow(id) + if stackedWin then -- if not found, then focused win is not stacked + -- -- DEBUG {{{ + -- print('found window:', win.id) + -- print('indicator:', win.indicator, '\n\n') + -- print('Focused curr focus', win:isFocused()) + -- -- }}} + stackedWin:drawIndicator() + end +end -wfd:subscribe(wf.windowFocused, indicatorActivate) +wfd:subscribe(wf.windowFocused, redrawWinIndicator) +wfd:subscribe({wf.windowNotVisible, wf.windowUnfocused}, redrawWinIndicator) -- HS BUG! windowUnfocused is not called when switching between windows of the -- same app - it's ONLY called when switching between windows of different apps -- https://github.com/Hammerspoon/hammerspoon/issues/2400 -wfd:subscribe({wf.windowNotVisible, wf.windowUnfocused}, indicatorDeactivate) + +-- I tried an experiment to redraw all indicators inside `redrawWinIndicator()` +-- every time, but it slowed down changing win focus A LOT: +-- wsi.redrawAllIndicators() diff --git a/stackline/stack.lua b/stackline/stack.lua index dc032ae..3a46742 100644 --- a/stackline/stack.lua +++ b/stackline/stack.lua @@ -1,16 +1,22 @@ +-- Utils +-- TODO: consolidate these utils! local _ = require 'stackline.utils.utils' -local Window = require 'stackline.stackline.window' -local u = require 'stackline.utils.underscore' -local tut = require 'stackline.utils.table-utils' -local under = require 'stackline.utils.underscore' +-- local u = require 'stackline.utils.underscore' +-- local tut = require 'stackline.utils.table-utils' +-- local under = require 'stackline.utils.underscore' +-- stackline modules local query = require 'stackline.stackline.WIP-query' +-- TODO: add proper logging -- local log = hs.logger.new('[stack]', 'debug') --- shortcuts +-- Convenience aliases local map = hs.fnutils.map +-- ┌──────────────┐ +-- │ Stack module │ +-- └──────────────┘ local Stack = {} function Stack:toggleIcons() -- {{{ @@ -19,9 +25,10 @@ function Stack:toggleIcons() -- {{{ end -- }}} function Stack:redrawIndicator(win) -- {{{ - -- NOTE: to be given Stack:eachWin() as argument + -- USAGE: pass this func to Stack:eachWin() print('calling redraw indicator') - win:process() + win:setupIndicator() + win:drawIndicator() end -- }}} function Stack:eachWin(fn) -- {{{ @@ -38,41 +45,15 @@ function Stack:redrawAllIndicators() end) end --- function Stack:each_win_id(fn) -- {{{ --- _.each(self.tabStacks, function(stack) --- -- hs.alert.show('running each win id') --- -- _.pheader('stack') --- -- _.p(stack) --- -- _.p(under.values(stack)) --- local winIds = _.map(under.values(stack), function(w) --- return w.id --- end) --- -- print(hs.inspect(winIds)) - --- for i = 1, #winIds do --- -- ┌────────────────────┐ --- -- the main event! --- -- └────────────────────┘ --- -- hs.alert.show(winIds[i]) - --- fn(winIds[i]) -- Call the `fn` provided with win ID - --- -- hs.alert.show('inside final loop') - --- -- DEBUG --- -- print(hs.inspect(winIds)) --- -- print(winIds[i]) --- end --- end) --- end -- }}} - function Stack:findWindow(wid) -- {{{ -- NOTE: A window must be *in* a stack to be found with this method! -- print('…searching for win id:', wid) for _stackId, stack in pairs(self.tabStacks) do + -- print('searching', #stack, 'windows in stackID', _stackId) for _idx, win in pairs(stack) do -- print('curr win id:', win.id) if win.id == wid then + -- print('found window', win.id) return win end end @@ -168,42 +149,12 @@ function Stack:ingest(windowData) -- {{{ end -- }}} function Stack:update(shouldClean) -- {{{ - if shouldClean then -- {{{ + if shouldClean then _.pheader('running cleanup') Stack:cleanup() - end -- }}} + end query:windowsCurrentSpace() -- calls Stack:ingest when ready - -- Stack:ingest(newState) - - -- DEBUG {{{ - -- print('\n\n\n\n') - -- _.pheader('self.tabStack after update') - -- self:get() - -- _.pheader('focused windows') - -- _.p(map(self:get(), function(stack) - -- _.each(stack, function(w) - -- print(w.id, ' is ', w.focused) - -- end) - -- end)) - -- print('\n\n\n\n') - - -- OLD --------------------------------------------------------------------- - -- -- _.pheader('value of "shouldClean:"') - -- -- _.p(shouldClean) - -- -- print('\n\n') - -- if shouldClean then - -- _.pheader('running cleanup') - -- Stack:cleanup() - -- end - - -- local yabai_get_stacks = 'stackline/bin/yabai-get-stacks' - - -- hs.task.new("/usr/local/bin/dash", function(_code, stdout) - -- local windowData = hs.json.decode(stdout) - -- Stack:ingest(windowData) - -- end, {yabai_get_stacks}):start() }}} - end -- }}} function Stack:get(shouldPrint) -- {{{ @@ -213,7 +164,7 @@ function Stack:get(shouldPrint) -- {{{ return self.tabStacks end -- }}} -function Stack:getShowIconsState(Print) -- {{{ +function Stack:getShowIconsState() -- {{{ return self.showIcons end -- }}} diff --git a/stackline/window.lua b/stackline/window.lua index dfb3a8a..228d8f4 100644 --- a/stackline/window.lua +++ b/stackline/window.lua @@ -86,42 +86,49 @@ end -- }}} function Window:setupIndicator(Icons) -- {{{ -- Config local showIcons = wsi.getShowIconsState() + + -- Color self.unfocused_color = {white = 0.9, alpha = 0.30} self.focused_color = {white = 0.9, alpha = 0.99} + + -- Padding self.padding = 4 self.iconPadding = 4 - self.aspectRatio = 5 + + -- Size + self.aspectRatio = 6 -- determines width of pills when showIcons = false self.size = 32 + self.width = showIcons and self.size or (self.size / self.aspectRatio) + -- Position self.offsetY = 2 - self.offsetX = 4 + self.offsetX = 2 - self.width = showIcons and size or (size / aspectRatio) - self.currTabIdx = self.stackIdx + -- Roundness + self.indicatorRadius = 3 + self.iconRadius = self.width / 4.0 self.canvas_frame = { - x = self.frame.x - (width + offsetX), - y = self.frame.y + offsetY, + x = self.frame.x - (self.width + self.offsetX), + y = self.frame.y + self.offsetY, w = self.frame.w, h = self.frame.h, } + -- NOTE: self.stackIdx comes from yabai self.indicator_rect = { x = 0, - y = ((currTabIdx - 1) * size * 1.1), - w = width, - h = size, + y = ((self.stackIdx - 1) * self.size * 1.1), + w = self.width, + h = self.size, } self.icon_rect = { - x = iconPadding, - y = self.indicator_rect.y + iconPadding, - w = self.indicator_rect.w - (iconPadding * 2), - h = self.indicator_rect.h - (iconPadding * 2), + x = self.iconPadding, + y = self.indicator_rect.y + self.iconPadding, + w = self.indicator_rect.w - (self.iconPadding * 2), + h = self.indicator_rect.h - (self.iconPadding * 2), } - - self:drawIndicator() - end -- }}} function Window:iconFromAppName() -- {{{ @@ -130,22 +137,18 @@ function Window:iconFromAppName() -- {{{ end -- }}} function Window:drawIndicator() -- {{{ + local showIcons = wsi.getShowIconsState() + local radius = showIcons and self.iconRadius or self.indicatorRadius + local focused = self:isFocused() + -- print('calling drawIndicator for window', self.app, self.id) if self.indicator then self.indicator:delete() end - self.indicator = hs.canvas.new(self.canvas_frame) - local showIcons = wsi.getShowIconsState() - local width = self.indicator_rect.w - - -- TODO: configurable roundness radius for icons & pills - local radius = showIcons and (self.indicator_rect.w / 4.0) or 3.0 - local focused = self:isFocused() - self.colorOpts = { - bg = focused and focused_color or unfocused_color, + bg = focused and self.focused_color or self.unfocused_color, canvasAlpha = focused and 1 or 0.2, imageAlpha = focused and 1 or 0.4, } From 719379b299e7aa14e5d7240e6fa90632a2330e7f Mon Sep 17 00:00:00 2001 From: adamwagner Date: Fri, 7 Aug 2020 02:24:52 -0700 Subject: [PATCH 08/33] Basic functionality working well. Parameterized display options. Indicators display on left/right edge of window based on which side of the screen the window resides. Known bug: changes broke multiple stacks on the same space. --- stackline/WIP-query.lua | 277 ---------------------------------------- stackline/core.lua | 40 +++--- stackline/query.lua | 234 +++++++++++++++++++++++++++++++++ stackline/stack.lua | 220 ++++--------------------------- stackline/stackMgr.lua | 146 +++++++++++++++++++++ stackline/window.lua | 157 +++++++++++++++-------- utils/utils.lua | 14 ++ 7 files changed, 541 insertions(+), 547 deletions(-) delete mode 100644 stackline/WIP-query.lua create mode 100644 stackline/query.lua create mode 100644 stackline/stackMgr.lua diff --git a/stackline/WIP-query.lua b/stackline/WIP-query.lua deleted file mode 100644 index d5633b9..0000000 --- a/stackline/WIP-query.lua +++ /dev/null @@ -1,277 +0,0 @@ -local _ = require 'stackline.utils.utils' -local spaces = require("hs._asm.undocumented.spaces") -local screen = require 'hs.screen' -local u = require 'stackline.utils.underscore' -local fnutils = require("hs.fnutils") - --- stackline modules -local Window = require 'stackline.stackline.window' - --- shortcuts -local map = hs.fnutils.map - -function clone(table) - _clone = {} - i, v = next(table, nil) - while i do - _clone[i] = v - i, v = next(table, i) - end - return _clone -end - ---[[ {{{ NOTES -The goal of this file is to eliminate the need to 'shell out' to yabai to query -window data needed to render stackline, which would address -https://github.com/AdamWagner/stackline/issues/8. The main problem with relying -on yabai is that a 0.03s sleep is required in the yabai script to ensure that -the changes that triggered hammerspoon's window event subscriber are, in fact, -represented in the query response from yabai. There are probably secondary -downsides, such as overall performance, and specifically *yabai* performance -(I've noticed that changing focus is slower when lots of yabai queries are -happening simultaneously). - -┌────────┐ -│ Status │ -└────────┘ -We're not yet using any of the code in this file to actually render the -indiators or query ata — all of that is still achieved via the "old" methods. - -However, this file IS being required by ./core.lua and runs one every window focus -event, and the resulting "stack" data is printed to the hammerspoon console. - -The stack data structure differs from that used in ./stack.lua enough that it -won't work as a drop-in replacement. I think that's fine (and it wouldn't be -worth attempting to make this a non-breaking change, esp. since zero people rely -on it as of 2020-08-02. - -┌──────┐ -│ Next │ -└──────┘ -- [ ] Integrate appropriate functionality in this file into the Stack module -- [ ] Update key Stack module functions to have basic compatiblity with the new data structure -- [ ] Simplify / refine Stack functions to leverage the benefits of having access to the hs.window module for each tracked window -- [ ] Integrate appropriate functionality in this file into the Core module -- [ ] … see if there's anything left and decide where it should live - -┌───────────┐ -│ WIP NOTES │ -└───────────┘ -Much of the functionality in this file should either be integrated into -stack.lua or core.lua — I don't think a new file is needed. - -Rather than calling out to the script ../bin/yabai-get-stacks, we're using -hammerspoon's mature (if complicated) hs.window.filter and hs.window modules to -achieve the same goal natively within hammerspon. - -There might be other benefits in addition to fixing the problems that inspired -#8: We get "free" access to the *hammerspoon* window module in the window data -tracked by stackline, which will probably make it easier to implement -enhancements that we haven't even considered yet. This approach should also be -easier to maintain, *and* we get to drop the jq dependency! - --- }}} --]] - --- Private functions -local wfd = hs.window.filter.new():setOverrideFilter{ -- {{{ - visible = true, -- (i.e. not hidden and not minimized) - fullscreen = false, - currentSpace = true, - allowRoles = 'AXStandardWindow', -}:setSortOrder(hs.window.filter.sortByCreated) -- }}} - -function makeStackId(win, winSpaceId) -- {{{ - -- generate stackId from spaceId & frame values - -- example: "302|35|63|1185|741" - local frame = win:frame():floor() - local x = frame.x - local y = frame.y - local w = frame.w - local h = frame.h - return table.concat({winSpaceId, x, y, w, h}, '|') -end -- }}} - -function lenGreaterThanOne(t) -- {{{ - return #t > 1 -end -- }}} - -function winToHs(win) -- {{{ - return win._win -end -- }}} - -local Query = {} -Query.focusedWindow = nil - -function Query:getWinStackIdxs() -- {{{ - local scriptPath = hs.configdir .. '/stackline/bin/yabai-get-stack-idx' - hs.task.new("/usr/local/bin/dash", function(_code, stdout, _stderr) - local winStackIdxs = hs.json.decode(stdout) - self.winStackIdxs = winStackIdxs - -- print('stack idxs are ', hs.inspect(winStackIdxs)) - end, {scriptPath}):start() -end -- }}} - -function mapWin(hsWindow) -- {{{ - local winData = { - stackId = makeStackId(hsWindow, hsWindow:spaces()[1]), - id = hsWindow:id(), - app = hsWindow:application():name(), - -- title = hsWindow:title(), - frame = hsWindow:frame(), - _win = hsWindow, - focused = (Query.focusedWindow == hsWindow), - } - return Window:new(winData) -end -- }}} - -function Query.getSpaces() -- {{{ - return fnutils.mapCat(screen.allScreens(), function(s) - return spaces.layout()[s:spacesUUID()] - end) -end -- }}} - -function Query.getActiveSpaceIndex() -- {{{ - local s = Query.getSpaces() - local activeSpace = spaces.activeSpace() - return _.indexOf(s, activeSpace) -end -- }}} - -function Query.stackOccluded(stack) -- {{{ - -- FIXES: When a stack that has "zoom-parent": 1 occludes another stack, the - -- occluded stack's indicators shouldn't be displaed - -- https://github.com/AdamWagner/stackline/issues/11 - - -- Returns true if any non-stack window occludes the stack's frame. - -- This can occur when an unstacked window is zoomed to cover a stack. - -- In this situation, we want to *hide* the occluded stack's indicators - -- TODO: Convert to Stack instance method (wouldn't need to pass in the 'stack' arg) - - function notInStack(hsWindow) - local stackWindowsHs = u.map(u.values(stack), winToHs) - local isInStack = u.include(stackWindowsHs, hsWindow) - return not isInStack - end - - -- NOTE: under.filter works with tables - -- _.filter only works with "list-like" tables - local nonStackWindows = u.filter(wfd:getWindows(), notInStack) - - function isStackInside(nonStackWindow) - local stackFrame = stack[1]._win:frame() - return stackFrame:inside(nonStackWindow:frame()) - end - - return u.any(_.map(nonStackWindows, isStackInside)) -end -- }}} - -function Query:makeStacksFromWindows(ws) -- {{{ - Query.focusedWindow = hs.window.focusedWindow() - local windows = u.map(ws, mapWin) - - -- DEBUG {{{ - -- print('\n\n\n\n\n\n') - -- _.pheader('Query.makestacksfromwindows') - -- _.p(windows, 2) }}} - - local groupedWindows = _.groupBy(windows, 'stackId') - - -- TODO: since we already need to shell out to yabai, we *could* do this by - -- intersecting windows with those that have a stack index - - -- stacks contain more than one window, - -- so ignore groups with only 1 window - stacks = hs.fnutils.filter(groupedWindows, lenGreaterThanOne) - self.stacks = stacks -end -- }}} - --- luacheck: ignore -function Query:setOccludedStacks(stacks) -- {{{ - -- NOTE: This *could* be a simple one-liner - local occludedStacks = _.map(stacks, Query.stackOccluded) - self.occludedStacks = occludedStacks - -- print('occluded stacks: ', hs.inspect(self.occludedStacks)) -end -- }}} - -function Query:winStackIdxsAreSet() - _.pheader('polling called') - local areSet = self.winStackIdxs ~= nil - -- print('ARE SET: ', areSet) - return areSet -end - -function Query:mergeWinStackIdxs() - hs.fnutils.each(self.stacks, function(stack) - hs.fnutils.each(stack, function(win) - -- print(win.id) - win.stackIdx = self.winStackIdxs[tostring(win.id)] - end) - end) -end - -local count = 0 - -function Query:windowsCurrentSpace() -- {{{ - self:getWinStackIdxs() -- set self.winStackIdxs (async shell call to yabai) - self:makeStacksFromWindows(wfd:getWindows()) -- set self.stacks - self:setOccludedStacks(self.stacks) -- set self.occludedStacks - - _.pheader('wfd:getWindows() in query') - for _idx, win in pairs(wfd:getWindows()) do - print(win:application():title(), ":", win:title()) - end - - -- Don't return until the yabai query is returned - function checkWinStackIdxsDone() -- {{{ - -- _.pheader('check stack idxs done') - -- Careful! These timers accumulate, tho it's less noticable with a fast polling interval - -- TODO: Find a way to cancel this if it's called again before completing? - return self:winStackIdxsAreSet() - end -- }}} - - function whenStackIdxDone() - - -- Add the stack indexes from yabai to the hs window data - self:mergeWinStackIdxs() - - -- _.p(self.stacks) - - local cloneStack = map(clone(self.stacks), function(stack) - local _stack = clone(stack) - - -- TODO: Decide whether the timestamp field is necessary, and if so, - -- store in temp var & delete key before looping over windows, then - -- restore at end. - -- NOTE: Removed to defer solving the problem of ignoring the - -- timestamp field for now. - -- → → _stack.timestamp = hs.timer.absoluteTime() - - return _stack - end) - - -- FIXME: self.stacks is being past to ingest as a *reference)out*, so - -- changes in the Stack module affect self.stacks - - -- After trying many different deepcopy methods, that path seemed unworkable. - -- Next, I thoughtabout using metatables? But this also seems not quite right. - -- https://stackoverflow.com/questions/18177101/hiding-a-lua-metatable-and-only-exposing-an-objects-attributes - -- It does make me think of a todo, tho: - -- NOTE: Found that hs.fnutils has a copy function! hs.fnutils.copy. Found in https://github.com/CommandPost/CommandPost/blob/develop/src/extensions/cp/watcher/init.lua - -- TODO: Create an actual "Stack" class that represents a single stack. - -- The current "Stack" class is *actually* a stack manager. - - -- NOTE: This is only being called ONCE per change (as desired), but - -- wsi: - count = count + 1 - print('Query module calling stack:ingest (' .. count .. ' times total)') - - -- NOTE: must require here to avoid circular dependency - -- & "Too many C levels" error - require('stackline.stackline.stack'):ingest(cloneStack) -- hand over to the Stack module - -- local stacks = wsi.ingest(self.stacks) - end - - local pollingInterval = 0.1 - hs.timer.waitUntil(checkWinStackIdxsDone, whenStackIdxDone, pollingInterval) -end -- }}} -return Query - diff --git a/stackline/core.lua b/stackline/core.lua index 13a9f43..a0b7068 100644 --- a/stackline/core.lua +++ b/stackline/core.lua @@ -1,11 +1,23 @@ require("hs.ipc") local _ = require 'stackline.utils.utils' -local Stack = require 'stackline.stackline.stack' +local StackMgr = require 'stackline.stackline.stackMgr' local tut = require 'stackline.utils.table-utils' local wf = hs.window.filter -wsi = Stack:newStackManager(showIcons) +-- shortcuts +map = hs.fnutils.map +filter = hs.fnutils.filter +each = hs.fnutils.each +copy = hs.fnutils.copy + +stacksMgr = StackMgr:new(showIcons) +-- _.pheader('stackmanager after construction') +-- _.p(stacksManager) + +hs.hotkey.bind({'alt', 'ctrl'}, 't', function() + stacksMgr:toggleIcons() +end) local win_added = { -- {{{ wf.windowCreated, @@ -29,7 +41,7 @@ local win_removed = { -- {{{ wf.windowNotInCurrentSpace, } -- }}} -local wfd = wf.new():setOverrideFilter{ -- {{{ +wfd = wf.new():setOverrideFilter{ -- {{{ visible = true, -- (i.e. not hidden and not minimized) fullscreen = false, currentSpace = true, @@ -42,8 +54,8 @@ local wfd = wf.new():setOverrideFilter{ -- {{{ -- This extension makes it simple to defer multiple actions after a delay from the initial execution. -- Unlike `hs.timer.delayed`, the delay will not be extended -- with subsequent `run()` calls, but the delay will trigger again if `run()` is called again later. -local queryWindowState = hs.timer.delayed.new(0.10, function() - wsi.update() +local queryWindowState = hs.timer.delayed.new(0.30, function() + stacksMgr:update() end) -- ┌──────────────────────────────────┐ @@ -51,18 +63,15 @@ end) -- └──────────────────────────────────┘ -- callback args: window, app, event wfd:subscribe(added_changed, function() - -- wsi.update() queryWindowState:start() end) wfd:subscribe(win_removed, function() - -- TODO: implement cleanup in a better way - -- wsi:update(true) queryWindowState:start() end) -- call once on load -wsi.update() +stacksMgr:update() -- ┌─────────────────────────────────┐ -- │ Update indicators subscriptions │ @@ -80,18 +89,9 @@ wsi.update() function redrawWinIndicator(hsWin, appName, event) local id = hsWin:id() print(event:gsub('window', ''), appName, id) - - -- Lookup *stacked* window by ID - -- If not found, then the focused/unfocused window is not stacked, - -- and this is a no-op. - local stackedWin = wsi.findWindow(id) + local stackedWin = stacksMgr:findWindow(id) if stackedWin then -- if not found, then focused win is not stacked - -- -- DEBUG {{{ - -- print('found window:', win.id) - -- print('indicator:', win.indicator, '\n\n') - -- print('Focused curr focus', win:isFocused()) - -- -- }}} - stackedWin:drawIndicator() + stackedWin:drawIndicator({shouldFade = false}) -- draw instantly on focus change end end diff --git a/stackline/query.lua b/stackline/query.lua new file mode 100644 index 0000000..a3cd535 --- /dev/null +++ b/stackline/query.lua @@ -0,0 +1,234 @@ +-- luacheck: ignore (augments hs.window module) +local spaces = require("hs._asm.undocumented.spaces") +local _ = require 'stackline.utils.utils' +local u = require 'stackline.utils.underscore' + +-- stackline modules +local Window = require 'stackline.stackline.window' + +--[[ {{{ NOTES +The goal of this file is to eliminate the need to 'shell out' to yabai to query +window data needed to render stackline, which would address +https://github.com/AdamWagner/stackline/issues/8. The main problem with relying +on yabai is that a 0.03s sleep is required in the yabai script to ensure that +the changes that triggered hammerspoon's window event subscriber are, in fact, +represented in the query response from yabai. There are probably secondary +downsides, such as overall performance, and specifically *yabai* performance +(I've noticed that changing focus is slower when lots of yabai queries are +happening simultaneously). + +┌────────┐ +│ Status │ +└────────┘ +We're not yet using any of the code in this file to actually render the +indiators or query ata — all of that is still achieved via the "old" methods. + +However, this file IS being required by ./core.lua and runs one every window focus +event, and the resulting "stack" data is printed to the hammerspoon console. + +The stack data structure differs from that used in ./stack.lua enough that it +won't work as a drop-in replacement. I think that's fine (and it wouldn't be +worth attempting to make this a non-breaking change, esp. since zero people rely +on it as of 2020-08-02. + +┌──────┐ +│ Next │ +└──────┘ +- [ ] Integrate appropriate functionality in this file into the Core module +- [ ] Integrate appropriate functionality in this file into the Stack module +- [x] Update key Stack module functions to have basic compatiblity with the new data structure +- [x] Simplify / refine Stack functions to leverage the benefits of having access to the hs.window module for each tracked window +- [ ] … see if there's anything left and decide where it should live + +┌───────────┐ +│ WIP NOTES │ +└───────────┘ +Much of the functionality in this file should either be integrated into +stack.lua or core.lua — I don't think a new file is needed. + +Rather than calling out to the script ../bin/yabai-get-stacks, we're using +hammerspoon's mature (if complicated) hs.window.filter and hs.window modules to +achieve the same goal natively within hammerspon. + +There might be other benefits in addition to fixing the problems that inspired +#8: We get "free" access to the *hammerspoon* window module in the window data +tracked by stackline, which will probably make it easier to implement +enhancements that we haven't even considered yet. This approach should also be +easier to maintain, *and* we get to drop the jq dependency! + +-- }}} --]] + +-- Private functions +local wfd = hs.window.filter.new():setOverrideFilter{ -- {{{ + visible = true, -- (i.e. not hidden and not minimized) + fullscreen = false, + currentSpace = true, + allowRoles = 'AXStandardWindow', +}:setSortOrder(hs.window.filter.sortByCreated) -- }}} + +function lenGreaterThanOne(t) -- {{{ + return #t > 1 +end -- }}} + +function winToHs(win) -- {{{ + return win._win +end -- }}} + +local scriptPath = hs.configdir .. '/stackline/bin/yabai-get-stack-idx' +local Query = {} +Query.focusedWindow = nil + +function Query:getWinStackIdxs() -- {{{ + hs.task.new("/usr/local/bin/dash", function(_code, stdout, _stderr) + self.winStackIdxs = hs.json.decode(stdout) + end, {scriptPath}):start() +end -- }}} + +-- function Query.getSpaces() -- {{{ +-- return fnutils.mapCat(screen.allScreens(), function(s) +-- return spaces.layout()[s:spacesUUID()] +-- end) +-- end -- }}} + +-- function Query.getActiveSpaceIndex() -- {{{ +-- local s = Query.getSpaces() +-- local activeSpace = spaces.activeSpace() +-- return _.indexOf(s, activeSpace) +-- end -- }}} + +function Query:makeStacksFromWindows(ws) -- {{{ + local windows = map(ws, function(w) + return Window:new(w) + end) + local groupedWindows = _.groupBy(windows, 'stackId') + + -- stacks contain more than one window, + -- so ignore groups with only 1 window + stacks = hs.fnutils.filter(groupedWindows, lenGreaterThanOne) + self.stacks = stacks +end -- }}} + +function isStackOccluded(stack) -- {{{ + -- FIXES: When a stack that has "zoom-parent": 1 occludes another stack, the + -- occluded stack's indicators shouldn't be displaed + -- https://github.com/AdamWagner/stackline/issues/11 + + -- Returns true if any non-stack window occludes the stack's frame. + -- This can occur when an unstacked window is zoomed to cover a stack. + -- In this situation, we want to *hide* the occluded stack's indicators + -- TODO: Convert to Stack instance method (wouldn't need to pass in the 'stack' arg) + + function notInStack(hsWindow) + local stackWindowsHs = u.map(stack, winToHs) + local isInStack = u.include(stackWindowsHs, hsWindow) + return not isInStack + end + + -- NOTE: under.filter works with tables + -- _.filter only works with "list-like" tables + local nonStackWindows = u.filter(wfd:getWindows(), notInStack) + _.pheader('nonstackwindows') + _.p(nonStackWindows) + + function isStackInside(nonStackWindow) + -- _.pheader('stack in is stack inside') + -- _.p(stack.windows[1]) + local stackFrame = stack.windows[1]._win:frame() + return stackFrame:inside(nonStackWindow:frame()) + end + + local stackIsOccluded = u.any(map(nonStackWindows, isStackInside)) + print("\nstack is occluded", stackIsOccluded) + return stackIsOccluded +end -- }}} + +-- luacheck: ignore +function Query:dimOccludedStacks(stacks) -- {{{ + each(filter(stacks, isStackOccluded), function(stack) + _.pheader('dimming occluded indicators') + print('\n\noccluded stack id', #stack.windows) + stack:dimAllIndicators() + end) +end -- }}} + +function Query:mergeWinStackIdxs() -- {{{ + hs.fnutils.each(self.stacks, function(stack) + hs.fnutils.each(stack, function(win) + -- print(win.id) + win.stackIdx = self.winStackIdxs[tostring(win.id)] + end) + end) +end -- }}} + +function shouldRestack(new) -- {{{ + local curr = stacksMgr:getSummary() + local new = stacksMgr:getSummary(u.values(new)) + + -- _.p(curr) + -- _.p(new) + + if curr.numStacks ~= new.numStacks then + _.pheader('number of stacks changed') + return true + end + + if not _.equal(curr.topLeft, new.topLeft) then + _.pheader('position changed') + _.p(curr.topLeft) + _.p(new.topLeft) + return true + end + + if not _.equal(curr.numWindows, new.numWindows) then + _.pheader('num windows changed') + return true + end +end -- }}} + +local count = 0 +function Query:windowsCurrentSpace() -- {{{ + self:makeStacksFromWindows(wfd:getWindows()) -- set self.stacks + + -- Analyze self.stacks to determine if a stack refresh is needed + -- • change space + -- • change num stacks (+/-) + -- • changes to existing stack + -- • change num windows (covers win added / removed) + -- • change position + + local shouldRefresh = false -- tmp var mocking ↑ + + local extantStacks = stacksMgr:get() + local extantStackSummary = stacksMgr:getSummary() + local extantStackExists = extantStackSummary.numStacks > 0 + + if extantStackExists then + shouldRefresh = shouldRestack(self.stacks, extantStacks) + -- self:dimOccludedStacks(extantStacks) -- set self.occludedStacks + else + shouldRefresh = true + end + + if shouldRefresh then + self:getWinStackIdxs() -- set self.winStackIdxs (async shell call to yabai) + + -- DEBUG {{{ + -- _.pheader('wfd:getWindows() in query') + -- for _idx, win in pairs(wfd:getWindows()) do + -- print(win:application():title(), ":", win:title()) + -- end + -- }}} + + function whenStackIdxDone() + self:mergeWinStackIdxs() -- Add the stack indexes from yabai to the hs window data + stacksMgr:ingest(self.stacks, extantStackExists) -- hand over to the Stack module + end + + local pollingInterval = 0.1 + hs.timer.waitUntil(function() + return self.winStackIdxs ~= nil + end, whenStackIdxDone, pollingInterval) + end +end -- }}} +return Query + diff --git a/stackline/stack.lua b/stackline/stack.lua index 3a46742..0a60885 100644 --- a/stackline/stack.lua +++ b/stackline/stack.lua @@ -1,214 +1,38 @@ --- Utils --- TODO: consolidate these utils! local _ = require 'stackline.utils.utils' --- local u = require 'stackline.utils.underscore' --- local tut = require 'stackline.utils.table-utils' --- local under = require 'stackline.utils.underscore' --- stackline modules -local query = require 'stackline.stackline.WIP-query' - --- TODO: add proper logging --- local log = hs.logger.new('[stack]', 'debug') - --- Convenience aliases -local map = hs.fnutils.map - --- ┌──────────────┐ --- │ Stack module │ --- └──────────────┘ local Stack = {} -function Stack:toggleIcons() -- {{{ - self.showIcons = not self.showIcons - self:redrawAllIndicators() +function Stack:new(stackData) -- {{{ + self.windows = stackData + return self end -- }}} - -function Stack:redrawIndicator(win) -- {{{ - -- USAGE: pass this func to Stack:eachWin() - print('calling redraw indicator') - win:setupIndicator() - win:drawIndicator() +function Stack:get() -- {{{ + return self end -- }}} - function Stack:eachWin(fn) -- {{{ - for _stackId, stack in pairs(self.tabStacks) do - for _idx, win in pairs(stack) do - fn(win) - end + for _idx, win in pairs(self.windows) do + fn(win) end end -- }}} - -function Stack:redrawAllIndicators() +function Stack:redrawAllIndicators() -- {{{ self:eachWin(function(win) - self:redrawIndicator(win) + print('calling redraw indicator') + -- TODO see if it works *without* win:setupIndicator + win:setupIndicator() + win:drawIndicator() end) -end - -function Stack:findWindow(wid) -- {{{ - -- NOTE: A window must be *in* a stack to be found with this method! - -- print('…searching for win id:', wid) - for _stackId, stack in pairs(self.tabStacks) do - -- print('searching', #stack, 'windows in stackID', _stackId) - for _idx, win in pairs(stack) do - -- print('curr win id:', win.id) - if win.id == wid then - -- print('found window', win.id) - return win - end - end - end end -- }}} - -function Stack:cleanup() -- {{{ - -- _.p('# to be cleaned: ', _.length(self.tabStacks)) - -- _.p('keys be cleaned: ', _.keys(self.tabStacks)) - - for key, stack in pairs(self.tabStacks) do - -- DEBUG: {{{ - -- _.p(stack) - -- _.pheader('stack keys') - -- _.p(_.map(stack, function(w) - -- return _.pick(w, {'id', 'app'}) - -- end)) }}} - - -- For each window, clear canvas element - _.each(stack, function(w) - -- _.pheader('window indicator in cleanup') - -- print(w.indicator) - w.indicator:delete() - end) - - self.tabStacks[key] = nil - end -end -- }}} - -i = 1 -local heap = require 'stackline.utils.heap' -- {{{ -h = heap.valueheap { - cmp = function(a, b) - return a.timestamp < b.timestamp - end, -} -- }}} - -function stackDiff() -- {{{ - local curr = h:pop() - local last = h:pop() - - _.pheader('stack diff:') - print("current: ", curr.timestamp) - print("last: ", last.timestamp) - print("diff: ", curr.timestamp - last.timestamp) - -- for _winIdx, w in pairs(curr) do - -- print(hs.inspect(w, {depth = 1})) - -- end -end -- }}} - -local clearConsoleAfterNewStack = hs.timer.delayed.new(5, - hs.console.clearConsole) - -function Stack:newStack(stack, stackId) -- {{{ - -- clearConsoleAfterNewStack:start() - print('new stack:', stackId, 'w/', #stack, 'wins') - - -- UPDATE: tentative solution found in hs.timer.delayed - -- FIXME: The problem with this is that there are multiple event - -- subscribers, and so Stack.update() is being called multiple times per - -- actual "change" event, so typically the last two stacks in diff() are the same. - -- ACTUALLY — is this fine? Much of the time, a stack WOULD be the same, - -- even if events *were* debounced / consolidated. The diff will tell the - -- story. The timestamp just ensrues that they stay in the right order. - -- FTNOTE: Well, it's not "fine" — it's very bad from a performance POV. - -- "Fine" above was only in reference to WRT to detecting state changes & - -- reacting appropriately. - - self.tabStacks[stackId] = stack - self:redrawAllIndicators() - - -- for _winIdx, w in pairs(stack) do - -- print(hs.inspect(w, {depth = 1})) - -- end -end -- }}} - -function Stack:ingest(windowData) -- {{{ - - -- TODO: track stacks in heap here instead of in Stack:newStack() - -- IDEA: which MIGHT solve the problem of the problematic timestamp key — we - -- could delete it before "ingestion"! - -- NOTE: disabled until solution to timestamp key is implemented - - -- h:push(stack) - -- print("heap length: ", h:length()) - -- if h:length() > 2 then - -- stackDiff() - -- end - - for stackId, stackWindows in pairs(windowData) do - Stack:newStack(stackWindows, stackId) - end -end -- }}} - -function Stack:update(shouldClean) -- {{{ - if shouldClean then - _.pheader('running cleanup') - Stack:cleanup() - end - - query:windowsCurrentSpace() -- calls Stack:ingest when ready -end -- }}} - -function Stack:get(shouldPrint) -- {{{ - if shouldPrint then - _.p(self.tabStacks, 3) - end - return self.tabStacks -end -- }}} - -function Stack:getShowIconsState() -- {{{ - return self.showIcons +function Stack:deleteAllIndicators() -- {{{ + self:eachWin(function(win) + print('calling delete indicator') + win:deleteIndicator() + end) end -- }}} - -function Stack:newStackManager() -- {{{ - self.tabStacks = {} - self.showIcons = false - - -- TODO: is it really necessary to expose methods like this? Or could other - -- modules just invoke the instance method, e.g., wsi:get() if we used - -- setmetatable to merge Stack methods with instance ala .window.lua:52 - return { - ingest = function(windowData) - return self:ingest(windowData) - end, - get = function() - return self:get() - end, - getShowIconsState = function() - return self:getShowIconsState() - end, - getCache = function() - return cache - end, - update = self.update, - cleanup = function() - return self:cleanup() - end, - toggleIcons = function() - return self:toggleIcons() - end, - findWindow = function(wid) - return self:findWindow(wid) - end, - each_win = function(wid) - return self:each_win_id(wid) - end, - get_win_str = function() - return Stack.win_str - end, - redrawAllIndicators = function() - return self:redrawAllIndicators() - end, - } +function Stack:dimAllIndicators() -- {{{ + self:eachWin(function(win) + print('calling delete indicator') + win:drawIndicator({unfocusedAlpha = 1}) + end) end -- }}} return Stack - diff --git a/stackline/stackMgr.lua b/stackline/stackMgr.lua new file mode 100644 index 0000000..c3a2e4a --- /dev/null +++ b/stackline/stackMgr.lua @@ -0,0 +1,146 @@ +-- TODO: consolidate these utils! +local _ = require 'stackline.utils.utils' + +-- stackline modules +local Query = require 'stackline.stackline.query' +local Stack = require 'stackline.stackline.stack' + +-- ┌──────────────┐ +-- │ Stack module │ +-- └──────────────┘ + +-- HEAP (unused for now) {{{ +i = 1 +local heap = require 'stackline.utils.heap' -- {{{ +h = heap.valueheap { + cmp = function(a, b) + _.pheader('compare in heap') + print('A:') + _.p(a) + print('B:') + _.p(b) + return a.timestamp < b.timestamp + end, +} -- }}} +function Stack:heapPush(stacks) -- {{{ + -- TODO: track stacks in heap here instead of in Stack:newStack() + local cloneStack = hs.fnutils.copy(stacks) + cloneStack.timestamp = hs.timer.absoluteTime() -- add timestamp for heap / diff + h:push(cloneStack) + if h:length() > 2 then + stackDiff() + end +end -- }}} +function stackDiff() -- {{{ + local curr = h:pop() + local last = h:pop() + + _.pheader('stack diff:') + print("current: ", curr.timestamp) + print("last: ", last.timestamp) + print("diff: ", curr.timestamp - last.timestamp) + + -- for _winIdx, w in pairs(curr) do + -- print(hs.inspect(w, {depth = 1})) + -- end +end -- }}} +-- }}} + +local StacksMgr = {} +function StacksMgr:update() -- {{{ + Query:windowsCurrentSpace() -- calls Stack:ingest when ready +end -- }}} + +-- FIXME: Doesn't wor with multiple tab stacks on same screen (?!) +function StacksMgr:new() -- {{{ + self.tabStacks = {} + self.showIcons = true + return self +end -- }}} + +function StacksMgr:ingest(stacks, shouldClean) -- {{{ + -- self:heapPush(stacks) + if shouldClean then + _.pheader('running cleanup') + self:cleanup() + end + for _stackId, stack in pairs(stacks) do + _.pheader('new stack') + _.p(stack) + table.insert(self.tabStacks, Stack:new(stack)) + _.pheader('stacksMngr.tabStacks afterward') + _.p(self.tabStacks) + self:redrawAllIndicators() + end +end -- }}} + +function StacksMgr:get() -- {{{ + return self.tabStacks +end -- }}} + +function StacksMgr:eachStack(fn) -- {{{ + for _stackId, stack in pairs(self.tabStacks) do + fn(stack) + end +end -- }}} + +function StacksMgr:cleanup() -- {{{ + _.pheader('calling cleanup') + self:eachStack(function(stack) + print('calling stackMgr:deleteAllIndicators()') + stack:deleteAllIndicators() + end) + self.tabStacks = {} +end -- }}} + +function StacksMgr:getSummary(external) -- {{{ + local stacks = external or self.tabStacks + return { + numStacks = #stacks, + topLeft = map(stacks, function(s) + local windows = external and s or s.windows + return windows[1].topLeft + end), + dimensions = map(stacks, function(s) + local windows = external and s or s.windows + return windows[1].stackId + end), + numWindows = map(stacks, function(s) + local windows = external and s or s.windows + return #windows + end), + } +end -- }}} + +function StacksMgr:redrawAllIndicators() -- {{{ + self:eachStack(function(stack) + stack:redrawAllIndicators() + end) +end -- }}} + +function StacksMgr:toggleIcons() -- {{{ + self.showIcons = not self.showIcons + self:redrawAllIndicators() +end -- }}} + +function StacksMgr:findWindow(wid) -- {{{ + -- NOTE: A window must be *in* a stack to be found with this method! + -- print('…searchi win id:', wid) + for _stackId, stack in pairs(self.tabStacks) do + -- print('searching', #stack, 'windows in stackID', _stackId) + for _idx, win in pairs(stack.windows) do + print('searching', win.id, 'for', wid) + if win.id == wid then + -- print('found window', win.id) + return win + end + end + end +end -- }}} + +function StacksMgr:getShowIconsState() -- {{{ + return self.showIcons +end -- }}} + +return StacksMgr + diff --git a/stackline/window.lua b/stackline/window.lua index 228d8f4..a8f752b 100644 --- a/stackline/window.lua +++ b/stackline/window.lua @@ -1,26 +1,46 @@ local _ = require 'stackline.utils.utils' +local u = require 'stackline.utils.underscore' + +function makeStackId(win) -- {{{ + -- stackId is top-left window frame coordinates + -- example: "302|35|63|1185|741" + -- OLD definition: + -- generate stackId from spaceId & frame values + -- example (old): "302|35|63|1185|741" + local frame = win:frame():floor() + local x = frame.x + local y = frame.y + local w = frame.w + local h = frame.h + return { + topLeft = table.concat({x, y}, '|'), + stackId = table.concat({x, y, w, h}, '|'), + } +end -- }}} + +-- ┌───────────────┐ +-- │ Window module │ +-- └───────────────┘ local Window = {} --- FROM: How to chain metatables: https://stackoverflow.com/questions/8109790/chain-lua-metatables -local metatbl = {} -- luacheck: ignore -function metatbl.__index(intbl, key) -- {{{ - -- luacheck: ignore - for i, mtbl in ipairs(metatbl.tbls) do - local mmethod = mtbl.__index - if (type(mmethod) == "function") then - local ret = mmethod(table, key) - if ret then - return ret - end - else - if mmethod[key] then - return mmethod[key] - end - end - return nil - end +function Window:new(hsWin) -- {{{ + local ws = { + -- title = w:title(), -- window title for debug only (string) + app = hsWin:application():name(), -- app name (string) + id = hsWin:id(), -- window id (string) NOTE: the ID is the same as yabai! So we could interopt if we need to + frame = hsWin:frame(), -- x,y,w,h of window (table) + stackIdx = hsWin.stackIdx, -- only from yabai, unfort. + stackId = makeStackId(hsWin).stackId, -- "{{x}|{y}|{w}|{h}" e.g., "35|63|1185|741" (string) + topLeft = makeStackId(hsWin).topLeft, -- "{{x}|{y}" e.g., "35|63" (string) + _win = hsWin, -- hs.window object (table) + indicator = nil, -- the canvas element (table) + } + + setmetatable(ws, self) + self.__index = self + return ws end -- }}} function Window:setStackIdx() -- {{{ @@ -36,24 +56,6 @@ function Window:setStackIdx() -- {{{ end, {scriptPath, tostring(self.id)}):start():waitUntilExit() end -- }}} --- luacheck: ignore -function Window:new(w) -- {{{ - local ws = { - app = w.app, -- app name (string) - id = w.id, -- window id (string) NOTE: the ID is the same as yabai! So we could interopt if we need to - title = w.title, -- window title (string) - _win = w._win, -- hs.window object (table) - frame = w.frame, -- x,y,w,h of window (table) - -- stackIdx = w.stackIdx, -- from yabai :( - stackId = w.stackId, -- "{spaceId}|{x}|{y}|{w}|{h}" e.g., "302|35|63|1185|741" (string) - indicator = nil, -- the canvas element (table) - } - - setmetatable(ws, self) - self.__index = self - return ws -end -- }}} - function Window:isFocused() -- {{{ local focusedWin = hs.window.focusedWindow() if focusedWin == nil then @@ -77,6 +79,24 @@ end -- }}} -- return isEqual -- end -- }}} +function Window:getScreenSide() + -- (sFrame.w - (wFrame.x + wFrame.w)) / sFrame.w + local screenWidth = self._win:screen():fullFrame().w + local frame = self.frame + local percRight = 1 - ((screenWidth - (frame.x + frame.w)) / screenWidth) + local percLeft = (screenWidth - frame.x) / screenWidth + local side = (percRight > 0.95 and percLeft < 0.95) and 'right' or 'left' + + return side + + -- TODO: find a way to use hs.window.filter.windowsTo{Dir} + -- to determine side instead of percLeft/Right ↑ + -- https://www.hammerspoon.org/docs/hs.window.filter.html#windowsToWest + -- wfd:windowsToWest(self._win) + -- https://www.hammerspoon.org/docs/hs.window.html#windowsToWest + -- self._win:windowsToSouth() +end + -- TODO: ↑ Convert to .__eq metatable function Window:setNeedsUpdated(extant) -- {{{ local isEqual = _.isEqual(existComp, currComp) @@ -85,11 +105,7 @@ end -- }}} function Window:setupIndicator(Icons) -- {{{ -- Config - local showIcons = wsi.getShowIconsState() - - -- Color - self.unfocused_color = {white = 0.9, alpha = 0.30} - self.focused_color = {white = 0.9, alpha = 0.99} + local showIcons = stacksMgr:getShowIconsState() -- Padding self.padding = 4 @@ -102,14 +118,29 @@ function Window:setupIndicator(Icons) -- {{{ -- Position self.offsetY = 2 - self.offsetX = 2 + self.offsetX = 4 + + -- Overlapped with window + percent top offset + -- self.offsetY = self.frame.h * 0.1 + -- self.offsetX = -(self.width / 2) -- Roundness self.indicatorRadius = 3 self.iconRadius = self.width / 4.0 + -- Fade-in/out duration + self.fadeDuration = 0.2 + + local side = self:getScreenSide() + local xval = nil + if side == 'right' then + xval = (self.frame.x + self.frame.w) + self.offsetX + else + xval = self.frame.x - (self.width + self.offsetX) + end + self.canvas_frame = { - x = self.frame.x - (self.width + self.offsetX), + x = xval, y = self.frame.y + self.offsetY, w = self.frame.w, h = self.frame.h, @@ -131,16 +162,28 @@ function Window:setupIndicator(Icons) -- {{{ } end -- }}} -function Window:iconFromAppName() -- {{{ - appBundle = hs.appfinder.appFromName(self.app):bundleID() - return hs.image.imageFromAppBundle(appBundle) -end -- }}} +function Window:drawIndicator(overrideOpts) -- {{{ + local defaultOpts = { + shouldFade = true, + focusedAlpha = 1, + unfocusedAlpha = 0.33, + } + + local opts = u.extend(defaultOpts, overrideOpts or {}) -function Window:drawIndicator() -- {{{ - local showIcons = wsi.getShowIconsState() + -- Unfocused icons should less transparent + -- than the bg color, but no more than 1 + local unfocusedIconAlpha = math.min(opts.unfocusedAlpha * 2, 1) + + local showIcons = stacksMgr:getShowIconsState() local radius = showIcons and self.iconRadius or self.indicatorRadius + local fadeDuration = opts.shouldFade and self.fadeDuration or 0 local focused = self:isFocused() + -- Color + self.unfocused_color = {white = 0.9, alpha = opts.unfocusedAlpha} + self.focused_color = {white = 0.9, alpha = opts.focusedAlpha} + -- print('calling drawIndicator for window', self.app, self.id) if self.indicator then self.indicator:delete() @@ -149,8 +192,7 @@ function Window:drawIndicator() -- {{{ self.colorOpts = { bg = focused and self.focused_color or self.unfocused_color, - canvasAlpha = focused and 1 or 0.2, - imageAlpha = focused and 1 or 0.4, + imageAlpha = focused and opts.focusedAlpha or unfocusedIconAlpha, } self.indicator:appendElements{ @@ -170,7 +212,18 @@ function Window:drawIndicator() -- {{{ } end - self.indicator:show() + self.indicator:show(fadeDuration) +end -- }}} + +function Window:iconFromAppName() -- {{{ + appBundle = hs.appfinder.appFromName(self.app):bundleID() + return hs.image.imageFromAppBundle(appBundle) +end -- }}} + +function Window:deleteIndicator() -- {{{ + if self.indicator then + self.indicator:delete(self.fadeDuration) + end end -- }}} return Window diff --git a/utils/utils.lua b/utils/utils.lua index 5e17678..bf358f0 100644 --- a/utils/utils.lua +++ b/utils/utils.lua @@ -322,4 +322,18 @@ function utils.tableCopyShallow(orig) return copy end +utils.equal = function(a, b) + if #a ~= #b then + return false + end + + for i, _ in ipairs(a) do + if b[i] ~= a[i] then + return false + end + end + + return true +end + return utils From d8c88faf4ad64d8cea444ac70b1b1a550e6a6b40 Mon Sep 17 00:00:00 2001 From: adamwagner Date: Fri, 7 Aug 2020 09:32:23 -0700 Subject: [PATCH 09/33] Fixed issue where only 1 of N stacks responded to focus events. Moved all stack-is-occluded functionality from Query to StackMgr and Stack modules. --- notes.md | 6 ++ stackline/core.lua | 4 + stackline/query.lua | 88 ++++++++------------- stackline/stack.lua | 141 +++++++++++++++++++++++++-------- stackline/stackMgr.lua | 12 ++- utils/bluclass.lua | 54 +++++++++++++ utils/self.lua | 171 +++++++++++++++++++++++++++++++++++++++++ utils/utils.lua | 2 + 8 files changed, 385 insertions(+), 93 deletions(-) create mode 100644 utils/bluclass.lua create mode 100644 utils/self.lua diff --git a/notes.md b/notes.md index f1c84e8..f7fe49d 100644 --- a/notes.md +++ b/notes.md @@ -35,6 +35,12 @@ See how they manage window indicators: - [statuslets.lua](https://github.com/cmsj/hammerspoon-config/blob/master/statuslets.lua): statuslets +## Debugging + +Debugging utils for lua + +- https://github.com/renatomaia/loop-debugging + ## Caching & queing [pyericz/LuaWorkQueue](https://github.com/pyericz/LuaWorkQueue/tree/master/src) diff --git a/stackline/core.lua b/stackline/core.lua index a0b7068..a66284b 100644 --- a/stackline/core.lua +++ b/stackline/core.lua @@ -10,6 +10,9 @@ map = hs.fnutils.map filter = hs.fnutils.filter each = hs.fnutils.each copy = hs.fnutils.copy +contains = hs.fnutils.contains +some = hs.fnutils.some +any = hs.fnutils.some -- also rename 'some()' to 'any()' stacksMgr = StackMgr:new(showIcons) -- _.pheader('stackmanager after construction') @@ -41,6 +44,7 @@ local win_removed = { -- {{{ wf.windowNotInCurrentSpace, } -- }}} +-- Global wfd = wf.new():setOverrideFilter{ -- {{{ visible = true, -- (i.e. not hidden and not minimized) fullscreen = false, diff --git a/stackline/query.lua b/stackline/query.lua index a3cd535..53c9a24 100644 --- a/stackline/query.lua +++ b/stackline/query.lua @@ -1,4 +1,4 @@ --- luacheck: ignore (augments hs.window module) +-- luacheck: ignore (spaces isn't used, but augments hs.window module) local spaces = require("hs._asm.undocumented.spaces") local _ = require 'stackline.utils.utils' local u = require 'stackline.utils.underscore' @@ -58,7 +58,7 @@ easier to maintain, *and* we get to drop the jq dependency! -- }}} --]] --- Private functions +-- Internal utils local wfd = hs.window.filter.new():setOverrideFilter{ -- {{{ visible = true, -- (i.e. not hidden and not minimized) fullscreen = false, @@ -84,22 +84,25 @@ function Query:getWinStackIdxs() -- {{{ end, {scriptPath}):start() end -- }}} --- function Query.getSpaces() -- {{{ --- return fnutils.mapCat(screen.allScreens(), function(s) --- return spaces.layout()[s:spacesUUID()] --- end) --- end -- }}} - --- function Query.getActiveSpaceIndex() -- {{{ --- local s = Query.getSpaces() --- local activeSpace = spaces.activeSpace() --- return _.indexOf(s, activeSpace) --- end -- }}} - function Query:makeStacksFromWindows(ws) -- {{{ local windows = map(ws, function(w) return Window:new(w) end) + + -- Methods to group windows into stacks + -- ------------------------------------------------------------------------- + -- Rationale: Identifying frames by topLeft (frame.x, frame.y) of each window + -- addresses macos MIN WIN SIZE EDGE CASE that can result in a stacked + -- window NOT sharing the same dimensions. + -- PRO: + -- ensures such windows will be members of the stack + -- CON: + -- zoom-parent & zoom-fullscreen windows will ALSO be counted as stack members + -- PROPER FIX + -- Filter out windows with a 0 stack-index using yabai data + + -- NOTE: 'stackID' groups by full frame, so windows with min-size > stack + -- width will not be stacked properly. See above ↑ local groupedWindows = _.groupBy(windows, 'stackId') -- stacks contain more than one window, @@ -108,49 +111,6 @@ function Query:makeStacksFromWindows(ws) -- {{{ self.stacks = stacks end -- }}} -function isStackOccluded(stack) -- {{{ - -- FIXES: When a stack that has "zoom-parent": 1 occludes another stack, the - -- occluded stack's indicators shouldn't be displaed - -- https://github.com/AdamWagner/stackline/issues/11 - - -- Returns true if any non-stack window occludes the stack's frame. - -- This can occur when an unstacked window is zoomed to cover a stack. - -- In this situation, we want to *hide* the occluded stack's indicators - -- TODO: Convert to Stack instance method (wouldn't need to pass in the 'stack' arg) - - function notInStack(hsWindow) - local stackWindowsHs = u.map(stack, winToHs) - local isInStack = u.include(stackWindowsHs, hsWindow) - return not isInStack - end - - -- NOTE: under.filter works with tables - -- _.filter only works with "list-like" tables - local nonStackWindows = u.filter(wfd:getWindows(), notInStack) - _.pheader('nonstackwindows') - _.p(nonStackWindows) - - function isStackInside(nonStackWindow) - -- _.pheader('stack in is stack inside') - -- _.p(stack.windows[1]) - local stackFrame = stack.windows[1]._win:frame() - return stackFrame:inside(nonStackWindow:frame()) - end - - local stackIsOccluded = u.any(map(nonStackWindows, isStackInside)) - print("\nstack is occluded", stackIsOccluded) - return stackIsOccluded -end -- }}} - --- luacheck: ignore -function Query:dimOccludedStacks(stacks) -- {{{ - each(filter(stacks, isStackOccluded), function(stack) - _.pheader('dimming occluded indicators') - print('\n\noccluded stack id', #stack.windows) - stack:dimAllIndicators() - end) -end -- }}} - function Query:mergeWinStackIdxs() -- {{{ hs.fnutils.each(self.stacks, function(stack) hs.fnutils.each(stack, function(win) @@ -204,7 +164,7 @@ function Query:windowsCurrentSpace() -- {{{ if extantStackExists then shouldRefresh = shouldRestack(self.stacks, extantStacks) - -- self:dimOccludedStacks(extantStacks) -- set self.occludedStacks + stacksMgr:dimOccluded() else shouldRefresh = true end @@ -232,3 +192,15 @@ function Query:windowsCurrentSpace() -- {{{ end -- }}} return Query +-- Deprecated +-- function Query.getSpaces() -- {{{ +-- return fnutils.mapCat(screen.allScreens(), function(s) +-- return spaces.layout()[s:spacesUUID()] +-- end) +-- end -- }}} +-- function Query.getActiveSpaceIndex() -- {{{ +-- local s = Query.getSpaces() +-- local activeSpace = spaces.activeSpace() +-- return _.indexOf(s, activeSpace) +-- end -- }}} + diff --git a/stackline/stack.lua b/stackline/stack.lua index 0a60885..3efb989 100644 --- a/stackline/stack.lua +++ b/stackline/stack.lua @@ -1,38 +1,111 @@ local _ = require 'stackline.utils.utils' +local u = require 'stackline.utils.underscore' -local Stack = {} - -function Stack:new(stackData) -- {{{ - self.windows = stackData - return self -end -- }}} -function Stack:get() -- {{{ - return self -end -- }}} -function Stack:eachWin(fn) -- {{{ - for _idx, win in pairs(self.windows) do - fn(win) - end -end -- }}} -function Stack:redrawAllIndicators() -- {{{ - self:eachWin(function(win) - print('calling redraw indicator') - -- TODO see if it works *without* win:setupIndicator - win:setupIndicator() - win:drawIndicator() - end) -end -- }}} -function Stack:deleteAllIndicators() -- {{{ - self:eachWin(function(win) - print('calling delete indicator') - win:deleteIndicator() - end) -end -- }}} -function Stack:dimAllIndicators() -- {{{ - self:eachWin(function(win) - print('calling delete indicator') - win:drawIndicator({unfocusedAlpha = 1}) - end) -end -- }}} +local Class = require 'stackline.utils.self' +-- NOTE: using simple 'self' library fixed +-- the issue of only 1 of N stacks responding to focus events. +-- Experimented with even smaller libs, but only 'self' worked so far. + +-- Class example in vanilla lua +-- https://github.com/lharck/inheritance-example + +-- ARGS: Class(className, +-- parentClass, +-- table [define methods], +-- isGlobal) +local Stack = Class("Stack", nil, { + windows = {}, + + new = function(self, stackedWindows) -- {{{ + self.windows = stackedWindows + end, -- }}} + + get = function(self) -- {{{ + return self.windows + end, -- }}} + + getHs = function(self) -- {{{ + return map(self.windows, function(w) + return w._win + end) + end, -- }}} + + frame = function(self) -- {{{ + -- All stacked windows have the same dimensions, + -- so the 1st Hs window's frame is ~= to the stack's frame + -- FIXME: Incorrect when the 1st window has min-size < stack width + -- See ./query.lua:104 + return self.windows[1]._win:frame() + end, -- }}} + + eachWin = function(self, fn) -- {{{ + for _idx, win in pairs(self.windows) do + fn(win) + end + end, -- }}} + + redrawAllIndicators = function(self) -- {{{ + self:eachWin(function(win) + print('calling redraw indicator') + -- TODO see if it works *without* win:setupIndicator + win:setupIndicator() + win:drawIndicator() + end) + end, -- }}} + + deleteAllIndicators = function(self) -- {{{ + self:eachWin(function(win) + print('calling delete indicator') + win:deleteIndicator() + end) + end, -- }}} + + dimAllIndicators = function(self) -- {{{ + self:eachWin(function(win) + win:drawIndicator({unfocusedAlpha = 1}) + end) + end, -- }}} + + restoreAlpha = function(self) -- {{{ + self:eachWin(function(win) + win:drawIndicator({unfocusedAlpha = nil}) + end) + end, -- }}} + + isWindowOccludedBy = function(self, otherWin, win) -- {{{ + -- Test uses optional 'win' arg if provided, + -- otherwise test uses 1st window of stack + local stackedFrame = win and win:frame() or self:frame() + return stackedFrame:inside(otherWin:frame()) + end, -- }}} + + isOccluded = function(self) -- {{{ + -- FIXES: https://github.com/AdamWagner/stackline/issues/11 + -- When a stack that has "zoom-parent": 1 occludes another stack, the + -- occluded stack's indicators shouldn't be displaed + + -- Returns true if any non-stack window occludes the stack's frame. + -- This can occur when an unstacked window is zoomed to cover a stack. + -- In this situation, we want to *hide* the occluded stack's indicators + + -- DONE: Convert to Stack instance method (wouldn't need to pass in the 'stack' arg) + local stackedHsWins = self:getHs() + + function notInStack(hsWin) + return not u.include(stackedHsWins, hsWin) + end + + local windowsCurrSpace = wfd:getWindows() + local nonStackWindows = filter(windowsCurrSpace, notInStack) + + -- true if *any* non-stacked windows occlude the stack's frame + -- NOTE: u.any() works, hs.fnutils.some does NOT work :~ + local stackIsOccluded = u.any(map(nonStackWindows, function(w) + return self:isWindowOccludedBy(w) + end)) + return stackIsOccluded + end, -- }}} + +}) return Stack diff --git a/stackline/stackMgr.lua b/stackline/stackMgr.lua index c3a2e4a..ec01cb3 100644 --- a/stackline/stackMgr.lua +++ b/stackline/stackMgr.lua @@ -67,7 +67,7 @@ function StacksMgr:ingest(stacks, shouldClean) -- {{{ for _stackId, stack in pairs(stacks) do _.pheader('new stack') _.p(stack) - table.insert(self.tabStacks, Stack:new(stack)) + table.insert(self.tabStacks, Stack(stack)) _.pheader('stacksMngr.tabStacks afterward') _.p(self.tabStacks) self:redrawAllIndicators() @@ -84,6 +84,16 @@ function StacksMgr:eachStack(fn) -- {{{ end end -- }}} +function StacksMgr:dimOccluded() -- {{{ + self:eachStack(function(stack) + if stack:isOccluded() then + stack:dimAllIndicators() + else + stack:restoreAlpha() + end + end) +end -- }}} + function StacksMgr:cleanup() -- {{{ _.pheader('calling cleanup') self:eachStack(function(stack) diff --git a/utils/bluclass.lua b/utils/bluclass.lua new file mode 100644 index 0000000..3fe7393 --- /dev/null +++ b/utils/bluclass.lua @@ -0,0 +1,54 @@ +local bluclass = { + _VERSION = 'bluclass 1.1.0', + _DESCRIPTION = 'Lua OOP module with simple inheritance', + _URL = 'https://github.com/superzazu/bluclass.lua', + _LICENSE = [[ +Copyright (c) 2015-2019 Nicolas Allemand + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +]] +} + +bluclass.class = function(super) + local class = {} + class.super = super + + class.new = function(self, ...) + local instance = {} + instance.class = self + + setmetatable(instance, {__index = function(t, key) + if self[key] then + return self[key] + elseif self.super and self.super[key] then + return self.super[key] + end + end}) + + if instance.init then + instance:init(...) + end + + return instance + end + + return class +end + +return bluclass diff --git a/utils/self.lua b/utils/self.lua new file mode 100644 index 0000000..057e5d8 --- /dev/null +++ b/utils/self.lua @@ -0,0 +1,171 @@ +-- FROM https://github.com/M1que4s/self/blob/master/self.lua +-- 4 stars +-- Updated Jul 2020 + +-- ADDED: 2020-08-07 + +-- ALTERNATIVES to consider: +-- +-- 1. https://github.com/siffiejoe/lua-classy/ +-- 531 lines +-- 21 stars +-- updated July 2020 +-- +-- 2. https://github.com/superzazu/bluclass.lua/tree/master +-- ~30 lines +-- 1 star +-- updated Oct 2019 +-- +-- 3. https://github.com/kikito/middleclass +-- 183 lines +-- 1.2k stars +-- updated Mar 2018 +-- good docs: https://github.com/kikito/middleclass/wiki/Quick-Example + +-- 4. https://github.com/niu2x/luaclass +-- only 60 lines (vs. 139) +-- updated Dec 2019 +-- 1 star +-- +-- 5. https://github.com/limadm/lua-oo +-- 80 lines +-- 7 stars +-- updated Oct 2017 +-- +-- 6. https://github.com/kurapica/PLoop +-- 1000s of lines? Multi file, too many to count +-- 138 stars +-- updated Aug 2020 + +local unp = unpack or table.unpack +local getmt = getmetatable +local setmt = setmetatable +local Class = {} +Class.__index = Class +Class.__name = "Object" +Class.__parent = { Class } + +local function dump(t, name, indent) + local cart + local autoref + + local function isemptytable(t) return next(t) == nil end + + local function basicSerialize (o) + local so = tostring(o) + if type(o) == "function" then + local info = debug.getinfo(o, "S") + if info.what == "C" then return string.format("%q", so .. ", C function") + else return ("%s, in %d-%d %s"):format(so, info.linedefined, info.lastlinedefined, info.source) end + elseif type(o) == "number" or type(o) == "boolean" then return so + else return string.format("%q", so) end + end + + local function addtocart (value, name, indent, saved, field) + indent = indent or "" + saved = saved or {} + field = field or name + cart = cart .. indent .. field + if type(value) ~= "table" then cart = cart .. " = " .. basicSerialize(value) .. "\n" + else + if saved[value] then + cart = cart .. " = {} -- " .. saved[value] .. " (self reference)\n" + autoref = autoref .. name .. " = " .. saved[value] .. "\n" + else + saved[value] = name + if isemptytable(value) then cart = cart .. " = {}\n" + else cart = cart .. " = {\n" + for k, v in pairs(value) do + k = basicSerialize(k) + local fname = string.format("%s[%s]", name, k) + field = string.format("[%s]", k) + addtocart(v, fname, indent .. " ", saved, field) + end cart = cart .. indent .. "}\n" + end + end + end + end + + name = name or "__unnamed__" + if type(t) ~= "table" then return name .. " = " .. basicSerialize(t) end + cart, autoref = "", "" + addtocart(t, name, indent) + return cart .. autoref +end + +local function err(exp, msg, ...) + local msg = msg:format(...) + if not (exp) then error(msg, 0) else return exp end +end + +function Class:create(name, parent, def, G) + err(type(name) == "string" or type(name) == "nil", "Object.new: bad argument #1, string expected, got %s", type(name)) + err(type(parent) == "table" or type(parent) == "nil", "Object.new: bad argument #2, class expected, got %s", type(parent)) + err(type(def) == "table" or type(def) == "nil", "Object.new: bad argument #3, table expected, got %s", type(def)) + err(type(G) == "boolean" or type(G) == "nil", "Object.new: bad argument #4, boolean expected, got %s", type(G)) + + local cls = def or {} + cls.__parent = { self } + + if parent then + table.insert(cls.__parent, parent) + for k, v in pairs(parent) do if not cls[k] then cls[k] = v end end + for i, v in ipairs(parent.__parent) do + if not (parent.__parent[i] == (self or cls or parent)) then table.insert(cls.__parent, v) end + end + end + + cls.__index = cls + cls.__name = name or "__AnonymousClass__" + + setmt(cls, self) + if G then + err(name, "Object.new: no name for global class") + err(not _G[name], "Object.new: class '%s' already exists", name) + rawset(_G, name, cls) + else return cls end +end + +function Class:uses(...) + err(self.__name ~= "Object", "Object.uses: attempt to modify parent class 'Object'") + local va = {...} + err(#va >= 1, "Object.uses: one or more classes expected, got %d", #va) + for idx, cls in pairs(va) do + err(type(va[idx]) == "table", "Object.uses: bad argument #%d, class expected, got %s", idx, type(va[idx])) + for k, v in pairs(cls) do if type(v) == "function" and not rawget(self, k) then rawset(self, k, v) end end + end +end + +function Class:is(cls) + err(type(cls) == "table", "Object.is: bad argument, class expected, got %s", type(cls)) + for i, v in ipairs(self.__parent) do if self.__parent[i] == cls or self.__name == cls.__name then return true end end + return false +end + +function Class:isClass(obj) + if not obj or type(obj) ~= "table" then return nil + elseif getmt(obj) == self then return true + elseif getmt(getmt(obj)) == self then return true + else return false end +end + +function Class:dump(details, indent) + err(type(details) == "boolean" or "nil", "Object.dump: bad argument #1, boolean expected, got %s", type(details)) + err(type(indent) == "string" or "nil", "Object.dump: bad argument #2, string expected, got %s", type(indent)) + if details then return dump(getmt(self), self.__name, indent) + else return dump(self, self.__name, indent) end +end + +function Class:__call(...) + if self.__name == "Object" then return self:create(...) end + local o = setmt({}, self) + if rawget(self, "new") then o:new(...) + elseif rawget(self, "init") then o:init(...) + else err(nil, "%s: no constructor defined", o.__name) end + return o +end + +function Class:__tostring() return ("Class '%s'"):format(self.__name) end +setmt(Class, Class) + +return Class diff --git a/utils/utils.lua b/utils/utils.lua index bf358f0..dfb2251 100644 --- a/utils/utils.lua +++ b/utils/utils.lua @@ -1,5 +1,7 @@ -- OTHERS ---------------------------------------------------------------------- -- https://github.com/luapower/glue/blob/master/glue.lua +-- https://github.com/Desvelao/f/blob/master/f/table.lua (new in 2020) +-- https://github.com/moriyalb/lamda (based on ramda, updated May 2020, 27 stars) -- ----------------------------------------------------------------------------- utils = {} From cb92957c8248c8c408ddcd2f147d32b037e14d0e Mon Sep 17 00:00:00 2001 From: adamwagner Date: Fri, 7 Aug 2020 12:59:51 -0700 Subject: [PATCH 10/33] Workaround hammerspoon bug (https://github.com/Hammerspoon/hammerspoon/issues/2400) to ensure indicators update when switching between windows of the same app. --- stackline/core.lua | 37 ++++++++++++++++++++++++++++++------- stackline/stack.lua | 22 ++++++++++++++++++++++ stackline/stackMgr.lua | 14 ++++++++++++-- 3 files changed, 64 insertions(+), 9 deletions(-) diff --git a/stackline/core.lua b/stackline/core.lua index a66284b..ba5a49f 100644 --- a/stackline/core.lua +++ b/stackline/core.lua @@ -65,6 +65,9 @@ end) -- ┌──────────────────────────────────┐ -- │ Query window state subscriptions │ -- └──────────────────────────────────┘ + +local options = {fixSameAppHammerspoonBug = true} + -- callback args: window, app, event wfd:subscribe(added_changed, function() queryWindowState:start() @@ -81,14 +84,29 @@ stacksMgr:update() -- │ Update indicators subscriptions │ -- └─────────────────────────────────┘ --- DONE: rather than subscribing to windowFocused here, do it only for --- windows within a stack. This will shorten the update process for focus --- changes, since we *only* need to update the indicators, not query for new --- window state entirely. --- wf.windowFocused, --- wf.windowUnfocused, +function unfocusOtherAppWindows(win) -- {{{ + -- Fix HS bug: windowUnfocused event not fired for same-app windows + -- https://github.com/Hammerspoon/hammerspoon/issues/2400 + -- NOTE: substantially slows down indicator redraw when focus changes :< + -- So much so that it probably makes sense to store a "has >1 win + -- from app" field on each stack + + -- Related: + -- ./stack.lua:22 + -- ./stack.lua:30 + + -- v1: Search for stack by window: + -- E.g., local stack = stacksMgr:findStackByWindow(stackedWin) + -- v2: Lookup stack from window instead of searching by window ID: + -- local stack = stackedWin.stack + -- v3: Store `otherAppWindows` directly on window: --- DONE: Parameterize Activate / Deactivate by reading event + -- See ./stack.lua:22 + each(win.otherAppWindows, function(w) + w:drawIndicator({shouldFade = false}) + end) + +end -- }}} function redrawWinIndicator(hsWin, appName, event) local id = hsWin:id() @@ -96,6 +114,11 @@ function redrawWinIndicator(hsWin, appName, event) local stackedWin = stacksMgr:findWindow(id) if stackedWin then -- if not found, then focused win is not stacked stackedWin:drawIndicator({shouldFade = false}) -- draw instantly on focus change + + if options.fixSameAppHammerspoonBug then + unfocusOtherAppWindows(stackedWin) + end + end end diff --git a/stackline/stack.lua b/stackline/stack.lua index 3efb989..5af8a90 100644 --- a/stackline/stack.lua +++ b/stackline/stack.lua @@ -18,6 +18,19 @@ local Stack = Class("Stack", nil, { new = function(self, stackedWindows) -- {{{ self.windows = stackedWindows + + each(self.windows, function(w) + -- Cache reference to stack on window for easy lookup + -- TODO: research cost of increased table size vs better stack lookup speed + -- Added to fix annoying HS bug detailed here: ./core.lua + w.otherAppWindows = self:getOtherAppWindows(w) + + -- NOTE: Can store other helpful references, like stack, too + -- I don't understand the perf. tradeoffs of size vs lookup speed, tho + -- w.stack = self + end) + + self.id = stackedWindows[1].stackId end, -- }}} get = function(self) -- {{{ @@ -44,6 +57,15 @@ local Stack = Class("Stack", nil, { end end, -- }}} + getOtherAppWindows = function(self, win) -- {{{ + -- NOTE: may not need when HS issue #2400 is closed + return filter(self:get(), function(w) + _.pheader('window in getOtherAppWindows') + _.p(w) + return w.app == win.app + end) + end, -- }}} + redrawAllIndicators = function(self) -- {{{ self:eachWin(function(win) print('calling redraw indicator') diff --git a/stackline/stackMgr.lua b/stackline/stackMgr.lua index ec01cb3..9e43627 100644 --- a/stackline/stackMgr.lua +++ b/stackline/stackMgr.lua @@ -67,9 +67,9 @@ function StacksMgr:ingest(stacks, shouldClean) -- {{{ for _stackId, stack in pairs(stacks) do _.pheader('new stack') _.p(stack) + table.insert(self.tabStacks, Stack(stack)) - _.pheader('stacksMngr.tabStacks afterward') - _.p(self.tabStacks) + self:redrawAllIndicators() end end -- }}} @@ -148,6 +148,16 @@ function StacksMgr:findWindow(wid) -- {{{ end end -- }}} +function StacksMgr:findStackByWindow(win) -- {{{ + -- NOTE: may not need when HS issue #2400 is closed + -- NOTE 2: Unused, since I'm storing reference to "otherAppWindows" directly on each window + for _stackId, stack in pairs(self.tabStacks) do + if stack.id == win.stackId then + return stack + end + end +end -- }}} + function StacksMgr:getShowIconsState() -- {{{ return self.showIcons end -- }}} From 1e5a14b9ff12f998277e600e64e30c1a7fc21d8f Mon Sep 17 00:00:00 2001 From: adamwagner Date: Sat, 8 Aug 2020 20:22:03 -0700 Subject: [PATCH 11/33] Fold Window:getScreenSide() --- stackline/window.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/stackline/window.lua b/stackline/window.lua index a8f752b..795b294 100644 --- a/stackline/window.lua +++ b/stackline/window.lua @@ -79,7 +79,7 @@ end -- }}} -- return isEqual -- end -- }}} -function Window:getScreenSide() +function Window:getScreenSide() -- {{{ -- (sFrame.w - (wFrame.x + wFrame.w)) / sFrame.w local screenWidth = self._win:screen():fullFrame().w local frame = self.frame @@ -95,7 +95,7 @@ function Window:getScreenSide() -- wfd:windowsToWest(self._win) -- https://www.hammerspoon.org/docs/hs.window.html#windowsToWest -- self._win:windowsToSouth() -end +end -- }}} -- TODO: ↑ Convert to .__eq metatable function Window:setNeedsUpdated(extant) -- {{{ From bb4caa8816273d24fee4846b842e0ae872b5af12 Mon Sep 17 00:00:00 2001 From: adamwagner Date: Sun, 9 Aug 2020 06:03:20 -0700 Subject: [PATCH 12/33] Modifying existing indicators on focus change is MUCH faster than destroying them & rendering from scratch. --- stackline/core.lua | 20 ++++----- stackline/window.lua | 101 ++++++++++++++++++++++++++++++------------- 2 files changed, 80 insertions(+), 41 deletions(-) diff --git a/stackline/core.lua b/stackline/core.lua index ba5a49f..69a24ff 100644 --- a/stackline/core.lua +++ b/stackline/core.lua @@ -108,28 +108,24 @@ function unfocusOtherAppWindows(win) -- {{{ end -- }}} -function redrawWinIndicator(hsWin, appName, event) +function redrawWinIndicator(hsWin, _app, event) -- {{{ + -- Dedicated redraw method to *adjust* the existing canvas element is WAY + -- faster than deleting the entire indicator & rebuilding it from scratch, + -- particularly since this skips querying the app icon & building the icon image. local id = hsWin:id() - print(event:gsub('window', ''), appName, id) local stackedWin = stacksMgr:findWindow(id) + if stackedWin then -- if not found, then focused win is not stacked - stackedWin:drawIndicator({shouldFade = false}) -- draw instantly on focus change + local focused = (event == wf.windowFocused) and true or false + stackedWin:redrawIndicator({shouldFade = false}, focused) -- draw instantly on focus change if options.fixSameAppHammerspoonBug then unfocusOtherAppWindows(stackedWin) end end -end +end -- }}} wfd:subscribe(wf.windowFocused, redrawWinIndicator) wfd:subscribe({wf.windowNotVisible, wf.windowUnfocused}, redrawWinIndicator) --- HS BUG! windowUnfocused is not called when switching between windows of the --- same app - it's ONLY called when switching between windows of different apps --- https://github.com/Hammerspoon/hammerspoon/issues/2400 - --- I tried an experiment to redraw all indicators inside `redrawWinIndicator()` --- every time, but it slowed down changing win focus A LOT: --- wsi.redrawAllIndicators() - diff --git a/stackline/window.lua b/stackline/window.lua index 795b294..e704c00 100644 --- a/stackline/window.lua +++ b/stackline/window.lua @@ -131,6 +131,9 @@ function Window:setupIndicator(Icons) -- {{{ -- Fade-in/out duration self.fadeDuration = 0.2 + -- Display indicators on + -- left edge of windows on the left side of the screen, & + -- right edge of windows on the right side of the screen local side = self:getScreenSide() local xval = nil if side == 'right' then @@ -139,82 +142,122 @@ function Window:setupIndicator(Icons) -- {{{ xval = self.frame.x - (self.width + self.offsetX) end - self.canvas_frame = { - x = xval, - y = self.frame.y + self.offsetY, - w = self.frame.w, - h = self.frame.h, - } + -- Set canvas to fill entire screen + self.canvas_frame = self._win:screen():frame() + + -- Store canvas elements indexes to reference via :elementAttribute() + -- https://www.hammerspoon.org/docs/hs.canvas.html#elementAttribute + self.rectIdx = 1 + self.iconIdx = 2 -- NOTE: self.stackIdx comes from yabai self.indicator_rect = { - x = 0, - y = ((self.stackIdx - 1) * self.size * 1.1), + x = xval, + y = self.frame.y + ((self.stackIdx - 1) * self.size * 1.1), w = self.width, h = self.size, } self.icon_rect = { - x = self.iconPadding, + x = xval + self.iconPadding, y = self.indicator_rect.y + self.iconPadding, w = self.indicator_rect.w - (self.iconPadding * 2), h = self.indicator_rect.h - (self.iconPadding * 2), } end -- }}} -function Window:drawIndicator(overrideOpts) -- {{{ +function Window:drawIndicator(overrideOpts, focusedHint) -- {{{ local defaultOpts = { shouldFade = true, - focusedAlpha = 1, - unfocusedAlpha = 0.33, + alphaFocused = 1, + alphaUnfocused = 0.33, } local opts = u.extend(defaultOpts, overrideOpts or {}) - -- Unfocused icons should less transparent - -- than the bg color, but no more than 1 - local unfocusedIconAlpha = math.min(opts.unfocusedAlpha * 2, 1) + -- Color + self.colorFocused = {white = 0.9, alpha = opts.alphaFocused} + self.colorUnfocused = {white = 0.9, alpha = opts.alphaUnfocused} + + -- Unfocused icons less transparent than bg color, but no more than 1 + self.iconAlphaFocused = opts.alphaFocused + self.iconAlphaUnfocused = math.min(opts.alphaUnfocused * 2, 1) + + self.shadowOpts = {blur = self.focus} local showIcons = stacksMgr:getShowIconsState() local radius = showIcons and self.iconRadius or self.indicatorRadius local fadeDuration = opts.shouldFade and self.fadeDuration or 0 - local focused = self:isFocused() - -- Color - self.unfocused_color = {white = 0.9, alpha = opts.unfocusedAlpha} - self.focused_color = {white = 0.9, alpha = opts.focusedAlpha} + self.focus = self:isFocused() + -- PROFILE: 0.0123s / 75 (0.0002s) :: isFocused - -- print('calling drawIndicator for window', self.app, self.id) if self.indicator then self.indicator:delete() end + self.indicator = hs.canvas.new(self.canvas_frame) - self.colorOpts = { - bg = focused and self.focused_color or self.unfocused_color, - imageAlpha = focused and opts.focusedAlpha or unfocusedIconAlpha, + self.currStyle = { + fillColor = self.focus and self.colorFocused or self.colorUnfocused, + imageAlpha = self.focus and self.iconAlphaFocused or + self.iconAlphaUnfocused, + shadow = { + blurRadius = 20.0, + color = {alpha = 1 / 5}, + offset = {h = -2.0, w = 0.0}, + }, } - self.indicator:appendElements{ + self.indicator:insertElement({ type = "rectangle", action = "fill", - fillColor = self.colorOpts.bg, + fillColor = self.currStyle.fillColor, frame = self.indicator_rect, roundedRectRadii = {xRadius = radius, yRadius = radius}, - } + padding = 60, + withShadow = true, + }, self.rectIdx) if showIcons then - self.indicator:appendElements{ + -- TODO: Figure out how to prevent clipping when adding a subtle shadow + -- to the icon to help distinguish icons with a near-white edge. + self.indicator:insertElement({ type = "image", image = self:iconFromAppName(), frame = self.icon_rect, - imageAlpha = self.colorOpts.imageAlpha, - } + imageAlpha = self.currStyle.imageAlpha, + }, self.iconIdx) end self.indicator:show(fadeDuration) end -- }}} +function Window:redrawIndicator(overrideOpts, isFocused) -- {{{ + _.pheader('redraw') + print(self.id, self.app, isFocused) + -- bail early if there's nothing to do + if isFocused == self.focus then + return false + else + self.focus = isFocused + end + + local set = _.partial(self.indicator.elementAttribute, self.indicator) + local setRect = _.partial(set, self.rectIdx) + local setIcon = _.partial(set, self.iconIdx) + + local fillColor = self.focus and self.colorFocused or self.colorUnfocused + local imageAlpha = self.focus and self.iconAlphaFocused or + self.iconAlphaUnfocused + + setRect('fillColor', fillColor) + if stacksMgr:getShowIconsState() then + print(self.focus, 'imageAlpha:', imageAlpha) + setIcon('imageAlpha', imageAlpha) + end +end -- }}} + function Window:iconFromAppName() -- {{{ appBundle = hs.appfinder.appFromName(self.app):bundleID() return hs.image.imageFromAppBundle(appBundle) From b46916e520fbffe3fe5517782e4a4aa99b46b834 Mon Sep 17 00:00:00 2001 From: adamwagner Date: Sun, 9 Aug 2020 10:06:29 -0700 Subject: [PATCH 13/33] Cleanup (one of the) utils.lua files --- utils/utils.lua | 272 ++++++++++++++++++++++++++---------------------- 1 file changed, 145 insertions(+), 127 deletions(-) diff --git a/utils/utils.lua b/utils/utils.lua index dfb2251..1fd2b47 100644 --- a/utils/utils.lua +++ b/utils/utils.lua @@ -6,34 +6,31 @@ utils = {} utils.map = hs.fnutils.map -utils.concat = hs.fnutils.concat +utils.filter = hs.fnutils.filter +utils.reduce = hs.fnutils.reduce +utils.partial = hs.fnutils.partial utils.each = hs.fnutils.each +utils.contains = hs.fnutils.contains +utils.some = hs.fnutils.some +utils.any = hs.fnutils.some -- also rename 'some()' to 'any()' +utils.concat = hs.fnutils.concat +utils.copy = hs.fnutils.copy -utils.filter = function(t, f) - local out = {} - for k, v in pairs(t) do - if (f(k, v)) then - out[k] = v - end - end - return out -end - -function utils.keyBind(hyper, keyFuncTable) +function utils.keyBind(hyper, keyFuncTable) -- {{{ for key, fn in pairs(keyFuncTable) do hs.hotkey.bind(hyper, key, fn) end -end +end -- }}} -utils.length = function(t) +utils.length = function(t) -- {{{ local count = 0 for _ in pairs(t) do count = count + 1 end return count -end +end -- }}} -utils.indexOf = function(t, object) +utils.indexOf = function(t, object) -- {{{ if type(t) ~= "table" then error("table expected, got " .. type(t), 2) end @@ -43,13 +40,13 @@ utils.indexOf = function(t, object) return i end end -end +end -- }}} ---[[ -This function takes 2 values as input and returns true if they are equal -and false if not. a and b can numbers, strings, booleans, tables and nil. ---]] -function utils.isEqual(a, b) +function utils.isEqual(a, b) -- {{{ + --[[ + This function takes 2 values as input and returns true if they are equal + and false if not. a and b can numbers, strings, booleans, tables and nil. + --]] local function isEqualTable(t1, t2) @@ -105,40 +102,36 @@ function utils.isEqual(a, b) return (a == b) end -end +end -- }}} --- Functions stolen from lume.lua --- (I should probably just use the library itself) --- https://github.com/rxi/lume/blob/master/lume.lua -function utils.isarray(x) +-- FROM: lume.lua — https://github.com/rxi/lume/blob/master/lume.lua +function utils.isarray(x) -- {{{ return type(x) == "table" and x[1] ~= nil -end - -local getiter = function(x) +end -- }}} +local getiter = function(x) -- {{{ if utils.isarray(x) then return ipairs elseif type(x) == "table" then return pairs end error("expected table", 3) -end -function utils.invert(t) +end -- }}} +function utils.invert(t) -- {{{ local rtn = {} for k, v in pairs(t) do rtn[v] = k end return rtn -end -function utils.keys(t) +end -- }}} +function utils.keys(t) -- {{{ local rtn = {} local iter = getiter(t) for k in iter(t) do rtn[#rtn + 1] = k end return rtn -end - -function utils.find(t, value) +end -- }}} +function utils.find(t, value) -- {{{ local iter = getiter(t) result = nil for k, v in iter(t) do @@ -152,17 +145,18 @@ function utils.find(t, value) result = v end end - utils.pheader('result') + -- utils.pheader('result') print(result) return result -end --- END lume.lua pillaging - ---- Check if a row matches the specified key constraints. --- @param row The row to check --- @param key_constraints The key constraints to apply --- @return A boolean result -local function filter_row(row, key_constraints) +end -- }}} +-- END lume.lua + +local function filter_row(row, key_constraints) -- {{{ + -- Check if a row matches the specified key constraints. + -- @param row The row to check + -- @param key_constraints The key constraints to apply + -- @return A boolean result + -- Loop through all constraints for k, v in pairs(key_constraints) do if v and not row[k] then @@ -196,100 +190,39 @@ local function filter_row(row, key_constraints) end return true -end +end -- }}} ---- Filter an array, returning entries matching `key_values`. --- @param input The array to process --- @param key_values A table of keys mapped to their viable values --- @return An array of matches -function utils.pick(input, key_values) +function utils.pick(input, key_values) -- {{{ + -- Filter an array, returning entries matching `key_values`. + -- @param input The array to process + -- @param key_values A table of keys mapped to their viable values + -- @return An array of matches local result = {} utils.each(key_values, function(k) result[k] = input[k] end) return result -end - -local print = print -local tconcat = table.concat -local tinsert = table.insert -local srep = string.rep -local type = type -local pairs = pairs -local tostring = tostring -local next = next - -function utils.print_r(root) - local cache = {[root] = "."} - local function _dump(t, space, name) - local temp = {} - tinsert(temp, "\n") - for k, v in pairs(t) do - local key = tostring(k) - if cache[v] then - tinsert(temp, "+" .. key .. " {" .. cache[v] .. "}") - elseif type(v) == "table" then - local new_key = name .. "." .. key - cache[v] = new_key - tinsert(temp, "+" .. key .. - _dump(v, space .. (next(t, k) and "|" or " ") .. - srep(" ", #key), new_key)) - else - tinsert(temp, "+" .. key .. " [" .. tostring(v) .. "]") - end - end - return tconcat(temp, "\n" .. space) - end - print(_dump(root, "", "")) -end - ---- Recursively print out a Lua value. --- @param value The value to print --- @param indent Indentation level (defaults to 0) --- @param no_newline If true, won't print a newline at the end -function utils.deep_print(value, indent, no_newline) - indent = indent or 0 - - if type(value) == "table" then - print("{") - for k, v in pairs(value) do - io.write(string.rep(" ", indent + 2) .. "[") - deep_print(k, indent + 2, true) - io.write("] = ") - deep_print(v, indent + 2, true) - print(";") - end - io.write(string.rep(" ", indent) .. "}") - elseif type(value) == "string" then - io.write(("%q"):format(value)) - else - io.write(tostring(value)) - end +end -- }}} - if not no_newline then - print() - end -end - -function utils.p(data, howDeep) +function utils.p(data, howDeep) -- {{{ howDeep = howDeep or 3 print(hs.inspect(data, {depth = howDeep})) -end +end -- }}} -function utils.pdivider(str) +function utils.pdivider(str) -- {{{ str = string.upper(str) or "" print("=========", str, "==========") -end +end -- }}} -function utils.pheader(str) +function utils.pheader(str) -- {{{ print('\n\n\n') print("========================================") print(string.upper(str), '==========') print("========================================") -end +end -- }}} --- FROM: https://github.com/pyrodogg/AdventOfCode/blob/1ff5baa57c0a6a86c40f685ba6ab590bd50c2148/2019/lua/util.lua#L149 -function utils.groupBy(t, f) +function utils.groupBy(t, f) -- {{{ + -- FROM: https://github.com/pyrodogg/AdventOfCode/blob/1ff5baa57c0a6a86c40f685ba6ab590bd50c2148/2019/lua/util.lua#L149 local res = {} for _k, v in pairs(t) do local g @@ -307,9 +240,9 @@ function utils.groupBy(t, f) table.insert(res[g], v) end return res -end +end -- }}} -function utils.tableCopyShallow(orig) +function utils.tableCopyShallow(orig) -- {{{ -- FROM: https://github.com/XavierCHN/go/blob/master/game/go/scripts/vscripts/utils/table.lua local orig_type = type(orig) local copy @@ -322,9 +255,9 @@ function utils.tableCopyShallow(orig) copy = orig end return copy -end +end -- }}} -utils.equal = function(a, b) +function utils.equal(a, b) -- {{{ if #a ~= #b then return false end @@ -336,6 +269,91 @@ utils.equal = function(a, b) end return true -end +end -- }}} + +-- TODO: Confirm that hs.fnutils.partial works just as well +function utils.partial(f, ...) -- {{{ + -- FROM: https://www.reddit.com/r/lua/comments/fh2go5/a_partialcurry_implementation_of_mine_hope_you/ + -- WHEN: 2020-08-08 + local unpack = unpack or table.unpack -- Lua 5.3 moved unpack + local a = {...} + local a_len = select("#", ...) + return function(...) + local tmp = {...} + local tmp_len = select("#", ...) + -- Merge arg lists + for i = 1, tmp_len do + a[a_len + i] = tmp[i] + end + return f(unpack(a, 1, a_len + tmp_len)) + end +end -- }}} + +utils.flattenForce = require 'stackline.utils.flatten' +-- table will be squashed down to 1 level deep. Previously nested structures +-- will be converted to long, path-like string keys. + +function utils.greaterThan(n) -- {{{ + return function(t) + return #t > n + end +end -- }}} + +function utils.getFields(t, fields) -- {{{ + -- FROM: https://stackoverflow.com/questions/41417971/a-better-way-to-assign-multiple-return-values-to-table-keys-in-lua + -- WHEN: 2020-08-09 + -- USAGE: + -- local bnot, band, bor = get_fields(require("bit"), {"bnot", "band", "bor"}) + local values = {} + for k, field in ipairs(fields) do + values[k] = t[field] + end + return (table.unpack or unpack)(values, 1, #fields) +end -- }}} + +function utils.setFields(tab, fields, ...) -- {{{ + -- USAGE: + -- image.size = set_fields({}, {"width", "height"}, image.data:getDimensions()) + -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --- + -- USAGE EXAMPLE #2: {{{ + -- Swap the values on-the-fly! + + -- local function get_weekdays() + -- return "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat" + -- end + + -- -- we want to save returned values in different order + -- local weekdays = set_fields({}, {7,1,2,3,4,5,6}, get_weekdays()) + -- -- now weekdays contains {"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"} + -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --- + -- USAGE EXAMPLE #3: + -- local function get_coords_x_y_z() + -- return 111, 222, 333 -- x, y, z of the point + -- end + -- -- we want to get the projection of the point on the ground plane local projection = {y = 0} + -- -- projection.y will be preserved, projection.x and projection.z will be modified + + -- set_fields(projection, {"x", "", "z"}, get_coords_x_y_z()) + + -- -- now projection contains {x = 111, y = 0, z = 333} + -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --- + -- Usage example #4: + -- -- If require("some_module") returns a module with plenty of functions + -- -- inside, but you need only a few of them: + -- local bnot, band, bor = get_fields(require("bit"), {"bnot", "band", "bor"}) + -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --- + -- }}} + + -- fields is an array of field names + -- (use empty string to skip value at corresponging position) + local values = {...} + for k, field in ipairs(fields) do + if field ~= "" then + tab[field] = values[k] + end + end + return tab +end -- }}} return utils + From db7eac902ffbea792f2564df70277419c3552844 Mon Sep 17 00:00:00 2001 From: adamwagner Date: Sun, 9 Aug 2020 18:18:59 -0700 Subject: [PATCH 14/33] Add a status update to readme describing recent changes --- README.md | 121 +++++++++++++++++++++++++++++++++++++------------ notes-query.md | 112 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 204 insertions(+), 29 deletions(-) create mode 100644 notes-query.md diff --git a/README.md b/README.md index 5ef9003..1adee5e 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,33 @@ > Visualize yabai window stacks on macOS. Works with yabai & hammerspoon. -## ⚠️ WARNING: THIS IS A PROOF-OF-CONCEPT +## ⚠️ ~~WARNING: THIS IS A PROOF-OF-CONCEPT~~ (it's more like an 'alpha' now!) -Feel free to try it out and open issues / PRs, but using stackline full-time is not recommended (yet). +My humble thanks to all who have been suffering through error-ridden setup instructions, spinning fans, flickering UI, and crashes. I'm happy to say that I _think_ this branch fixes enough of these issues that it _should_ be reasonable for actual use ;-) -As of 2020-08-01, https://github.com/AdamWagner/stackline is a proof-of-concept for visualizing the # of windows in a stack & the currently active window. +As before, if you notice something that's off, or could be better, please open an issue (or a PR!). + +--- + +**2020-08-09 Status update** + +This update makes adds performance, reliability, and even some new functionality: + +- Hammerspoon is now responsible for querying and processing macOS window data. Hammerspoon's ability to coalesce the swarm of asynchronous change events that were causing your fans to spin up is a major improvement over calling `yabai -m query --windows --space …` dozens of times a minute. The move also made it possible to take a more traditional OOP approach, which has made tracking and mutating all of this desktop state a bit simpler. Unfortunately, it's still necessary to call out `yabai`, as it's the the keeper of each window's `stack-index` — a key bit of info that, afaict, is neither available elsewhere nor inferrable. That said, `yabai` is invoked _much less frequently_ in this update. +- In addition to only updating data when it's actually necessary, special attention has been given to _changing focus within a stack_: the POC blew away all of the UI state and _regenerated it from scratch … every … time … a window gained/lost focus. That approach is easier to think about, but it's far too slow to be useful. In this version, indicators should be _snappy_ when changing focus :) + +There's also some fun new functionality: + +- Stack indicators are always positioned on the side of the window that's closest to the edge of the screen. This allows for tight `window_gaps`, — even with `showIcons` enabled. +- Magic numbers are less entangled in the implementation details (though it's still pretty bad) — and a few of them have even been abstracted into easy-to-mess with configuration settings. The new `config.lua` file isn't that exciting yet (it's mostly boilerplate), but I think the _next_ update will bring the much-needed configuration mojo. + +**NOTE:** even though this update focused on performance and reliability, there are _still plenty of bugs_. One particularly annoying bug appears to be [Hammerspoon's fault](https://github.com/Hammerspoon/hammerspoon/issues/2400), and required ugly workarounds to get a so-so result. + +Also I'm still very new to lua and find its behavior (particularly its silence about errors) pretty baffling … it's quite hard to diagnose — or — even _notice_ small problems, which of course means they eventually become large, messy problems. ( ͡° ʖ̯ ͡°) If you're a lua pro and you're reading this, it'd be great to get your critique. --- + +**2020-08-01 | Status update** + +https://github.com/AdamWagner/stackline is a proof-of-concept for visualizing the # of windows in a stack & the currently active window. There is much crucial fuctionality that is either missing or broken. For example, stack indicators do not refresh when: @@ -22,26 +44,22 @@ There is much crucial fuctionality that is either missing or broken. For example 4. app icons are toggled on/off +## What is stackline & why would I want to use it? -## What is stackline & why do I need it? - -Consider a browser window with many tabs. +Consider a browser window with many tabs. A tabbed user interface consists of a collection of windows that occupy the same screen space. Only _one_ tabbed window may be visible at any given time, and it's the user's job to specify the 'active' window. -To enable this task, tabbed interfaces provide visual indicators for each tab, each occupying much less space than the tab it references, which enables all indicators to be visible at all times. Each indicator _identifies the contents of a window_ & _communicates its position relative to the active window_. - -A 'stack' provides a generalized subset of the functionality provided by tabbed user interfaces: it enables multiple to windows to occupy the same screen space, and provides mechanisms to navigate its member windows. It also provides mechanisms to add & remove windows from the stack. +Tabbed interfaces provide visual indicators for each tab. The indicators are relatively small, so they can be visible at all times. Each indicator _identifies the contents of a window_ & _communicates its position relative to the active window_. -Critically for stackline, a 'stack' does not provide the visual indicators necessary to identify how many windows belong to a stack or understand the relative position of the active window within the stack. +A 'stack' provides a generalized subset of a tabbed UI: it enables multiple to windows to occupy the same screen space, and provides mechanisms to navigate its member windows. It also provides mechanisms to add & remove windows from the stack. -Stacks are a recent addition (June 2020) to the (_excellent!_) macOS tiling window manager https://github.com/koekeishiya/yabai. The author has stated that he does not have plans to build stack indicators into yabai itself. +Stacks are a recent addition (June 2020) to the (_excellent!_) macOS tiling window manager [koekeishiya/yabai,](https://github.com/koekeishiya/yabai,) and visualization UI is not yet in-the-box. -Enter stackline, which adds non-obtrusive visual indicators to yabai'e 's stacking functionality. +Enter stackline, which adds non-obtrusive visual indicators to yabai'e 's stacking functionality. ![stackline-demo](assets/stackline-demo.gif) - ## Getting started with stackline **Prerequisites** @@ -51,11 +69,9 @@ Enter stackline, which adds non-obtrusive visual indicators to yabai'e 's stacki 3. https://github.com/stedolan/jq (`brew install jq`) -You're free to bind yabai commands using your favorite key remapper tool -(skhd, karabiner elements, and even hammerspoon are all viable options). +You're free to bind yabai commands using your favorite key remapper tool (skhd, karabiner elements, and even hammerspoon are all viable options). -That said, you're _probably_ using https://github.com/koekeishiya/skhd. If so, -now is a good time to map keys for navigating and manipulating yabai stacks. +That said, you're _probably_ using https://github.com/koekeishiya/skhd. If so, now is a good time to map keys for navigating and manipulating yabai stacks. ```sh # Focus window up/down in stack @@ -72,19 +88,49 @@ cmd + ctrl - right : yabai -m window east --stack $(yabai -m query --windows --w ### Installing stackline +1. Clone the repo into ~/.hammerspoon/stackline +2. Install the hammerspoon cli tool + +#### 1. Clone the repo into ~/.hammerspoon/stackline + ```sh # Get the repo -git clone https://github.com/AdamWagner/stackline.git ~/Downloads/stackline -cd ~/Downloads/stackline - -# Symlink stackline modules to your hammerspoon config dir -ln -sr ./stackline ~/.hammerspoon/stackline -ln -sr ./utils ~/.hammerspoon/utils -ln -sr ./bin ~/.hammerspoon/bin +git clone https://github.com/AdamWagner/stackline.git ~/.hammerspoon/stackline # Make stackline run when hammerspoon launches cd ~/.hammerspoon -echo 'require "stackline.stackline.core"' >> init.lua +echo 'require "stackline.stackline.stackline"' >> init.lua +``` + +Now your `~/.hammerspoon` directory should look like this: + +``` +├── init.lua +├── stackline +│ ├── bin +│ │ └── yabai-get-stacks +│ ├── stackline +│ │ ├── core.lua +│ │ ├── stack.lua +│ │ └── window.lua +│ └── utils +│ ├── flatten.lua +│ ├── table-utils.lua +│ ├── underscore.lua +│ └── utils.lua +├── … +``` + + +#### 2. Install the hammerspoon cli tool + +Open the hammerspoon console via the menu bar, type `hs.ipc.cliInstall()`, and hit return. + +Confirm that `hs` is now available: + +```sh +❯ which hs +/usr/local/bin/hs ``` ### RETRO? GO! FIDO? GO! GUIDANCE… @@ -109,6 +155,16 @@ Did the terminal window expand to cover the area previously occupied by Safari? ![stackline setup 01](assets/stackline-setup-01@2x.png) +The default stack indicator style is a "pill" as seen ↑ +To toggle icons: + +```sh + echo ":toggle_icons:1" | hs -m stackline-config +``` + +![stackline setup 02](assets/stackline-icon-indicators.png) + +Image (and feature!) courtesy of [@alin23](https://github.com/alin23). ## Help us get to v1.0.0! @@ -117,20 +173,27 @@ Give a ⭐️ if you think (a fully functional version of) stackline would be us ## Thanks to contributors! -All are welcome (actually, _please_ help us, 🤣️)! Feel free to dive in by opening an issue](https://github.com/AdamWagner/stackline/issues/new) or submitting a PR. +All are welcome (actually, _please_ help us, 🤣️)! Feel free to dive in by opening an [issue](https://github.com/AdamWagner/stackline/issues/new) or submitting a PR. [@AdamWagner](https://github.com/AdamWagner) wrote the initial proof-of-concept (POC) for stackline. -[@alin23](gh-alin23), initially proposed the [concept for stackline here](https://github.com/koekeishiya/yabai/issues/203#issuecomment-652948362) and encouraged [@AdamWagner](https://github.com/AdamWagner) to share this mostly-broken POC publicly. +[@alin23](https://github.com/alin23), initially proposed the [concept for stackline here](https://github.com/koekeishiya/yabai/issues/203#issuecomment-652948362) and encouraged [@AdamWagner](https://github.com/AdamWagner) to share this mostly-broken POC publicly. -[@zweck](gh-zweck), who, [in the same thread](https://github.com/koekeishiya/yabai/issues/203#issuecomment-656780281), got the gears turning about how [@alin23](gh-alin23)'s idea could be implemented and _also_ urged Adam to share his POC. +- After [@alin23](https://github.com/alin23)'s https://github.com/AdamWagner/stackline/pull/13, stackline sucks a lot less. + +Thanks to [@johnallen3d](https://github.com/johnallen3d) for being one the first folks to install stackline, and for identifying several mistakes & gaps in the setup instructions. + +[@zweck](https://github.com/zweck), who, [in the same thread](https://github.com/koekeishiya/yabai/issues/203#issuecomment-656780281), got the gears turning about how [@alin23](gh-alin23)'s idea could be implemented and _also_ urged Adam to share his POC. ### …on the shoulders of giants + Thanks to [@koekeishiya](gh-koekeishiya) without whom the _wonderful_ [yabai](https://github.com/koekeishiya/yabai) would not exist, and projects like this would have no reason to exist. Similarly, thanks to [@dominiklohmann](https://github.com/dominiklohmann), who has helped _so many people_ make chunkwm/yabai "do the thing" they want, that I seriously doubt either project would enjoy the vibrant user bases they do today. -Finally, thanks to [@cmsj](https://github.com/cmsj), [@asmagill](https://github.com/asmagill), and all of the contributors to [hammerspoon](https://github.com/Hammerspoon/hammerspoon) for opening up macOS APIs to all of us! +And of course, thanks to [@cmsj](https://github.com/cmsj), [@asmagill](https://github.com/asmagill), and all of the contributors to [hammerspoon](https://github.com/Hammerspoon/hammerspoon) for opening up macOS APIs to all of us! + +Thanks to the creators & maintainers of [underscore.lua](https://github.com/mirven/underscore.lua), [lume.lua](https://github.com/rxi/lume), [self.lua](https://github.com/M1que4s/self), and several other lua utility belts that have been helpful. ## License & attribution stackline is licensed under the [↗ MIT License](stackline-license), the same license used by [yabai](https://github.com/koekeishiya/yabai/blob/master/LICENSE.txt) and [hammerspoon](https://github.com/Hammerspoon/hammerspoon/blob/master/LICENSE). diff --git a/notes-query.md b/notes-query.md new file mode 100644 index 0000000..99e9d99 --- /dev/null +++ b/notes-query.md @@ -0,0 +1,112 @@ +# Refactor notes + +## Overview + +The goal of this file is to eliminate the need to 'shell out' to yabai to query +window data needed to render stackline, which would have addressed +https://github.com/AdamWagner/stackline/issues/8 if @alin32 hadn't implemented +an even better fix faster than I could ;_) + +Originally, I thought the main problem with relying on `yabai` was the 0.03s sleep required to ensure that the new window state is, in fact, represented in the query response from `yabai`. I've since noticed secondary downsides, such as overall performance. Specifically, `yabai` is frequently sluggish during something simple like focusing a window. This is, I presume, a side effect of `stackline` pummelling yabai with dozens of (mostly superfluous) `query` invocations. :confusion: + +So, instead, I want to try try using hammerspoon's mature (if complicated) hs.window.filter and hs.window modules to achieve the same goal natively within hammerspon. + +I also hope for tertiary benefits: + +- easier to implement enhancements that we haven't even considered yet +- easier to maintain +- drop the jq dependency + + +┌────────┐ +│ Status │ +└────────┘ +2020-08-02 +We're not yet using any of the code in this file to actually render the indiators or query ata — all of that is still achieved via the "old" methods. + +However, `query.lua` IS being required by ./core.lua and runs one every window focus event, and the resulting "stack" data is printed to the hammerspoon console. + +The stack data structure differs from that used in ./stack.lua enough that it won't work as a drop-in replacement. I think that's fine (and it wouldn't be worth attempting to make this a non-breaking change, esp. since (hopefully?) no one is rellying on `stackline` for daily computing. + +┌──────┐ +│ Next │ +└──────┘ +- [x] Integrate appropriate functionality in Query module the Core module +- [x] Integrate appropriate functionality in Query module into the Stack module +- [x] Update key Stack module functions to have basic compatiblity with the new + data structure +- [x] Simplify / refine Stack functions to leverage the benefits of having access to the hs.window module for each tracked window +- [x] Fix egregious indicator lag when switching the focused window in a stack + + +## Challenges & resolutions + +┌──────────────────────────────────────┐ +│ coalescing a torrent of async events │ +└──────────────────────────────────────┘ + +`hs.timer.delayed` has done a great job taming the flood of window-change events that can occur when, say, resizing windows in a tiled layout. + +NOTE: alternative: https://github.com/CommandPost/CommandPost/blob/develop/src/extensions/cp/deferred/init.lua This extension makes it simple to defer multiple actions after a delay from the initial execution. + +Unlike `hs.timer.delayed`, the delay will not be extended with subsequent `run()` calls, but the delay will trigger again if `run()` is called again later. + + +┌──────────────────────────────────────┐ +│ Methods to group windows into stacks │ +└──────────────────────────────────────┘ +TOP-LEFT ONLY +Rationale: Identifying frames by topLeft (frame.x, frame.y) of each window +addresses macos MIN WIN SIZE EDGE CASE that can result in a stacked +window NOT sharing the same dimensions. +PRO: + ensures such windows will be members of the stack +CON: + zoom-parent & zoom-fullscreen windows will ALSO be counted as stack members +PROPER FIX + Filter out windows with a 0 stack-index using yabai data + +NOTE: 'stackID' groups by full frame, so windows with min-size > stack +width will not be stacked properly. See above ↑ }}} + + +┌────────────────┐ +│ Draw vs Redraw │ +└────────────────┘ +I originally apporached this with a "reactjs" mindset — _destroy everythig on every render — that'll keep things simple! + +Unfortunately, it also made things _SLOW_. + +The costliest aspect of drawing the indicators from scratch each time is +fetching the icon & generating the icons's image. + +When it exists, it's MUCH faster/snappier to modify the attributes of existing canvas instances to achieve the desired "focused window has changed" effect. Obviously, 100ms isn't going to ruin anyone's day… But it _is_ the difference between: + +- "Hey, I wonder if _stackline_ is what's been driing the fans at full blast recently? It _does_ seem to struggle…" + +And: + +- "Damnit, Chrome. When will the Chrome team get their shit together? This is ridiculous… _—force quit—_" + + +┌───────────────────────────────────┐ +│ windows of the same app (hs bug) │ +└───────────────────────────────────┘ +There's a very annoying Hammerspoon bug that needed to be worked around: windowUnfocused event not fired for same-app windows. + +See https://github.com/Hammerspoon/hammerspoon/issues/2400 for more detail. + +NOTE: v1 *substantially* slowed down indicator redraw when focus changes. So much so as to justify storing an "otherAppWindows" field on each window. See history below: + +v1: Search for stack by window: + E.g., local stack = stacksMgr:findStackByWindow(stackedWin) + +v2: Lookup stack from window instead of searching by window ID: + local stack = stackedWin.stack + +v3: Store `otherAppWindows` directly on window: + +Related: + +- `./stack.lua:22` +- `./stack.lua:30` From e095747286c4d82388b390b2207ad2083bcfae18 Mon Sep 17 00:00:00 2001 From: adamwagner Date: Tue, 11 Aug 2020 13:31:18 -0700 Subject: [PATCH 15/33] Included a bunch of cleanup/reorg changes missed in last commit --- notes.md => general-dev-notes.md | 0 ...s-query.md => refactor-notes-2020-08-09.md | 0 stack.lua.bak | 219 ------------- stackline/config.lua | 103 ++++++ stackline/core.lua | 131 -------- stackline/query.lua | 146 ++------- stackline/stack.lua | 26 +- stackline/stackline.lua | 106 +++++++ stackline/{stackMgr.lua => stackmanager.lua} | 98 ++---- stackline/window.lua | 158 +++++----- utils/heap.lua | 181 ----------- utils/self.lua | 293 ++++++++++-------- utils/utils.lua | 16 +- 13 files changed, 508 insertions(+), 969 deletions(-) rename notes.md => general-dev-notes.md (100%) rename notes-query.md => refactor-notes-2020-08-09.md (100%) delete mode 100644 stack.lua.bak create mode 100644 stackline/config.lua delete mode 100644 stackline/core.lua create mode 100644 stackline/stackline.lua rename stackline/{stackMgr.lua => stackmanager.lua} (51%) delete mode 100644 utils/heap.lua diff --git a/notes.md b/general-dev-notes.md similarity index 100% rename from notes.md rename to general-dev-notes.md diff --git a/notes-query.md b/refactor-notes-2020-08-09.md similarity index 100% rename from notes-query.md rename to refactor-notes-2020-08-09.md diff --git a/stack.lua.bak b/stack.lua.bak deleted file mode 100644 index d29096a..0000000 --- a/stack.lua.bak +++ /dev/null @@ -1,219 +0,0 @@ -local _ = require 'stackline.utils.utils' -local Window = require 'stackline.stackline.window' -local u = require 'stackline.utils.underscore' -local tut = require 'stackline.utils.table-utils' -local under = require 'stackline.utils.underscore' - -local query = require 'stackline.stackline.WIP-query' - -local log = hs.logger.new('[stack]', 'debug') - -local Stack = {} - --- TODO: include hs.task functionality from core.lua in the Stack module directly - -function Stack:toggleIcons() -- {{{ - self.showIcons = not self.showIcons - Stack.update() -end -- }}} - --- function Stack:each_win_id(fn) -- {{{ --- _.each(self.tabStacks, function(stack) --- -- hs.alert.show('running each win id') --- -- _.pheader('stack') --- -- _.p(stack) --- -- _.p(under.values(stack)) --- local winIds = _.map(under.values(stack), function(w) --- return w.id --- end) --- -- print(hs.inspect(winIds)) - --- for i = 1, #winIds do --- -- ┌────────────────────┐ --- -- the main event! --- -- └────────────────────┘ --- -- hs.alert.show(winIds[i]) - --- fn(winIds[i]) -- Call the `fn` provided with win ID - --- -- hs.alert.show('inside final loop') - --- -- DEBUG --- -- print(hs.inspect(winIds)) --- -- print(winIds[i]) --- end --- end) --- end -- }}} - -function Stack:findWindow(wid) -- {{{ - -- NOTE: A window must be *in* a stack to be found with this method! - for _idx, stack in pairs(self.tabStacks) do - extantWin = stack[wid] - if extantWin then - return extantWin - end - end -end -- }}} - -function Stack:cleanup() -- {{{ - -- _.p('# to be cleaned: ', _.length(self.tabStacks)) - -- _.p('keys be cleaned: ', _.keys(self.tabStacks)) - - for key, stack in pairs(self.tabStacks) do - -- DEBUG: {{{ - -- _.p(stack) - -- _.pheader('stack keys') - -- _.p(_.map(stack, function(w) - -- return _.pick(w, {'id', 'app'}) - -- end)) }}} - - -- For each window, clear canvas element - _.each(stack, function(w) - -- _.pheader('window indicator in cleanup') - -- print(w.indicator) - w.indicator:delete() - end) - - self.tabStacks[key] = nil - end -end -- }}} - -_.pheader('running again!') -local cache = {} - -function Stack:newStack(stack, stackId) -- {{{ - -- DEBUG {{{ - -- _.pheader('cache') - -- _.p(cache) - - _.pheader('NEWSTACK') - -- print('stack window #:', #stack) - -- print('stack ID: ', stackId) }}} - local extantStack = self.tabStacks[stackId] - self.tabStacks[stackId] = extandStack or {} - - for winIdx, w in pairs(stack) do - if not extantStack then -- it's the first run {{{ - _.pheader('First run') - local win = Window:new(w) - - -- win:setStackIdx() {{{ - -- NOTE: blocking task - -- TODO: retrieve all window stack indexes in one yabai query and - -- weave into windows instead of calling each window }}} - - win:process(self.showIcons, winIdx) - win.indicator:show() - - self.tabStacks[stackId][win.id] = win - - win.cacheWin = win - win.cacheWin.focus = win:isFocused() - cache[win.id] = win -- }}} - else -- if extantStack *does* exist {{{ - local extantWin = extantStack[w.id].cacheWin - local win = Window:new(w) - - local noExtantWin = (type(extantWin) == 'nil') - local sameFocus = (extantWin.focus == win:isFocused()) - local shouldRedrawIndicator = (noExtantWin or not sameFocus) - - print('should redraw? ', shouldRedrawIndicator) - - if shouldRedrawIndicator then - extantWin.indicator:delete() - win:process(self.showIcons, winIdx) - win.indicator:show() - win.stackId = stackId -- set stackId on win for easy lookup later - self.tabStacks[stackId][win.id] = win - end - end -- if extantStack exists }}} - end -end -- }}} - -function Stack:ingest(windowData) -- {{{ - for stackId, stackWindows in pairs(windowData) do - Stack:newStack(stackWindows, stackId) - end -end -- }}} - -function Stack:update(shouldClean) -- {{{ - if shouldClean then -- {{{ - _.pheader('running cleanup') - Stack:cleanup() - end -- }}} - - query:windowsCurrentSpace() -- calls Stack:ingest when ready - -- Stack:ingest(newState) - - -- DEBUG {{{ - -- print('\n\n\n\n') - -- _.pheader('self.tabStack after update') - -- self:get() - -- _.pheader('focused windows') - -- _.p(hs.fnutils.map(self:get(), function(stack) - -- _.each(stack, function(w) - -- print(w.id, ' is ', w.focused) - -- end) - -- end)) - -- print('\n\n\n\n') - - -- OLD --------------------------------------------------------------------- - -- -- _.pheader('value of "shouldClean:"') - -- -- _.p(shouldClean) - -- -- print('\n\n') - -- if shouldClean then - -- _.pheader('running cleanup') - -- Stack:cleanup() - -- end - - -- local yabai_get_stacks = 'stackline/bin/yabai-get-stacks' - - -- hs.task.new("/usr/local/bin/dash", function(_code, stdout) - -- local windowData = hs.json.decode(stdout) - -- Stack:ingest(windowData) - -- end, {yabai_get_stacks}):start() }}} - -end -- }}} - -function Stack:get(shouldPrint) -- {{{ - if shouldPrint then - _.p(self.tabStacks, 3) - end - return self.tabStacks -end -- }}} - -function Stack:newStackManager() -- {{{ - self.tabStacks = {} - self.showIcons = false - return { - ingest = function(windowData) - return self:ingest(windowData) - end, - get = function() - return self:get() - end, - getCache = function() - return cache - end, - update = self.update, - cleanup = function() - return self:cleanup() - end, - toggleIcons = function() - return self:toggleIcons() - end, - findWindow = function(wid) - return self:findWindow(wid) - end, - each_win = function(wid) - return self:each_win_id(wid) - end, - get_win_str = function() - return Stack.win_str - end, - } -end -- }}} - -return Stack - diff --git a/stackline/config.lua b/stackline/config.lua new file mode 100644 index 0000000..917f8eb --- /dev/null +++ b/stackline/config.lua @@ -0,0 +1,103 @@ +local _ = require 'stackline.utils.utils' + +local handleSignal = function(_, msgID, msg) -- {{{ + if msgID == 900 then + return "version:2.0a" + end + + if msgID == 500 then + local key, _value = msg:match(".+:([%a_-]+):([%a%d_-]+)") + if key == "toggle_icons" then + stackConfig:toggle('showIcons') -- global var + end + end + return "ok" +end -- }}} + +-- ┌────────┐ +-- │ config │ +-- └────────┘ + +local StackConfig = {} + +function StackConfig:new() + local config = {id = 'stackline', store = hs.settings} + + setmetatable(config, self) + self.__index = self + return config +end + +function StackConfig:get(key) + local settingPath = self:makePath(key) + return self.store.get(settingPath) +end + +function StackConfig:set(key, val) + local settingPath = self:makePath(key) + self.store.set(settingPath, val) + return self.store.get(settingPath) +end + +function StackConfig:setEach(settingsTable) + for key, value in pairs(settingsTable) do + local settingPath = self:makePath(key) + self.store.set(settingPath, value) + end + return self +end + +function StackConfig:getOrSet(key, val) + local settingPath = self:makePath(key) + local existingVal = self.store.get(settingPath) + if val ~= nil then -- set if val provided + self.store.set(settingPath, val) + return val + else + return existingVal + end +end + +function StackConfig:toggle(key) + local toggledVal = not self:get(key) -- if key is not yet set, initial toggle is "on" + self:set(key, toggledVal) + return toggledVal +end + +function StackConfig:handleSingal(msgID, msg) + if msgID == 900 then + return "version:2.0a" + end + + if msgID == 500 then + local key, _value = msg:match(".+:([%a_-]+):([%a%d_-]+)") + if key == "toggle_icons" then + self:toggle('showIcons') + end + end + return "ok" +end + +function StackConfig:makePath(key) + return self.id .. '-' .. key +end + +function StackConfig:registerWatchers() + local key = 'showIcons' + local identifier = self:makePath(key .. '-handler') + local settingPath = self:makePath(key) + self.store.watchKey(identifier, settingPath, function(_val) + sm:toggleIcons() + end) + return self +end + +-- One very out-of-place hotkey binding (•_•) +hs.hotkey.bind({'alt', 'ctrl'}, 't', function() + sm:toggleIcons() +end) + +-- luacheck: ignore +ipcConfigPort = hs.ipc.localPort('stackline-config', handleSignal) + +return StackConfig diff --git a/stackline/core.lua b/stackline/core.lua deleted file mode 100644 index 69a24ff..0000000 --- a/stackline/core.lua +++ /dev/null @@ -1,131 +0,0 @@ -require("hs.ipc") - -local _ = require 'stackline.utils.utils' -local StackMgr = require 'stackline.stackline.stackMgr' -local tut = require 'stackline.utils.table-utils' -local wf = hs.window.filter - --- shortcuts -map = hs.fnutils.map -filter = hs.fnutils.filter -each = hs.fnutils.each -copy = hs.fnutils.copy -contains = hs.fnutils.contains -some = hs.fnutils.some -any = hs.fnutils.some -- also rename 'some()' to 'any()' - -stacksMgr = StackMgr:new(showIcons) --- _.pheader('stackmanager after construction') --- _.p(stacksManager) - -hs.hotkey.bind({'alt', 'ctrl'}, 't', function() - stacksMgr:toggleIcons() -end) - -local win_added = { -- {{{ - wf.windowCreated, - wf.windowUnhidden, - wf.windowUnminimized, -} -- }}} - -local win_changed = { -- {{{ - wf.windowFullscreened, - wf.windowUnfullscreened, - wf.windowMoved, -- NOTE: windowMoved captures movement OR resize events -} -- }}} - --- combine added & changed events -local added_changed = tut.mergeArrays(win_added, win_changed) - -local win_removed = { -- {{{ - wf.windowDestroyed, - wf.windowHidden, - wf.windowMinimized, - wf.windowNotInCurrentSpace, -} -- }}} - --- Global -wfd = wf.new():setOverrideFilter{ -- {{{ - visible = true, -- (i.e. not hidden and not minimized) - fullscreen = false, - currentSpace = true, - allowRoles = 'AXStandardWindow', -} -- }}} - --- TO CONFIRM: Compared to calling wsi.update() directly in wf:subscribe --- callback, even a delay of "0" appears to coalesce events as desired. --- NOTE: alternative: https://github.com/CommandPost/CommandPost/blob/develop/src/extensions/cp/deferred/init.lua --- This extension makes it simple to defer multiple actions after a delay from the initial execution. --- Unlike `hs.timer.delayed`, the delay will not be extended --- with subsequent `run()` calls, but the delay will trigger again if `run()` is called again later. -local queryWindowState = hs.timer.delayed.new(0.30, function() - stacksMgr:update() -end) - --- ┌──────────────────────────────────┐ --- │ Query window state subscriptions │ --- └──────────────────────────────────┘ - -local options = {fixSameAppHammerspoonBug = true} - --- callback args: window, app, event -wfd:subscribe(added_changed, function() - queryWindowState:start() -end) - -wfd:subscribe(win_removed, function() - queryWindowState:start() -end) - --- call once on load -stacksMgr:update() - --- ┌─────────────────────────────────┐ --- │ Update indicators subscriptions │ --- └─────────────────────────────────┘ - -function unfocusOtherAppWindows(win) -- {{{ - -- Fix HS bug: windowUnfocused event not fired for same-app windows - -- https://github.com/Hammerspoon/hammerspoon/issues/2400 - -- NOTE: substantially slows down indicator redraw when focus changes :< - -- So much so that it probably makes sense to store a "has >1 win - -- from app" field on each stack - - -- Related: - -- ./stack.lua:22 - -- ./stack.lua:30 - - -- v1: Search for stack by window: - -- E.g., local stack = stacksMgr:findStackByWindow(stackedWin) - -- v2: Lookup stack from window instead of searching by window ID: - -- local stack = stackedWin.stack - -- v3: Store `otherAppWindows` directly on window: - - -- See ./stack.lua:22 - each(win.otherAppWindows, function(w) - w:drawIndicator({shouldFade = false}) - end) - -end -- }}} - -function redrawWinIndicator(hsWin, _app, event) -- {{{ - -- Dedicated redraw method to *adjust* the existing canvas element is WAY - -- faster than deleting the entire indicator & rebuilding it from scratch, - -- particularly since this skips querying the app icon & building the icon image. - local id = hsWin:id() - local stackedWin = stacksMgr:findWindow(id) - - if stackedWin then -- if not found, then focused win is not stacked - local focused = (event == wf.windowFocused) and true or false - stackedWin:redrawIndicator({shouldFade = false}, focused) -- draw instantly on focus change - - if options.fixSameAppHammerspoonBug then - unfocusOtherAppWindows(stackedWin) - end - - end -end -- }}} - -wfd:subscribe(wf.windowFocused, redrawWinIndicator) -wfd:subscribe({wf.windowNotVisible, wf.windowUnfocused}, redrawWinIndicator) - diff --git a/stackline/query.lua b/stackline/query.lua index 53c9a24..6480cbd 100644 --- a/stackline/query.lua +++ b/stackline/query.lua @@ -1,3 +1,10 @@ +-- NOTES: Functionality from this file can be completely factored out into +-- stack.lua and stackline.lua. In fact, I've already done this once, but was +-- riding a bit too fast and found myself in a place where nothing worked, and I +-- didn't know why. So, this mess lives another day. Conceptually, it'll be +-- pretty easy to put this stuff where it belongs. + + -- luacheck: ignore (spaces isn't used, but augments hs.window module) local spaces = require("hs._asm.undocumented.spaces") local _ = require 'stackline.utils.utils' @@ -6,77 +13,9 @@ local u = require 'stackline.utils.underscore' -- stackline modules local Window = require 'stackline.stackline.window' ---[[ {{{ NOTES -The goal of this file is to eliminate the need to 'shell out' to yabai to query -window data needed to render stackline, which would address -https://github.com/AdamWagner/stackline/issues/8. The main problem with relying -on yabai is that a 0.03s sleep is required in the yabai script to ensure that -the changes that triggered hammerspoon's window event subscriber are, in fact, -represented in the query response from yabai. There are probably secondary -downsides, such as overall performance, and specifically *yabai* performance -(I've noticed that changing focus is slower when lots of yabai queries are -happening simultaneously). - -┌────────┐ -│ Status │ -└────────┘ -We're not yet using any of the code in this file to actually render the -indiators or query ata — all of that is still achieved via the "old" methods. - -However, this file IS being required by ./core.lua and runs one every window focus -event, and the resulting "stack" data is printed to the hammerspoon console. - -The stack data structure differs from that used in ./stack.lua enough that it -won't work as a drop-in replacement. I think that's fine (and it wouldn't be -worth attempting to make this a non-breaking change, esp. since zero people rely -on it as of 2020-08-02. - -┌──────┐ -│ Next │ -└──────┘ -- [ ] Integrate appropriate functionality in this file into the Core module -- [ ] Integrate appropriate functionality in this file into the Stack module -- [x] Update key Stack module functions to have basic compatiblity with the new data structure -- [x] Simplify / refine Stack functions to leverage the benefits of having access to the hs.window module for each tracked window -- [ ] … see if there's anything left and decide where it should live - -┌───────────┐ -│ WIP NOTES │ -└───────────┘ -Much of the functionality in this file should either be integrated into -stack.lua or core.lua — I don't think a new file is needed. - -Rather than calling out to the script ../bin/yabai-get-stacks, we're using -hammerspoon's mature (if complicated) hs.window.filter and hs.window modules to -achieve the same goal natively within hammerspon. - -There might be other benefits in addition to fixing the problems that inspired -#8: We get "free" access to the *hammerspoon* window module in the window data -tracked by stackline, which will probably make it easier to implement -enhancements that we haven't even considered yet. This approach should also be -easier to maintain, *and* we get to drop the jq dependency! - --- }}} --]] - --- Internal utils -local wfd = hs.window.filter.new():setOverrideFilter{ -- {{{ - visible = true, -- (i.e. not hidden and not minimized) - fullscreen = false, - currentSpace = true, - allowRoles = 'AXStandardWindow', -}:setSortOrder(hs.window.filter.sortByCreated) -- }}} - -function lenGreaterThanOne(t) -- {{{ - return #t > 1 -end -- }}} - -function winToHs(win) -- {{{ - return win._win -end -- }}} - local scriptPath = hs.configdir .. '/stackline/bin/yabai-get-stack-idx' + local Query = {} -Query.focusedWindow = nil function Query:getWinStackIdxs() -- {{{ hs.task.new("/usr/local/bin/dash", function(_code, stdout, _stderr) @@ -85,67 +24,51 @@ function Query:getWinStackIdxs() -- {{{ end -- }}} function Query:makeStacksFromWindows(ws) -- {{{ - local windows = map(ws, function(w) + local windows = _.map(ws, function(w) return Window:new(w) end) - -- Methods to group windows into stacks - -- ------------------------------------------------------------------------- - -- Rationale: Identifying frames by topLeft (frame.x, frame.y) of each window - -- addresses macos MIN WIN SIZE EDGE CASE that can result in a stacked - -- window NOT sharing the same dimensions. - -- PRO: - -- ensures such windows will be members of the stack - -- CON: - -- zoom-parent & zoom-fullscreen windows will ALSO be counted as stack members - -- PROPER FIX - -- Filter out windows with a 0 stack-index using yabai data - -- NOTE: 'stackID' groups by full frame, so windows with min-size > stack -- width will not be stacked properly. See above ↑ - local groupedWindows = _.groupBy(windows, 'stackId') + local groupedWins = _.groupBy(windows, 'stackId') + + local byStack = _.filter(groupedWins, _.greaterThan(1)) -- stacks have >1 window, so ignore 'groups' of 1 + local byApp = _.groupBy(_.reduce(u.values(byStack), _.concat), 'app') -- group stacked windows by app (app name is key) -- stacks contain more than one window, -- so ignore groups with only 1 window - stacks = hs.fnutils.filter(groupedWindows, lenGreaterThanOne) - self.stacks = stacks + self.appWindows = byApp + self.stacks = byStack end -- }}} function Query:mergeWinStackIdxs() -- {{{ hs.fnutils.each(self.stacks, function(stack) hs.fnutils.each(stack, function(win) - -- print(win.id) win.stackIdx = self.winStackIdxs[tostring(win.id)] end) end) end -- }}} function shouldRestack(new) -- {{{ - local curr = stacksMgr:getSummary() - local new = stacksMgr:getSummary(u.values(new)) - - -- _.p(curr) - -- _.p(new) + local curr = sm:getSummary() + local new = sm:getSummary(u.values(new)) if curr.numStacks ~= new.numStacks then - _.pheader('number of stacks changed') + print('num stacks changed') return true end if not _.equal(curr.topLeft, new.topLeft) then - _.pheader('position changed') - _.p(curr.topLeft) - _.p(new.topLeft) + print('position changed') return true end if not _.equal(curr.numWindows, new.numWindows) then - _.pheader('num windows changed') + print('num windows changed') return true end end -- }}} -local count = 0 function Query:windowsCurrentSpace() -- {{{ self:makeStacksFromWindows(wfd:getWindows()) -- set self.stacks @@ -158,13 +81,13 @@ function Query:windowsCurrentSpace() -- {{{ local shouldRefresh = false -- tmp var mocking ↑ - local extantStacks = stacksMgr:get() - local extantStackSummary = stacksMgr:getSummary() + local extantStacks = sm:get() + local extantStackSummary = sm:getSummary() local extantStackExists = extantStackSummary.numStacks > 0 if extantStackExists then shouldRefresh = shouldRestack(self.stacks, extantStacks) - stacksMgr:dimOccluded() + -- stacksMgr:dimOccluded() TODO: revisit in a future update. This is kind of an edge case — there are bigger fish to fry. else shouldRefresh = true end @@ -172,16 +95,9 @@ function Query:windowsCurrentSpace() -- {{{ if shouldRefresh then self:getWinStackIdxs() -- set self.winStackIdxs (async shell call to yabai) - -- DEBUG {{{ - -- _.pheader('wfd:getWindows() in query') - -- for _idx, win in pairs(wfd:getWindows()) do - -- print(win:application():title(), ":", win:title()) - -- end - -- }}} - function whenStackIdxDone() self:mergeWinStackIdxs() -- Add the stack indexes from yabai to the hs window data - stacksMgr:ingest(self.stacks, extantStackExists) -- hand over to the Stack module + sm:ingest(self.stacks, self.appWindows, extantStackExists) -- hand over to the Stack module end local pollingInterval = 0.1 @@ -190,17 +106,5 @@ function Query:windowsCurrentSpace() -- {{{ end, whenStackIdxDone, pollingInterval) end end -- }}} -return Query - --- Deprecated --- function Query.getSpaces() -- {{{ --- return fnutils.mapCat(screen.allScreens(), function(s) --- return spaces.layout()[s:spacesUUID()] --- end) --- end -- }}} --- function Query.getActiveSpaceIndex() -- {{{ --- local s = Query.getSpaces() --- local activeSpace = spaces.activeSpace() --- return _.indexOf(s, activeSpace) --- end -- }}} +return Query diff --git a/stackline/stack.lua b/stackline/stack.lua index 5af8a90..d89173f 100644 --- a/stackline/stack.lua +++ b/stackline/stack.lua @@ -18,18 +18,6 @@ local Stack = Class("Stack", nil, { new = function(self, stackedWindows) -- {{{ self.windows = stackedWindows - - each(self.windows, function(w) - -- Cache reference to stack on window for easy lookup - -- TODO: research cost of increased table size vs better stack lookup speed - -- Added to fix annoying HS bug detailed here: ./core.lua - w.otherAppWindows = self:getOtherAppWindows(w) - - -- NOTE: Can store other helpful references, like stack, too - -- I don't understand the perf. tradeoffs of size vs lookup speed, tho - -- w.stack = self - end) - self.id = stackedWindows[1].stackId end, -- }}} @@ -38,7 +26,7 @@ local Stack = Class("Stack", nil, { end, -- }}} getHs = function(self) -- {{{ - return map(self.windows, function(w) + return _.map(self.windows, function(w) return w._win end) end, -- }}} @@ -59,7 +47,7 @@ local Stack = Class("Stack", nil, { getOtherAppWindows = function(self, win) -- {{{ -- NOTE: may not need when HS issue #2400 is closed - return filter(self:get(), function(w) + return _.filter(self:get(), function(w) _.pheader('window in getOtherAppWindows') _.p(w) return w.app == win.app @@ -68,8 +56,6 @@ local Stack = Class("Stack", nil, { redrawAllIndicators = function(self) -- {{{ self:eachWin(function(win) - print('calling redraw indicator') - -- TODO see if it works *without* win:setupIndicator win:setupIndicator() win:drawIndicator() end) @@ -77,7 +63,6 @@ local Stack = Class("Stack", nil, { deleteAllIndicators = function(self) -- {{{ self:eachWin(function(win) - print('calling delete indicator') win:deleteIndicator() end) end, -- }}} @@ -118,16 +103,15 @@ local Stack = Class("Stack", nil, { end local windowsCurrSpace = wfd:getWindows() - local nonStackWindows = filter(windowsCurrSpace, notInStack) + local nonStackWindows = _.filter(windowsCurrSpace, notInStack) -- true if *any* non-stacked windows occlude the stack's frame -- NOTE: u.any() works, hs.fnutils.some does NOT work :~ - local stackIsOccluded = u.any(map(nonStackWindows, function(w) + local stackIsOccluded = u.any(_.map(nonStackWindows, function(w) return self:isWindowOccludedBy(w) end)) return stackIsOccluded - end, -- }}} - + end -- }}} }) return Stack diff --git a/stackline/stackline.lua b/stackline/stackline.lua new file mode 100644 index 0000000..e731273 --- /dev/null +++ b/stackline/stackline.lua @@ -0,0 +1,106 @@ +require("hs.ipc") + +local StackConfig = require 'stackline.stackline.config' +local Stackmanager = require 'stackline.stackline.stackmanager' +local _ = require 'stackline.utils.utils' + +print(hs.settings.bundleID) + +-- ┌────────┐ +-- │ config │ +-- └────────┘ +stackConfig = StackConfig:new():setEach({ + showIcons = false, + enableTmpFixForHsBug = true, +}):registerWatchers() + +-- instantiate an instance of the stack manager globally +sm = Stackmanager:new(showIcons) + +-- ┌─────────┐ +-- │ globals │ +-- └─────────┘ +wf = hs.window.filter +wfd = wf.new():setOverrideFilter{ + visible = true, -- (i.e. not hidden and not minimized) + fullscreen = false, + currentSpace = true, + allowRoles = 'AXStandardWindow', +} + +-- TODO: review how @alin32 structured window (and config!) events into +-- 'shouldRestack' and 'shouldClean' and integrate the good parts here. +local windowEvents = { + -- window added + wf.windowCreated, + wf.windowUnhidden, + wf.windowUnminimized, + + -- window changed + wf.windowFullscreened, + wf.windowUnfullscreened, + wf.windowMoved, -- NOTE: includes move AND resize events + + -- window removed + wf.windowDestroyed, + wf.windowHidden, + wf.windowMinimized, + wf.windowNotInCurrentSpace, +} + +-- TO CONFIRM: Compared to calling wsi.update() directly in wf:subscribe +-- callback, even a delay of "0" appears to coalesce events as desired. +local queryWindowState = hs.timer.delayed.new(0.30, function() + sm:update() +end) + +-- ┌───────────────────────────────┐ +-- │ window events → update stacks │ +-- └───────────────────────────────┘ +wfd:subscribe(windowEvents, function() + -- callback args: window, app, event + queryWindowState:start() +end) + +-- ┌───────────────────────────────────────────────┐ +-- │ special case: focus events → optimized redraw │ +-- └───────────────────────────────────────────────┘ +function unfocusOtherAppWindows(win) -- {{{ + -- To workaround HS BUG "windowUnfocused event not fired for same-app windows " + -- https://github.com/Hammerspoon/hammerspoon/issues/2400 + -- ../notes-query.md:103 + _.each(win.otherAppWindows, function(w) + w:redrawIndicator(false) + end) +end -- }}} + +function redrawWinIndicator(hsWin, _app, event) -- {{{ + -- Dedicated redraw method to *adjust* the existing canvas element is WAY + -- faster than deleting the entire indicator & rebuilding it from scratch, + -- particularly since this skips querying the app icon & building the icon image. + local id = hsWin:id() + local stackedWin = sm:findWindow(id) + if stackedWin then -- when falsey, the focused win is not stacked + -- BUG: Unfocused window(s) flicker when an app has 2+ win in a stack {{{ + -- TODO: If there are 2+ windows of the same app in a stack, then the + -- *unfocused* window(s) indicator(s) flash 'focused' styles for a split second *before* the + -- the actually focused window's indicator :< + -- REPRO TIP #1: A non-common app window must be between the same-app windows. + -- REPRO TIP #2: You must be switching FROM a non-common app window TO a a shared app-window. + -- Switching between same-app windows is fine, even when a + -- non-common window is in the same stack. You must. }}} + if stackConfig:get('enableTmpFixForHsBug') then + unfocusOtherAppWindows(stackedWin) + end + local focused = (event == wf.windowFocused) + stackedWin:redrawIndicator(focused) -- draw instantly on focus change + end +end -- }}} + +wfd:subscribe(wf.windowFocused, redrawWinIndicator) + +wfd:subscribe({wf.windowNotVisible, wf.windowUnfocused}, redrawWinIndicator) + +-- always update on load +sm:update() + diff --git a/stackline/stackMgr.lua b/stackline/stackmanager.lua similarity index 51% rename from stackline/stackMgr.lua rename to stackline/stackmanager.lua index 9e43627..1aee392 100644 --- a/stackline/stackMgr.lua +++ b/stackline/stackmanager.lua @@ -1,5 +1,6 @@ -- TODO: consolidate these utils! local _ = require 'stackline.utils.utils' +local u = require 'stackline.utils.underscore' -- stackline modules local Query = require 'stackline.stackline.query' @@ -9,82 +10,44 @@ local Stack = require 'stackline.stackline.stack' -- │ Stack module │ -- └──────────────┘ --- HEAP (unused for now) {{{ -i = 1 -local heap = require 'stackline.utils.heap' -- {{{ -h = heap.valueheap { - cmp = function(a, b) - _.pheader('compare in heap') - print('A:') - _.p(a) - print('B:') - _.p(b) - return a.timestamp < b.timestamp - end, -} -- }}} -function Stack:heapPush(stacks) -- {{{ - -- TODO: track stacks in heap here instead of in Stack:newStack() - local cloneStack = hs.fnutils.copy(stacks) - cloneStack.timestamp = hs.timer.absoluteTime() -- add timestamp for heap / diff - h:push(cloneStack) - if h:length() > 2 then - stackDiff() - end -end -- }}} -function stackDiff() -- {{{ - local curr = h:pop() - local last = h:pop() - - _.pheader('stack diff:') - print("current: ", curr.timestamp) - print("last: ", last.timestamp) - print("diff: ", curr.timestamp - last.timestamp) - - -- for _winIdx, w in pairs(curr) do - -- print(hs.inspect(w, {depth = 1})) - -- end -end -- }}} --- }}} - -local StacksMgr = {} -function StacksMgr:update() -- {{{ +local Stackmanager = {} +function Stackmanager:update() -- {{{ Query:windowsCurrentSpace() -- calls Stack:ingest when ready end -- }}} --- FIXME: Doesn't wor with multiple tab stacks on same screen (?!) -function StacksMgr:new() -- {{{ +function Stackmanager:new() -- {{{ self.tabStacks = {} - self.showIcons = true + self.showIcons = stackConfig:get('showIcons') return self end -- }}} -function StacksMgr:ingest(stacks, shouldClean) -- {{{ - -- self:heapPush(stacks) +function Stackmanager:ingest(stacks, appWindows, shouldClean) -- {{{ if shouldClean then - _.pheader('running cleanup') self:cleanup() end - for _stackId, stack in pairs(stacks) do - _.pheader('new stack') - _.p(stack) + for _stackId, stack in pairs(stacks) do + _.each(stack, function(win) + win.otherAppWindows = u.filter(appWindows[win.app], function(w) + return w.id ~= win.id + end) + end) table.insert(self.tabStacks, Stack(stack)) - self:redrawAllIndicators() end end -- }}} -function StacksMgr:get() -- {{{ +function Stackmanager:get() -- {{{ return self.tabStacks end -- }}} -function StacksMgr:eachStack(fn) -- {{{ +function Stackmanager:eachStack(fn) -- {{{ for _stackId, stack in pairs(self.tabStacks) do fn(stack) end end -- }}} -function StacksMgr:dimOccluded() -- {{{ +function Stackmanager:dimOccluded() -- {{{ self:eachStack(function(stack) if stack:isOccluded() then stack:dimAllIndicators() @@ -94,61 +57,57 @@ function StacksMgr:dimOccluded() -- {{{ end) end -- }}} -function StacksMgr:cleanup() -- {{{ - _.pheader('calling cleanup') +function Stackmanager:cleanup() -- {{{ self:eachStack(function(stack) - print('calling stackMgr:deleteAllIndicators()') stack:deleteAllIndicators() end) self.tabStacks = {} end -- }}} -function StacksMgr:getSummary(external) -- {{{ +function Stackmanager:getSummary(external) -- {{{ + -- Summariaes all stacks on the current space, making it easy to determine + -- what needs to be updated (if anything) local stacks = external or self.tabStacks return { numStacks = #stacks, - topLeft = map(stacks, function(s) + topLeft = _.map(stacks, function(s) local windows = external and s or s.windows return windows[1].topLeft end), - dimensions = map(stacks, function(s) + dimensions = _.map(stacks, function(s) local windows = external and s or s.windows return windows[1].stackId end), - numWindows = map(stacks, function(s) + numWindows = _.map(stacks, function(s) local windows = external and s or s.windows return #windows end), } end -- }}} -function StacksMgr:redrawAllIndicators() -- {{{ +function Stackmanager:redrawAllIndicators() -- {{{ self:eachStack(function(stack) stack:redrawAllIndicators() end) end -- }}} -function StacksMgr:toggleIcons() -- {{{ +function Stackmanager:toggleIcons() -- {{{ self.showIcons = not self.showIcons self:redrawAllIndicators() end -- }}} -function StacksMgr:findWindow(wid) -- {{{ +function Stackmanager:findWindow(wid) -- {{{ -- NOTE: A window must be *in* a stack to be found with this method! - -- print('…searchi win id:', wid) for _stackId, stack in pairs(self.tabStacks) do - -- print('searching', #stack, 'windows in stackID', _stackId) for _idx, win in pairs(stack.windows) do - print('searching', win.id, 'for', wid) if win.id == wid then - -- print('found window', win.id) return win end end end end -- }}} -function StacksMgr:findStackByWindow(win) -- {{{ +function Stackmanager:findStackByWindow(win) -- {{{ -- NOTE: may not need when HS issue #2400 is closed -- NOTE 2: Unused, since I'm storing reference to "otherAppWindows" directly on each window for _stackId, stack in pairs(self.tabStacks) do @@ -158,9 +117,8 @@ function StacksMgr:findStackByWindow(win) -- {{{ end end -- }}} -function StacksMgr:getShowIconsState() -- {{{ +function Stackmanager:getShowIconsState() -- {{{ return self.showIcons end -- }}} -return StacksMgr - +return Stackmanager diff --git a/stackline/window.lua b/stackline/window.lua index e704c00..6aa566e 100644 --- a/stackline/window.lua +++ b/stackline/window.lua @@ -1,61 +1,28 @@ local _ = require 'stackline.utils.utils' local u = require 'stackline.utils.underscore' -function makeStackId(win) -- {{{ - -- stackId is top-left window frame coordinates - -- example: "302|35|63|1185|741" - -- OLD definition: - -- generate stackId from spaceId & frame values - -- example (old): "302|35|63|1185|741" - local frame = win:frame():floor() - local x = frame.x - local y = frame.y - local w = frame.w - local h = frame.h - return { - topLeft = table.concat({x, y}, '|'), - stackId = table.concat({x, y, w, h}, '|'), - } -end -- }}} - -- ┌───────────────┐ -- │ Window module │ -- └───────────────┘ - local Window = {} --- luacheck: ignore function Window:new(hsWin) -- {{{ local ws = { - -- title = w:title(), -- window title for debug only (string) + title = hsWin:title(), -- window title app = hsWin:application():name(), -- app name (string) id = hsWin:id(), -- window id (string) NOTE: the ID is the same as yabai! So we could interopt if we need to frame = hsWin:frame(), -- x,y,w,h of window (table) stackIdx = hsWin.stackIdx, -- only from yabai, unfort. - stackId = makeStackId(hsWin).stackId, -- "{{x}|{y}|{w}|{h}" e.g., "35|63|1185|741" (string) - topLeft = makeStackId(hsWin).topLeft, -- "{{x}|{y}" e.g., "35|63" (string) + stackId = self:makeStackId(hsWin).stackId, -- "{{x}|{y}|{w}|{h}" e.g., "35|63|1185|741" (string) + topLeft = self:makeStackId(hsWin).topLeft, -- "{{x}|{y}" e.g., "35|63" (string) _win = hsWin, -- hs.window object (table) indicator = nil, -- the canvas element (table) } - setmetatable(ws, self) self.__index = self return ws end -- }}} -function Window:setStackIdx() -- {{{ - -- FIXME: Too slow. Probably want to query all windows on space, pluck out - -- their stack indexes with jq, & send to hammerspoon to merge with windows. - - -- _.pheader('running setStackIdx for: ' .. self.id) - local scriptPath = hs.configdir .. '/stackline/bin/yabai-get-stack-idx' - hs.task.new("/usr/local/bin/dash", function(_code, stdout, stderr) - local stackIdx = tonumber(stdout) - self.stackIdx = stackIdx - -- print('stack idx for ', self.id, ' is ', stackIdx) - end, {scriptPath, tostring(self.id)}):start():waitUntilExit() -end -- }}} - function Window:isFocused() -- {{{ local focusedWin = hs.window.focusedWindow() if focusedWin == nil then @@ -65,22 +32,7 @@ function Window:isFocused() -- {{{ return isFocused end -- }}} --- function Window.__eq(a, b) -- {{{ --- -- FIXME: unused as of 2020-07-31 --- local t1 = a.id --- local t2 = b.id --- print('Window.__eq metamethod called:', a.id, a.focused, ' < VS: > ', t2, --- b.focused) --- local existComp = {id = a.id, frame = a.frameFlat, focused = a.focused} --- local currComp = {id = b.id, frame = b.frameFlat, focused = b.focused} --- -- _.p('A Compare:', existComp) --- -- _.p('B Compare:', currComp) --- local isEqual = _.isEqual(existComp, currComp) --- return isEqual --- end -- }}} - function Window:getScreenSide() -- {{{ - -- (sFrame.w - (wFrame.x + wFrame.w)) / sFrame.w local screenWidth = self._win:screen():fullFrame().w local frame = self.frame local percRight = 1 - ((screenWidth - (frame.x + frame.w)) / screenWidth) @@ -97,15 +49,9 @@ function Window:getScreenSide() -- {{{ -- self._win:windowsToSouth() end -- }}} --- TODO: ↑ Convert to .__eq metatable -function Window:setNeedsUpdated(extant) -- {{{ - local isEqual = _.isEqual(existComp, currComp) - self.needsUpdated = not isEqual -end -- }}} - -function Window:setupIndicator(Icons) -- {{{ +function Window:setupIndicator() -- {{{ -- Config - local showIcons = stacksMgr:getShowIconsState() + local showIcons = sm:getShowIconsState() -- Padding self.padding = 4 @@ -119,10 +65,9 @@ function Window:setupIndicator(Icons) -- {{{ -- Position self.offsetY = 2 self.offsetX = 4 - - -- Overlapped with window + percent top offset - -- self.offsetY = self.frame.h * 0.1 - -- self.offsetX = -(self.width / 2) + -- example: overlapped with window + percent top offset + -- self.offsetY = self.frame.h * 0.1 + -- self.offsetX = -(self.width / 2) -- Roundness self.indicatorRadius = 3 @@ -135,7 +80,7 @@ function Window:setupIndicator(Icons) -- {{{ -- left edge of windows on the left side of the screen, & -- right edge of windows on the right side of the screen local side = self:getScreenSide() - local xval = nil + local xval if side == 'right' then xval = (self.frame.x + self.frame.w) + self.offsetX else @@ -166,14 +111,14 @@ function Window:setupIndicator(Icons) -- {{{ } end -- }}} -function Window:drawIndicator(overrideOpts, focusedHint) -- {{{ - local defaultOpts = { +function Window:drawIndicator(overrideOpts) -- {{{ + self.defaultOpts = { shouldFade = true, alphaFocused = 1, alphaUnfocused = 0.33, } - local opts = u.extend(defaultOpts, overrideOpts or {}) + local opts = u.extend(self.defaultOpts, overrideOpts or {}) -- Color self.colorFocused = {white = 0.9, alpha = opts.alphaFocused} @@ -181,11 +126,9 @@ function Window:drawIndicator(overrideOpts, focusedHint) -- {{{ -- Unfocused icons less transparent than bg color, but no more than 1 self.iconAlphaFocused = opts.alphaFocused - self.iconAlphaUnfocused = math.min(opts.alphaUnfocused * 2, 1) + self.iconAlphaUnfocused = math.min(opts.alphaUnfocused * 2.25, 1) - self.shadowOpts = {blur = self.focus} - - local showIcons = stacksMgr:getShowIconsState() + local showIcons = sm:getShowIconsState() local radius = showIcons and self.iconRadius or self.indicatorRadius local fadeDuration = opts.shouldFade and self.fadeDuration or 0 @@ -202,11 +145,6 @@ function Window:drawIndicator(overrideOpts, focusedHint) -- {{{ fillColor = self.focus and self.colorFocused or self.colorUnfocused, imageAlpha = self.focus and self.iconAlphaFocused or self.iconAlphaUnfocused, - shadow = { - blurRadius = 20.0, - color = {alpha = 1 / 5}, - offset = {h = -2.0, w = 0.0}, - }, } self.indicator:insertElement({ @@ -217,6 +155,7 @@ function Window:drawIndicator(overrideOpts, focusedHint) -- {{{ roundedRectRadii = {xRadius = radius, yRadius = radius}, padding = 60, withShadow = true, + shadow = self:getShadowAttrs(), }, self.rectIdx) if showIcons then @@ -233,9 +172,7 @@ function Window:drawIndicator(overrideOpts, focusedHint) -- {{{ self.indicator:show(fadeDuration) end -- }}} -function Window:redrawIndicator(overrideOpts, isFocused) -- {{{ - _.pheader('redraw') - print(self.id, self.app, isFocused) +function Window:redrawIndicator(isFocused) -- {{{ -- bail early if there's nothing to do if isFocused == self.focus then return false @@ -243,18 +180,18 @@ function Window:redrawIndicator(overrideOpts, isFocused) -- {{{ self.focus = isFocused end - local set = _.partial(self.indicator.elementAttribute, self.indicator) - local setRect = _.partial(set, self.rectIdx) - local setIcon = _.partial(set, self.iconIdx) + if not self.indicator then + self:setupIndicator() + end - local fillColor = self.focus and self.colorFocused or self.colorUnfocused - local imageAlpha = self.focus and self.iconAlphaFocused or - self.iconAlphaUnfocused + local f = self.focus + local rect = self.indicator[self.rectIdx] + local icon = self.indicator[self.iconIdx] - setRect('fillColor', fillColor) - if stacksMgr:getShowIconsState() then - print(self.focus, 'imageAlpha:', imageAlpha) - setIcon('imageAlpha', imageAlpha) + rect.fillColor = f and self.colorFocused or self.colorUnfocused + rect.shadow = self:getShadowAttrs(f) + if sm:getShowIconsState() then + icon.imageAlpha = f and self.iconAlphaFocused or self.iconAlphaUnfocused end end -- }}} @@ -263,6 +200,49 @@ function Window:iconFromAppName() -- {{{ return hs.image.imageFromAppBundle(appBundle) end -- }}} +function Window:getShadowAttrs() -- {{{ + local shadowAlpha = self.focus and 3 or 3.75 -- denominator in 1 / N, so "2" == 50% alpha + local shadowBlur = self.focus and 18.0 or 5.0 + + -- Shadows should cast outwards toward the screen edges as if due to the glow of onscreen windows… + -- …or, if you prefer, from a light source originating from the center of the screen. + local shadowXDirection = (self:getScreenSide() == 'left') and -1 or 1 + local shadowOffset = { + h = (self.focus and 3.0 or 2.0) * -1.0, + w = (self.focus and 7.0 or 6.0) * shadowXDirection, + } + -- TODO [just for fun]: Dust off an old Geometry textbook and try get the + -- shadow's angle to rotate around a point at the center of the screen (aka, 'light source'). + -- Here's a super crude POC that uses the indicator's stack index such that + -- higher indicators have a negative Y offset and lower indicators have a + -- positive Y offset ;-) + -- h = (self.focus and 3.0 or 2.0 - (2 + (self.stackIdx * 5))) * -1.0, + + -- TODO ↓ align all alpha values to be defined like this + return { + blurRadius = shadowBlur, + color = {alpha = 1 / shadowAlpha}, + offset = shadowOffset, + } +end -- }}} + +function Window:makeStackId(hsWin) -- {{{ + -- stackId is top-left window frame coordinates + -- example: "302|35|63|1185|741" + -- OLD definition: + -- generate stackId from spaceId & frame values + -- example (old): "302|35|63|1185|741" + local frame = hsWin:frame():floor() + local x = frame.x + local y = frame.y + local w = frame.w + local h = frame.h + return { + topLeft = table.concat({x, y}, '|'), + stackId = table.concat({x, y, w, h}, '|'), + } +end -- }}} + function Window:deleteIndicator() -- {{{ if self.indicator then self.indicator:delete(self.fadeDuration) diff --git a/utils/heap.lua b/utils/heap.lua deleted file mode 100644 index f39b920..0000000 --- a/utils/heap.lua +++ /dev/null @@ -1,181 +0,0 @@ --- ----------------------------------------------------------------------------- --- FROM: https://github.com/luapower/heap/blob/master/heap.lua --- ----------------------------------------------------------------------------- --- Priority queue implemented as a binary heap. --- Written by Cosmin Apreutesei. Public Domain. -if not ... then - require 'heap_test'; - return -end - -local ffi -- init on demand so that the module can be used without luajit -local assert, floor = assert, math.floor - --- heap algorithm working over abstract API that counts from one. - -local function heap(add, remove, swap, length, cmp) - - local function moveup(child) - local parent = floor(child / 2) - while child > 1 and cmp(child, parent) do - swap(child, parent) - child = parent - parent = floor(child / 2) - end - return child - end - - local function movedown(parent) - local last = length() - local child = parent * 2 - while child <= last do - if child + 1 <= last and cmp(child + 1, child) then - child = child + 1 -- sibling is smaller - end - if not cmp(child, parent) then - break - end - swap(parent, child) - parent = child - child = parent * 2 - end - return parent - end - - local function push(...) - add(...) - return moveup(length()) - end - - local function pop(i) - swap(i, length()) - remove() - movedown(i) - end - - local function rebalance(i) - if moveup(i) == i then - movedown(i) - end - end - - return push, pop, rebalance -end - --- cdata heap working over a cdata array - -local function cdataheap(h) - ffi = ffi or require 'ffi' - assert(h and h.size, 'size expected') - assert(h.size >= 2, 'size too small') - assert(h.ctype, 'ctype expected') - local ctype = ffi.typeof(h.ctype) - h.data = h.data or ffi.new(ffi.typeof('$[?]', ctype), h.size) - local t, n, maxn = h.data, h.length or 0, h.size - 1 - local function add(v) - n = n + 1; - t[n] = v - end - local function rem() - n = n - 1 - end - local function swap(i, j) - t[0] = t[i]; - t[i] = t[j]; - t[j] = t[0] - end - local function length() - return n - end - local cmp = h.cmp and function(i, j) - return h.cmp(t[i], t[j]) - end or function(i, j) - return t[i] < t[j] - end - local push, pop, rebalance = heap(add, rem, swap, length, cmp) - - local function get(i, box) - assert(i >= 1 and i <= n, 'invalid index') - if box then - box[0] = t[i] - else - return ffi.new(ctype, t[i]) - end - end - function h:push(v) - assert(n < maxn, 'buffer overflow') - push(v) - end - function h:pop(i, box) - assert(n > 0, 'buffer underflow') - local v = get(i or 1, box) - pop(i or 1) - return v - end - function h:peek(i, box) - return get(i or 1, box) - end - function h:replace(i, v) - assert(i >= 1 and i <= n, 'invalid index') - t[i] = v - rebalance(i) - end - h.length = length - - return h -end - --- value heap working over a Lua table - -local function valueheap(h) - h = h or {} - local t, n = h, #h - local function add(v) - n = n + 1; - t[n] = v - end - local function rem() - t[n] = nil; - n = n - 1 - end - local function swap(i, j) - t[i], t[j] = t[j], t[i] - end - local function length() - return n - end - local cmp = h.cmp and function(i, j) - return h.cmp(t[i], t[j]) - end or function(i, j) - return t[i] < t[j] - end - local push, pop, rebalance = heap(add, rem, swap, length, cmp) - - local function get(i) - assert(i >= 1 and i <= n, 'invalid index') - return t[i] - end - function h:push(v) - assert(v ~= nil, 'invalid value') - push(v) - end - function h:pop(i) - assert(n > 0, 'buffer underflow') - local v = get(i or 1) - pop(i or 1) - return v - end - function h:peek(i) - return get(i or 1) - end - function h:replace(i, v) - assert(i >= 1 and i <= n, 'invalid index') - t[i] = v - rebalance(i) - end - h.length = length - - return h -end - -return {heap = heap, valueheap = valueheap, cdataheap = cdataheap} diff --git a/utils/self.lua b/utils/self.lua index 057e5d8..8a8be0c 100644 --- a/utils/self.lua +++ b/utils/self.lua @@ -1,171 +1,194 @@ --- FROM https://github.com/M1que4s/self/blob/master/self.lua --- 4 stars --- Updated Jul 2020 - --- ADDED: 2020-08-07 - --- ALTERNATIVES to consider: --- --- 1. https://github.com/siffiejoe/lua-classy/ --- 531 lines --- 21 stars --- updated July 2020 --- --- 2. https://github.com/superzazu/bluclass.lua/tree/master --- ~30 lines --- 1 star --- updated Oct 2019 --- --- 3. https://github.com/kikito/middleclass --- 183 lines --- 1.2k stars --- updated Mar 2018 --- good docs: https://github.com/kikito/middleclass/wiki/Quick-Example - --- 4. https://github.com/niu2x/luaclass --- only 60 lines (vs. 139) --- updated Dec 2019 --- 1 star --- --- 5. https://github.com/limadm/lua-oo --- 80 lines --- 7 stars --- updated Oct 2017 --- --- 6. https://github.com/kurapica/PLoop --- 1000s of lines? Multi file, too many to count --- 138 stars --- updated Aug 2020 - -local unp = unpack or table.unpack +local unp = unpack or table.unpack local getmt = getmetatable local setmt = setmetatable local Class = {} -Class.__index = Class -Class.__name = "Object" -Class.__parent = { Class } +Class.__index = Class +Class.__name = "Object" +Class.__parent = {Class} local function dump(t, name, indent) - local cart - local autoref - - local function isemptytable(t) return next(t) == nil end - - local function basicSerialize (o) - local so = tostring(o) - if type(o) == "function" then - local info = debug.getinfo(o, "S") - if info.what == "C" then return string.format("%q", so .. ", C function") - else return ("%s, in %d-%d %s"):format(so, info.linedefined, info.lastlinedefined, info.source) end - elseif type(o) == "number" or type(o) == "boolean" then return so - else return string.format("%q", so) end - end - - local function addtocart (value, name, indent, saved, field) - indent = indent or "" - saved = saved or {} - field = field or name - cart = cart .. indent .. field - if type(value) ~= "table" then cart = cart .. " = " .. basicSerialize(value) .. "\n" - else - if saved[value] then - cart = cart .. " = {} -- " .. saved[value] .. " (self reference)\n" - autoref = autoref .. name .. " = " .. saved[value] .. "\n" - else - saved[value] = name - if isemptytable(value) then cart = cart .. " = {}\n" - else cart = cart .. " = {\n" - for k, v in pairs(value) do - k = basicSerialize(k) - local fname = string.format("%s[%s]", name, k) - field = string.format("[%s]", k) - addtocart(v, fname, indent .. " ", saved, field) - end cart = cart .. indent .. "}\n" - end - end + local cart + local autoref + + local function isemptytable(t) + return next(t) == nil + end + + local function basicSerialize(o) + local so = tostring(o) + if type(o) == "function" then + local info = debug.getinfo(o, "S") + if info.what == "C" then + return string.format("%q", so .. ", C function") + else + return ("%s, in %d-%d %s"):format(so, info.linedefined, + info.lastlinedefined, info.source) + end + elseif type(o) == "number" or type(o) == "boolean" then + return so + else + return string.format("%q", so) + end + end + + local function addtocart(value, name, indent, saved, field) + indent = indent or "" + saved = saved or {} + field = field or name + cart = cart .. indent .. field + if type(value) ~= "table" then + cart = cart .. " = " .. basicSerialize(value) .. "\n" + else + if saved[value] then + cart = cart .. " = {} -- " .. saved[value] .. + " (self reference)\n" + autoref = autoref .. name .. " = " .. saved[value] .. "\n" + else + saved[value] = name + if isemptytable(value) then + cart = cart .. " = {}\n" + else + cart = cart .. " = {\n" + for k, v in pairs(value) do + k = basicSerialize(k) + local fname = string.format("%s[%s]", name, k) + field = string.format("[%s]", k) + addtocart(v, fname, indent .. " ", saved, field) + end + cart = cart .. indent .. "}\n" + end + end + end end - end - name = name or "__unnamed__" - if type(t) ~= "table" then return name .. " = " .. basicSerialize(t) end - cart, autoref = "", "" - addtocart(t, name, indent) - return cart .. autoref + name = name or "__unnamed__" + if type(t) ~= "table" then + return name .. " = " .. basicSerialize(t) + end + cart, autoref = "", "" + addtocart(t, name, indent) + return cart .. autoref end local function err(exp, msg, ...) - local msg = msg:format(...) - if not (exp) then error(msg, 0) else return exp end + local msg = msg:format(...) + if not (exp) then + error(msg, 0) + else + return exp + end end function Class:create(name, parent, def, G) - err(type(name) == "string" or type(name) == "nil", "Object.new: bad argument #1, string expected, got %s", type(name)) - err(type(parent) == "table" or type(parent) == "nil", "Object.new: bad argument #2, class expected, got %s", type(parent)) - err(type(def) == "table" or type(def) == "nil", "Object.new: bad argument #3, table expected, got %s", type(def)) - err(type(G) == "boolean" or type(G) == "nil", "Object.new: bad argument #4, boolean expected, got %s", type(G)) - - local cls = def or {} - cls.__parent = { self } - - if parent then - table.insert(cls.__parent, parent) - for k, v in pairs(parent) do if not cls[k] then cls[k] = v end end - for i, v in ipairs(parent.__parent) do - if not (parent.__parent[i] == (self or cls or parent)) then table.insert(cls.__parent, v) end + err(type(name) == "string" or type(name) == "nil", + "Object.new: bad argument #1, string expected, got %s", type(name)) + err(type(parent) == "table" or type(parent) == "nil", + "Object.new: bad argument #2, class expected, got %s", type(parent)) + err(type(def) == "table" or type(def) == "nil", + "Object.new: bad argument #3, table expected, got %s", type(def)) + err(type(G) == "boolean" or type(G) == "nil", + "Object.new: bad argument #4, boolean expected, got %s", type(G)) + + local cls = def or {} + cls.__parent = {self} + + if parent then + table.insert(cls.__parent, parent) + for k, v in pairs(parent) do + if not cls[k] then + cls[k] = v + end + end + for i, v in ipairs(parent.__parent) do + if not (parent.__parent[i] == (self or cls or parent)) then + table.insert(cls.__parent, v) + end + end end - end - cls.__index = cls - cls.__name = name or "__AnonymousClass__" + cls.__index = cls + cls.__name = name or "__AnonymousClass__" - setmt(cls, self) - if G then - err(name, "Object.new: no name for global class") - err(not _G[name], "Object.new: class '%s' already exists", name) - rawset(_G, name, cls) - else return cls end + setmt(cls, self) + if G then + err(name, "Object.new: no name for global class") + err(not _G[name], "Object.new: class '%s' already exists", name) + rawset(_G, name, cls) + else + return cls + end end function Class:uses(...) - err(self.__name ~= "Object", "Object.uses: attempt to modify parent class 'Object'") - local va = {...} - err(#va >= 1, "Object.uses: one or more classes expected, got %d", #va) - for idx, cls in pairs(va) do - err(type(va[idx]) == "table", "Object.uses: bad argument #%d, class expected, got %s", idx, type(va[idx])) - for k, v in pairs(cls) do if type(v) == "function" and not rawget(self, k) then rawset(self, k, v) end end - end + err(self.__name ~= "Object", + "Object.uses: attempt to modify parent class 'Object'") + local va = {...} + err(#va >= 1, "Object.uses: one or more classes expected, got %d", #va) + for idx, cls in pairs(va) do + err(type(va[idx]) == "table", + "Object.uses: bad argument #%d, class expected, got %s", idx, + type(va[idx])) + for k, v in pairs(cls) do + if type(v) == "function" and not rawget(self, k) then + rawset(self, k, v) + end + end + end end function Class:is(cls) - err(type(cls) == "table", "Object.is: bad argument, class expected, got %s", type(cls)) - for i, v in ipairs(self.__parent) do if self.__parent[i] == cls or self.__name == cls.__name then return true end end - return false + err(type(cls) == "table", "Object.is: bad argument, class expected, got %s", + type(cls)) + for i, v in ipairs(self.__parent) do + if self.__parent[i] == cls or self.__name == cls.__name then + return true + end + end + return false end function Class:isClass(obj) - if not obj or type(obj) ~= "table" then return nil - elseif getmt(obj) == self then return true - elseif getmt(getmt(obj)) == self then return true - else return false end + if not obj or type(obj) ~= "table" then + return nil + elseif getmt(obj) == self then + return true + elseif getmt(getmt(obj)) == self then + return true + else + return false + end end function Class:dump(details, indent) - err(type(details) == "boolean" or "nil", "Object.dump: bad argument #1, boolean expected, got %s", type(details)) - err(type(indent) == "string" or "nil", "Object.dump: bad argument #2, string expected, got %s", type(indent)) - if details then return dump(getmt(self), self.__name, indent) - else return dump(self, self.__name, indent) end + err(type(details) == "boolean" or "nil", + "Object.dump: bad argument #1, boolean expected, got %s", type(details)) + err(type(indent) == "string" or "nil", + "Object.dump: bad argument #2, string expected, got %s", type(indent)) + if details then + return dump(getmt(self), self.__name, indent) + else + return dump(self, self.__name, indent) + end end function Class:__call(...) - if self.__name == "Object" then return self:create(...) end - local o = setmt({}, self) - if rawget(self, "new") then o:new(...) - elseif rawget(self, "init") then o:init(...) - else err(nil, "%s: no constructor defined", o.__name) end - return o + if self.__name == "Object" then + return self:create(...) + end + local o = setmt({}, self) + if rawget(self, "new") then + o:new(...) + elseif rawget(self, "init") then + o:init(...) + else + err(nil, "%s: no constructor defined", o.__name) + end + return o end -function Class:__tostring() return ("Class '%s'"):format(self.__name) end +function Class:__tostring() + return ("Class '%s'"):format(self.__name) +end setmt(Class, Class) return Class diff --git a/utils/utils.lua b/utils/utils.lua index 1fd2b47..9cd27d4 100644 --- a/utils/utils.lua +++ b/utils/utils.lua @@ -205,8 +205,12 @@ function utils.pick(input, key_values) -- {{{ end -- }}} function utils.p(data, howDeep) -- {{{ - howDeep = howDeep or 3 - print(hs.inspect(data, {depth = howDeep})) + local depth = howDeep or 3 + if type(data) == 'table' then + print(hs.inspect(data, {depth = depth})) + else + print(hs.inspect(data, {depth = depth})) + end end -- }}} function utils.pdivider(str) -- {{{ @@ -271,6 +275,14 @@ function utils.equal(a, b) -- {{{ return true end -- }}} +function utils.Set(list) -- {{{ + local set = {} + for _, l in ipairs(list) do + set[l] = true + end + return set +end -- }}} + -- TODO: Confirm that hs.fnutils.partial works just as well function utils.partial(f, ...) -- {{{ -- FROM: https://www.reddit.com/r/lua/comments/fh2go5/a_partialcurry_implementation_of_mine_hope_you/ From 02d975854c84a8bb44533fd6f7bc53650916c7cd Mon Sep 17 00:00:00 2001 From: adamwagner Date: Tue, 11 Aug 2020 16:04:07 -0700 Subject: [PATCH 16/33] Fix path to dash shell (again) --- bin/yabai-get-stack-idx | 12 +----------- bin/yabai-get-stacks | 2 +- 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/bin/yabai-get-stack-idx b/bin/yabai-get-stack-idx index 60bb24a..f10a477 100755 --- a/bin/yabai-get-stack-idx +++ b/bin/yabai-get-stack-idx @@ -1,15 +1,5 @@ -#!/usr/local/bin/dash +#!/bin/dash /usr/local/bin/yabai -m query --windows --space \ | /usr/local/bin/jq --raw-output --compact-output --monochrome-output 'map({"\(.id)": .["stack-index"]}) | reduce .[] as $item ({}; . + $item)' - -# WIN_ID="46041" -# WIN_ID="$1" - -# IDX=$(/usr/local/bin/yabai -m query --windows --window "$WIN_ID" \ -# | /usr/local/bin/jq --raw-output --compact-output --monochrome-output '.["stack-index"]') - -# /Users/adamwagner/Programming/dotfiles/scripts/notify "IDX for $WIN_ID IS $IDX" - -# echo -n "$IDX" diff --git a/bin/yabai-get-stacks b/bin/yabai-get-stacks index 7d02bc1..cdba71a 100755 --- a/bin/yabai-get-stacks +++ b/bin/yabai-get-stacks @@ -1,4 +1,4 @@ -#!/usr/local/bin/dash +#!/bin/dash # ↑ dash for fast startup From 413adea6e27c791482426e75912427a1bcae9a80 Mon Sep 17 00:00:00 2001 From: adamwagner Date: Tue, 11 Aug 2020 17:51:08 -0700 Subject: [PATCH 17/33] Add hs._asm.undocumented.spaces dependency --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 1adee5e..fb5bcc9 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,8 @@ Enter stackline, which adds non-obtrusive visual indicators to yabai'e 's stacki 1. https://github.com/koekeishiya/yabai ([install guide](http://https://github.com/koekeishiya/yabai/wiki/Installing-yabai-(latest-release))) 2. https://github.com/Hammerspoon/hammerspoon ([getting started guide](https://www.hammerspoon.org/go/)) -3. https://github.com/stedolan/jq (`brew install jq`) +3. https://github.com/asmagill/hs._asm.undocumented.spaces +4. https://github.com/stedolan/jq (`brew install jq`) You're free to bind yabai commands using your favorite key remapper tool (skhd, karabiner elements, and even hammerspoon are all viable options). From 7f83b527e663f222a7f563b16439a2a666e4a92b Mon Sep 17 00:00:00 2001 From: adamwagner Date: Tue, 11 Aug 2020 17:57:18 -0700 Subject: [PATCH 18/33] Remove unused StackConfig:handleSignal() method (it needs to be available outside of class) --- stackline/config.lua | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/stackline/config.lua b/stackline/config.lua index 917f8eb..f48e23f 100644 --- a/stackline/config.lua +++ b/stackline/config.lua @@ -64,20 +64,6 @@ function StackConfig:toggle(key) return toggledVal end -function StackConfig:handleSingal(msgID, msg) - if msgID == 900 then - return "version:2.0a" - end - - if msgID == 500 then - local key, _value = msg:match(".+:([%a_-]+):([%a%d_-]+)") - if key == "toggle_icons" then - self:toggle('showIcons') - end - end - return "ok" -end - function StackConfig:makePath(key) return self.id .. '-' .. key end From 610226d7eb9a870d9c05a3251b8b324467e0693c Mon Sep 17 00:00:00 2001 From: adamwagner Date: Tue, 11 Aug 2020 18:06:14 -0700 Subject: [PATCH 19/33] Cleanup comments & delete unused bluclass.lua --- stackline/stack.lua | 14 ++++------ stackline/stackline.lua | 1 + stackline/stackmanager.lua | 7 ++--- stackline/window.lua | 15 ++++++----- utils/bluclass.lua | 54 -------------------------------------- 5 files changed, 18 insertions(+), 73 deletions(-) delete mode 100644 utils/bluclass.lua diff --git a/stackline/stack.lua b/stackline/stack.lua index d89173f..40105c4 100644 --- a/stackline/stack.lua +++ b/stackline/stack.lua @@ -2,12 +2,9 @@ local _ = require 'stackline.utils.utils' local u = require 'stackline.utils.underscore' local Class = require 'stackline.utils.self' --- NOTE: using simple 'self' library fixed --- the issue of only 1 of N stacks responding to focus events. --- Experimented with even smaller libs, but only 'self' worked so far. - --- Class example in vanilla lua --- https://github.com/lharck/inheritance-example +-- NOTE: using simple 'self' library fixed the issue of only 1 of N stacks +-- responding to focus events. Experimented with even smaller libs, but only +-- 'self' worked so far. -- ARGS: Class(className, -- parentClass, @@ -93,9 +90,8 @@ local Stack = Class("Stack", nil, { -- Returns true if any non-stack window occludes the stack's frame. -- This can occur when an unstacked window is zoomed to cover a stack. - -- In this situation, we want to *hide* the occluded stack's indicators + -- In this situation, we want to hide or dim the occluded stack's indicators - -- DONE: Convert to Stack instance method (wouldn't need to pass in the 'stack' arg) local stackedHsWins = self:getHs() function notInStack(hsWin) @@ -111,7 +107,7 @@ local Stack = Class("Stack", nil, { return self:isWindowOccludedBy(w) end)) return stackIsOccluded - end -- }}} + end, -- }}} }) return Stack diff --git a/stackline/stackline.lua b/stackline/stackline.lua index e731273..5b24d15 100644 --- a/stackline/stackline.lua +++ b/stackline/stackline.lua @@ -82,6 +82,7 @@ function redrawWinIndicator(hsWin, _app, event) -- {{{ local stackedWin = sm:findWindow(id) if stackedWin then -- when falsey, the focused win is not stacked -- BUG: Unfocused window(s) flicker when an app has 2+ win in a stack {{{ + -- Wouldn't be an issue if Hammerspon #2400 is fixed -- TODO: If there are 2+ windows of the same app in a stack, then the -- *unfocused* window(s) indicator(s) flash 'focused' styles for a split second *before* the -- the actually focused window's indicator :< diff --git a/stackline/stackmanager.lua b/stackline/stackmanager.lua index 1aee392..59347e7 100644 --- a/stackline/stackmanager.lua +++ b/stackline/stackmanager.lua @@ -65,7 +65,7 @@ function Stackmanager:cleanup() -- {{{ end -- }}} function Stackmanager:getSummary(external) -- {{{ - -- Summariaes all stacks on the current space, making it easy to determine + -- Summarizes all stacks on the current space, making it easy to determine -- what needs to be updated (if anything) local stacks = external or self.tabStacks return { @@ -108,8 +108,9 @@ function Stackmanager:findWindow(wid) -- {{{ end -- }}} function Stackmanager:findStackByWindow(win) -- {{{ - -- NOTE: may not need when HS issue #2400 is closed - -- NOTE 2: Unused, since I'm storing reference to "otherAppWindows" directly on each window + -- NOTE: may not need when Hammerspoon #2400 is closed + -- NOTE 2: Currently unused, since reference to "otherAppWindows" is sstored + -- directly on each window. Likely to be useful, tho, so keeping it around. for _stackId, stack in pairs(self.tabStacks) do if stack.id == win.stackId then return stack diff --git a/stackline/window.lua b/stackline/window.lua index 6aa566e..bdc8345 100644 --- a/stackline/window.lua +++ b/stackline/window.lua @@ -41,6 +41,10 @@ function Window:getScreenSide() -- {{{ return side + -- TODO: BUG: Right-side window incorrectly reports as a left-side window with + -- very large padding settings. Will need to consider coordinates from both + -- sides of a window. + -- TODO: find a way to use hs.window.filter.windowsTo{Dir} -- to determine side instead of percLeft/Right ↑ -- https://www.hammerspoon.org/docs/hs.window.filter.html#windowsToWest @@ -133,7 +137,7 @@ function Window:drawIndicator(overrideOpts) -- {{{ local fadeDuration = opts.shouldFade and self.fadeDuration or 0 self.focus = self:isFocused() - -- PROFILE: 0.0123s / 75 (0.0002s) :: isFocused + -- Speed profile: 0.0123s / 75 (0.0002s) :: isFocused if self.indicator then self.indicator:delete() @@ -160,7 +164,8 @@ function Window:drawIndicator(overrideOpts) -- {{{ if showIcons then -- TODO: Figure out how to prevent clipping when adding a subtle shadow - -- to the icon to help distinguish icons with a near-white edge. + -- to the icon to help distinguish icons with a near-white edge.Note + -- that `padding` attribute, which works for rects, does not work for images. self.indicator:insertElement({ type = "image", image = self:iconFromAppName(), @@ -218,10 +223,9 @@ function Window:getShadowAttrs() -- {{{ -- positive Y offset ;-) -- h = (self.focus and 3.0 or 2.0 - (2 + (self.stackIdx * 5))) * -1.0, - -- TODO ↓ align all alpha values to be defined like this return { blurRadius = shadowBlur, - color = {alpha = 1 / shadowAlpha}, + color = {alpha = 1 / shadowAlpha}, -- TODO align all alpha values to be defined like this (1/X) offset = shadowOffset, } end -- }}} @@ -229,9 +233,6 @@ end -- }}} function Window:makeStackId(hsWin) -- {{{ -- stackId is top-left window frame coordinates -- example: "302|35|63|1185|741" - -- OLD definition: - -- generate stackId from spaceId & frame values - -- example (old): "302|35|63|1185|741" local frame = hsWin:frame():floor() local x = frame.x local y = frame.y diff --git a/utils/bluclass.lua b/utils/bluclass.lua deleted file mode 100644 index 3fe7393..0000000 --- a/utils/bluclass.lua +++ /dev/null @@ -1,54 +0,0 @@ -local bluclass = { - _VERSION = 'bluclass 1.1.0', - _DESCRIPTION = 'Lua OOP module with simple inheritance', - _URL = 'https://github.com/superzazu/bluclass.lua', - _LICENSE = [[ -Copyright (c) 2015-2019 Nicolas Allemand - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. -]] -} - -bluclass.class = function(super) - local class = {} - class.super = super - - class.new = function(self, ...) - local instance = {} - instance.class = self - - setmetatable(instance, {__index = function(t, key) - if self[key] then - return self[key] - elseif self.super and self.super[key] then - return self.super[key] - end - end}) - - if instance.init then - instance:init(...) - end - - return instance - end - - return class -end - -return bluclass From 408cac563bf4f41c3fcc7b81a62baa281d48d861 Mon Sep 17 00:00:00 2001 From: adamwagner Date: Wed, 12 Aug 2020 12:20:08 -0700 Subject: [PATCH 20/33] Don't use hs._asm.undocumented.spaces. Uncomment window.filter.notInCurrentSpace which depends on it.' --- stackline/config.lua | 1 - stackline/query.lua | 28 ++++++++++++++-------------- stackline/stackline.lua | 2 +- 3 files changed, 15 insertions(+), 16 deletions(-) diff --git a/stackline/config.lua b/stackline/config.lua index f48e23f..8ed9ad9 100644 --- a/stackline/config.lua +++ b/stackline/config.lua @@ -1,5 +1,4 @@ local _ = require 'stackline.utils.utils' - local handleSignal = function(_, msgID, msg) -- {{{ if msgID == 900 then return "version:2.0a" diff --git a/stackline/query.lua b/stackline/query.lua index 6480cbd..d68fdfb 100644 --- a/stackline/query.lua +++ b/stackline/query.lua @@ -3,10 +3,10 @@ -- riding a bit too fast and found myself in a place where nothing worked, and I -- didn't know why. So, this mess lives another day. Conceptually, it'll be -- pretty easy to put this stuff where it belongs. - - --- luacheck: ignore (spaces isn't used, but augments hs.window module) -local spaces = require("hs._asm.undocumented.spaces") +-- +-- DONE: remove dependency on hs._asm.undocumented.spaces +-- Affects line at ./stackline/stackline.lua:48 using hs.window.filter.windowNotInCurrentSpace +-- local spaces = require 'hs._asm.undocumented.spaces' local _ = require 'stackline.utils.utils' local u = require 'stackline.utils.underscore' @@ -24,6 +24,7 @@ function Query:getWinStackIdxs() -- {{{ end -- }}} function Query:makeStacksFromWindows(ws) -- {{{ + _.p(ws) local windows = _.map(ws, function(w) return Window:new(w) end) @@ -35,7 +36,7 @@ function Query:makeStacksFromWindows(ws) -- {{{ local byStack = _.filter(groupedWins, _.greaterThan(1)) -- stacks have >1 window, so ignore 'groups' of 1 local byApp = _.groupBy(_.reduce(u.values(byStack), _.concat), 'app') -- group stacked windows by app (app name is key) - -- stacks contain more than one window, + -- stacks contain more than one window, -- so ignore groups with only 1 window self.appWindows = byApp self.stacks = byStack @@ -50,6 +51,13 @@ function Query:mergeWinStackIdxs() -- {{{ end -- }}} function shouldRestack(new) -- {{{ + -- Analyze self.stacks to determine if a stack refresh is needed + -- • change space + -- • change num stacks (+/-) + -- • changes to existing stack + -- • change num windows (covers win added / removed) + -- • change position + local curr = sm:getSummary() local new = sm:getSummary(u.values(new)) @@ -70,17 +78,9 @@ function shouldRestack(new) -- {{{ end -- }}} function Query:windowsCurrentSpace() -- {{{ - self:makeStacksFromWindows(wfd:getWindows()) -- set self.stacks - - -- Analyze self.stacks to determine if a stack refresh is needed - -- • change space - -- • change num stacks (+/-) - -- • changes to existing stack - -- • change num windows (covers win added / removed) - -- • change position + self:makeStacksFromWindows(wfd:getWindows()) -- set self.stacks & self.appWindows local shouldRefresh = false -- tmp var mocking ↑ - local extantStacks = sm:get() local extantStackSummary = sm:getSummary() local extantStackExists = extantStackSummary.numStacks > 0 diff --git a/stackline/stackline.lua b/stackline/stackline.lua index 5b24d15..3b22b7c 100644 --- a/stackline/stackline.lua +++ b/stackline/stackline.lua @@ -45,7 +45,7 @@ local windowEvents = { wf.windowDestroyed, wf.windowHidden, wf.windowMinimized, - wf.windowNotInCurrentSpace, + -- wf.windowNotInCurrentSpace, -- depends on hs._asm.undocumented.spaces } -- TO CONFIRM: Compared to calling wsi.update() directly in wf:subscribe From 587ebbfe0f0eb213dded9400f0593684659d6382 Mon Sep 17 00:00:00 2001 From: adamwagner Date: Wed, 12 Aug 2020 17:17:38 -0700 Subject: [PATCH 21/33] Fill gap left behind by hs._asm.undocumented.spaces with hs.spaces.watcher --- stackline/query.lua | 12 ++++++++---- stackline/stackline.lua | 10 ++++++++++ stackline/stackmanager.lua | 6 +++++- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/stackline/query.lua b/stackline/query.lua index d68fdfb..2e7168c 100644 --- a/stackline/query.lua +++ b/stackline/query.lua @@ -24,7 +24,7 @@ function Query:getWinStackIdxs() -- {{{ end -- }}} function Query:makeStacksFromWindows(ws) -- {{{ - _.p(ws) + -- _.p(ws) local windows = _.map(ws, function(w) return Window:new(w) end) @@ -34,7 +34,11 @@ function Query:makeStacksFromWindows(ws) -- {{{ local groupedWins = _.groupBy(windows, 'stackId') local byStack = _.filter(groupedWins, _.greaterThan(1)) -- stacks have >1 window, so ignore 'groups' of 1 - local byApp = _.groupBy(_.reduce(u.values(byStack), _.concat), 'app') -- group stacked windows by app (app name is key) + local byApp + + if _.length(byStack) > 0 then -- if byStack == {}, there are no more stacks on space, so just cleanup + byApp = _.groupBy(_.reduce(u.values(byStack), _.concat), 'app') -- group stacked windows by app (app name is key) + end -- stacks contain more than one window, -- so ignore groups with only 1 window @@ -59,7 +63,7 @@ function shouldRestack(new) -- {{{ -- • change position local curr = sm:getSummary() - local new = sm:getSummary(u.values(new)) + new = sm:getSummary(u.values(new)) if curr.numStacks ~= new.numStacks then print('num stacks changed') @@ -80,7 +84,7 @@ end -- }}} function Query:windowsCurrentSpace() -- {{{ self:makeStacksFromWindows(wfd:getWindows()) -- set self.stacks & self.appWindows - local shouldRefresh = false -- tmp var mocking ↑ + local shouldRefresh local extantStacks = sm:get() local extantStackSummary = sm:getSummary() local extantStackExists = extantStackSummary.numStacks > 0 diff --git a/stackline/stackline.lua b/stackline/stackline.lua index 3b22b7c..53bbaea 100644 --- a/stackline/stackline.lua +++ b/stackline/stackline.lua @@ -62,6 +62,16 @@ wfd:subscribe(windowEvents, function() queryWindowState:start() end) +-- Added 2020-08-12 to fill the gap of hs._asm.undocumented.spaces +-- Stacks refresh on every space/monitor change, wasting resources shelling out to yabai +-- & redrawing all indicators from scratch. +-- TODO: It would better to update our data model to store: +-- screens[] → spaces[] → stacks[] → windows[] +-- … and then only update on *window* change events +hs.spaces.watcher.new(function() + queryWindowState:start() +end):start() + -- ┌───────────────────────────────────────────────┐ -- │ special case: focus events → optimized redraw │ -- └───────────────────────────────────────────────┘ diff --git a/stackline/stackmanager.lua b/stackline/stackmanager.lua index 59347e7..65adf5a 100644 --- a/stackline/stackmanager.lua +++ b/stackline/stackmanager.lua @@ -22,7 +22,11 @@ function Stackmanager:new() -- {{{ end -- }}} function Stackmanager:ingest(stacks, appWindows, shouldClean) -- {{{ - if shouldClean then + + local stacksCount = _.length(stacks) + print('\n\nlength of ingested stacks is', stacksCount, '\n\n') + + if shouldClean or (stacksCount == 0) then self:cleanup() end From c364bc7289dbc27fb2e6ef291a08660bdf025c10 Mon Sep 17 00:00:00 2001 From: adamwagner Date: Wed, 12 Aug 2020 18:56:50 -0700 Subject: [PATCH 22/33] Increase threshold in window:getSide() to fix issue w/ large yabai padding values --- stackline/window.lua | 53 ++++++++++++++++++++++++-------------------- 1 file changed, 29 insertions(+), 24 deletions(-) diff --git a/stackline/window.lua b/stackline/window.lua index bdc8345..660f248 100644 --- a/stackline/window.lua +++ b/stackline/window.lua @@ -32,27 +32,6 @@ function Window:isFocused() -- {{{ return isFocused end -- }}} -function Window:getScreenSide() -- {{{ - local screenWidth = self._win:screen():fullFrame().w - local frame = self.frame - local percRight = 1 - ((screenWidth - (frame.x + frame.w)) / screenWidth) - local percLeft = (screenWidth - frame.x) / screenWidth - local side = (percRight > 0.95 and percLeft < 0.95) and 'right' or 'left' - - return side - - -- TODO: BUG: Right-side window incorrectly reports as a left-side window with - -- very large padding settings. Will need to consider coordinates from both - -- sides of a window. - - -- TODO: find a way to use hs.window.filter.windowsTo{Dir} - -- to determine side instead of percLeft/Right ↑ - -- https://www.hammerspoon.org/docs/hs.window.filter.html#windowsToWest - -- wfd:windowsToWest(self._win) - -- https://www.hammerspoon.org/docs/hs.window.html#windowsToWest - -- self._win:windowsToSouth() -end -- }}} - function Window:setupIndicator() -- {{{ -- Config local showIcons = sm:getShowIconsState() @@ -83,9 +62,9 @@ function Window:setupIndicator() -- {{{ -- Display indicators on -- left edge of windows on the left side of the screen, & -- right edge of windows on the right side of the screen - local side = self:getScreenSide() + self.side = self:getScreenSide() local xval - if side == 'right' then + if self.side == 'right' then xval = (self.frame.x + self.frame.w) + self.offsetX else xval = self.frame.x - (self.width + self.offsetX) @@ -200,6 +179,32 @@ function Window:redrawIndicator(isFocused) -- {{{ end end -- }}} +function Window:getScreenSide() -- {{{ + local thresh = 0.75 + local screenWidth = self._win:screen():fullFrame().w + + local leftEdge = self.frame.x + local rightEdge = self.frame.x + self.frame.w + + local percR = 1 - ((screenWidth - rightEdge) / screenWidth) + local percL = (screenWidth - leftEdge) / screenWidth + + local side = (percR > thresh and percL < thresh) and 'right' or 'left' + + return side + + -- TODO: BUG: Right-side window incorrectly reports as a left-side window with + -- very large padding settings. Will need to consider coordinates from both + -- sides of a window. + + -- TODO: find a way to use hs.window.filter.windowsTo{Dir} + -- to determine side instead of percLeft/Right ↑ + -- https://www.hammerspoon.org/docs/hs.window.filter.html#windowsToWest + -- wfd:windowsToWest(self._win) + -- https://www.hammerspoon.org/docs/hs.window.html#windowsToWest + -- self._win:windowsToSouth() +end -- }}} + function Window:iconFromAppName() -- {{{ appBundle = hs.appfinder.appFromName(self.app):bundleID() return hs.image.imageFromAppBundle(appBundle) @@ -211,7 +216,7 @@ function Window:getShadowAttrs() -- {{{ -- Shadows should cast outwards toward the screen edges as if due to the glow of onscreen windows… -- …or, if you prefer, from a light source originating from the center of the screen. - local shadowXDirection = (self:getScreenSide() == 'left') and -1 or 1 + local shadowXDirection = (self.side == 'left') and -1 or 1 local shadowOffset = { h = (self.focus and 3.0 or 2.0) * -1.0, w = (self.focus and 7.0 or 6.0) * shadowXDirection, From b2ce16aa5f90fb8552b232b721eb3d8705d069e7 Mon Sep 17 00:00:00 2001 From: adamwagner Date: Wed, 12 Aug 2020 18:57:26 -0700 Subject: [PATCH 23/33] Cleanup comments in query.lua --- stackline/query.lua | 49 +++++++++++++++++++++++------------------ stackline/stackline.lua | 2 +- 2 files changed, 28 insertions(+), 23 deletions(-) diff --git a/stackline/query.lua b/stackline/query.lua index 2e7168c..789af06 100644 --- a/stackline/query.lua +++ b/stackline/query.lua @@ -3,64 +3,68 @@ -- riding a bit too fast and found myself in a place where nothing worked, and I -- didn't know why. So, this mess lives another day. Conceptually, it'll be -- pretty easy to put this stuff where it belongs. --- -- DONE: remove dependency on hs._asm.undocumented.spaces -- Affects line at ./stackline/stackline.lua:48 using hs.window.filter.windowNotInCurrentSpace -- local spaces = require 'hs._asm.undocumented.spaces' local _ = require 'stackline.utils.utils' local u = require 'stackline.utils.underscore' --- stackline modules -local Window = require 'stackline.stackline.window' - local scriptPath = hs.configdir .. '/stackline/bin/yabai-get-stack-idx' +-- stackline modules +local Window = require 'stackline.stackline.window' local Query = {} function Query:getWinStackIdxs() -- {{{ + -- call out to yabai to get stack-indexes hs.task.new("/usr/local/bin/dash", function(_code, stdout, _stderr) self.winStackIdxs = hs.json.decode(stdout) end, {scriptPath}):start() end -- }}} function Query:makeStacksFromWindows(ws) -- {{{ - -- _.p(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 byApp local windows = _.map(ws, function(w) return Window:new(w) end) - -- NOTE: 'stackID' groups by full frame, so windows with min-size > stack - -- width will not be stacked properly. See above ↑ - local groupedWins = _.groupBy(windows, 'stackId') - - local byStack = _.filter(groupedWins, _.greaterThan(1)) -- stacks have >1 window, so ignore 'groups' of 1 - local byApp + -- See 'stackId' def @ /window.lua:233 + byStack = _.filter(_.groupBy(windows, 'stackId'), _.greaterThan(1)) -- stacks have >1 window, so ignore 'groups' of 1 - if _.length(byStack) > 0 then -- if byStack == {}, there are no more stacks on space, so just cleanup - byApp = _.groupBy(_.reduce(u.values(byStack), _.concat), 'app') -- group stacked windows by app (app name is key) + if _.length(byStack) > 0 then + -- app names are keys in group + local stackedWins = _.reduce(u.values(byStack), _.concat) + byApp = _.groupBy(stackedWins, 'app') end - -- stacks contain more than one window, - -- so ignore groups with only 1 window self.appWindows = byApp self.stacks = byStack end -- }}} function Query:mergeWinStackIdxs() -- {{{ - hs.fnutils.each(self.stacks, function(stack) - hs.fnutils.each(stack, function(win) - win.stackIdx = self.winStackIdxs[tostring(win.id)] - end) + -- merge windowID <> stack-index mapping queried from yabai into window objs + + function assignStackIndex(win) + win.stackIdx = self.winStackIdxs[tostring(win.id)] + end + + _.each(self.stacks, function(stack) + _.each(stack, assignStackIndex) end) end -- }}} function shouldRestack(new) -- {{{ -- Analyze self.stacks to determine if a stack refresh is needed - -- • change space -- • change num stacks (+/-) -- • changes to existing stack - -- • change num windows (covers win added / removed) -- • change position + -- • change num windows (win added / removed) local curr = sm:getSummary() new = sm:getSummary(u.values(new)) @@ -91,7 +95,8 @@ function Query:windowsCurrentSpace() -- {{{ if extantStackExists then shouldRefresh = shouldRestack(self.stacks, extantStacks) - -- stacksMgr:dimOccluded() TODO: revisit in a future update. This is kind of an edge case — there are bigger fish to fry. + -- stacksMgr:dimOccluded() TODO: revisit in a future update. This is + -- kind of an edge case — there are bigger fish to fry. else shouldRefresh = true end diff --git a/stackline/stackline.lua b/stackline/stackline.lua index 53bbaea..b3b129a 100644 --- a/stackline/stackline.lua +++ b/stackline/stackline.lua @@ -10,7 +10,7 @@ print(hs.settings.bundleID) -- │ config │ -- └────────┘ stackConfig = StackConfig:new():setEach({ - showIcons = false, + showIcons = true, enableTmpFixForHsBug = true, }):registerWatchers() From 0a1f8eb7715904b07468a4b0ade0521e9431d3a5 Mon Sep 17 00:00:00 2001 From: adamwagner Date: Thu, 13 Aug 2020 09:19:52 -0700 Subject: [PATCH 24/33] Removed hs._asm.undocumented.spaces from list of dependencies --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index fb5bcc9..1adee5e 100644 --- a/README.md +++ b/README.md @@ -66,8 +66,7 @@ Enter stackline, which adds non-obtrusive visual indicators to yabai'e 's stacki 1. https://github.com/koekeishiya/yabai ([install guide](http://https://github.com/koekeishiya/yabai/wiki/Installing-yabai-(latest-release))) 2. https://github.com/Hammerspoon/hammerspoon ([getting started guide](https://www.hammerspoon.org/go/)) -3. https://github.com/asmagill/hs._asm.undocumented.spaces -4. https://github.com/stedolan/jq (`brew install jq`) +3. https://github.com/stedolan/jq (`brew install jq`) You're free to bind yabai commands using your favorite key remapper tool (skhd, karabiner elements, and even hammerspoon are all viable options). From a8ab9ed757c43b4d7e4c400431838c1e52fd9bf3 Mon Sep 17 00:00:00 2001 From: adamwagner Date: Fri, 14 Aug 2020 21:04:11 -0700 Subject: [PATCH 25/33] Delete unused utils, consolidate utils into single file. --- {utils => lib}/self.lua | 8 +- {utils => lib}/utils.lua | 155 +++++--- luarocks-reqs.txt | 1 + stackline/config.lua | 5 +- stackline/query.lua | 31 +- stackline/stack.lua | 18 +- stackline/stackline.lua | 12 +- stackline/stackmanager.lua | 13 +- stackline/window.lua | 9 +- utils/flatten.lua | 132 ------- utils/table-utils.lua | 760 ------------------------------------- utils/underscore.lua | 457 ---------------------- 12 files changed, 148 insertions(+), 1453 deletions(-) rename {utils => lib}/self.lua (96%) rename {utils => lib}/utils.lua (89%) create mode 100644 luarocks-reqs.txt delete mode 100644 utils/flatten.lua delete mode 100644 utils/table-utils.lua delete mode 100644 utils/underscore.lua diff --git a/utils/self.lua b/lib/self.lua similarity index 96% rename from utils/self.lua rename to lib/self.lua index 8a8be0c..22cc707 100644 --- a/utils/self.lua +++ b/lib/self.lua @@ -1,4 +1,3 @@ -local unp = unpack or table.unpack local getmt = getmetatable local setmt = setmetatable local Class = {} @@ -10,6 +9,7 @@ local function dump(t, name, indent) local cart local autoref + -- luacheck: ignore local function isemptytable(t) return next(t) == nil end @@ -31,7 +31,7 @@ local function dump(t, name, indent) end end - local function addtocart(value, name, indent, saved, field) + local function addtocart(value, _name, _indent, saved, field) indent = indent or "" saved = saved or {} field = field or name @@ -71,7 +71,7 @@ local function dump(t, name, indent) end local function err(exp, msg, ...) - local msg = msg:format(...) + msg = msg:format(...) if not (exp) then error(msg, 0) else @@ -139,7 +139,7 @@ end function Class:is(cls) err(type(cls) == "table", "Object.is: bad argument, class expected, got %s", type(cls)) - for i, v in ipairs(self.__parent) do + for i, _v in ipairs(self.__parent) do if self.__parent[i] == cls or self.__name == cls.__name then return true end diff --git a/utils/utils.lua b/lib/utils.lua similarity index 89% rename from utils/utils.lua rename to lib/utils.lua index 9cd27d4..cf4e4e8 100644 --- a/utils/utils.lua +++ b/lib/utils.lua @@ -2,9 +2,16 @@ -- https://github.com/luapower/glue/blob/master/glue.lua -- https://github.com/Desvelao/f/blob/master/f/table.lua (new in 2020) -- https://github.com/moriyalb/lamda (based on ramda, updated May 2020, 27 stars) --- ----------------------------------------------------------------------------- +-- u.values( +-- u.values( +-- u.extend( +-- u.include( +-- u.any( +-- u.filter( +-- utils = {} +-- Alias hs.fnutils methods {{{ utils.map = hs.fnutils.map utils.filter = hs.fnutils.filter utils.reduce = hs.fnutils.reduce @@ -15,6 +22,100 @@ utils.some = hs.fnutils.some utils.any = hs.fnutils.some -- also rename 'some()' to 'any()' utils.concat = hs.fnutils.concat utils.copy = hs.fnutils.copy +-- }}} + +-- FROM: https://github.com/rxi/lume/blob/master/lume.lua +function utils.isarray(x) -- {{{ + return type(x) == "table" and x[1] ~= nil +end -- }}} +local getiter = function(x) -- {{{ + if utils.isarray(x) then + return ipairs + elseif type(x) == "table" then + return pairs + end + error("expected table", 3) +end -- }}} +function utils.invert(t) -- {{{ + local rtn = {} + for k, v in pairs(t) do + rtn[v] = k + end + return rtn +end -- }}} +function utils.keys(t) -- {{{ + local rtn = {} + local iter = getiter(t) + for k in iter(t) do + rtn[#rtn + 1] = k + end + return rtn +end -- }}} +function utils.find(t, value) -- {{{ + local iter = getiter(t) + result = nil + for k, v in iter(t) do + print('value looking for') + print(value) + print('key matching against') + print(k) + print('are they equal?') + print(k == value) + if k == value then + result = v + end + end + -- utils.pheader('result') + print(result) + return result +end -- }}} +-- END lume.lua + +-- underscore.lua +function utils.identity(value) -- {{{ + return value +end -- }}} +function utils.iter(list_or_iter) -- {{{ + if type(list_or_iter) == "function" then + return list_or_iter + end + + return coroutine.wrap(function() + for i = 1, #list_or_iter do + coroutine.yield(list_or_iter[i]) + end + end) +end -- }}} +function utils.values(t) -- {{{ + local values = {} + for _k, v in pairs(t) do + values[#values + 1] = v + end + return values +end -- }}} +function utils.extend(destination, source) -- {{{ + for k, v in pairs(source) do + destination[k] = v + end + return destination +end -- }}} +function utils.include(list, value) -- {{{ + for i in Underscore.iter(list) do + if i == value then + return true + end + end + return false +end -- }}} +function utils.any(list, func) -- {{{ + for i in utils.iter(list) do + if func(i) then + return true + end + end + return false +end -- }}} +-- end underscore.lua function utils.keyBind(hyper, keyFuncTable) -- {{{ for key, fn in pairs(keyFuncTable) do @@ -104,53 +205,6 @@ function utils.isEqual(a, b) -- {{{ end -- }}} --- FROM: lume.lua — https://github.com/rxi/lume/blob/master/lume.lua -function utils.isarray(x) -- {{{ - return type(x) == "table" and x[1] ~= nil -end -- }}} -local getiter = function(x) -- {{{ - if utils.isarray(x) then - return ipairs - elseif type(x) == "table" then - return pairs - end - error("expected table", 3) -end -- }}} -function utils.invert(t) -- {{{ - local rtn = {} - for k, v in pairs(t) do - rtn[v] = k - end - return rtn -end -- }}} -function utils.keys(t) -- {{{ - local rtn = {} - local iter = getiter(t) - for k in iter(t) do - rtn[#rtn + 1] = k - end - return rtn -end -- }}} -function utils.find(t, value) -- {{{ - local iter = getiter(t) - result = nil - for k, v in iter(t) do - print('value looking for') - print(value) - print('key matching against') - print(k) - print('are they equal?') - print(k == value) - if k == value then - result = v - end - end - -- utils.pheader('result') - print(result) - return result -end -- }}} --- END lume.lua - local function filter_row(row, key_constraints) -- {{{ -- Check if a row matches the specified key constraints. -- @param row The row to check @@ -283,7 +337,6 @@ function utils.Set(list) -- {{{ return set end -- }}} --- TODO: Confirm that hs.fnutils.partial works just as well function utils.partial(f, ...) -- {{{ -- FROM: https://www.reddit.com/r/lua/comments/fh2go5/a_partialcurry_implementation_of_mine_hope_you/ -- WHEN: 2020-08-08 @@ -301,10 +354,6 @@ function utils.partial(f, ...) -- {{{ end end -- }}} -utils.flattenForce = require 'stackline.utils.flatten' --- table will be squashed down to 1 level deep. Previously nested structures --- will be converted to long, path-like string keys. - function utils.greaterThan(n) -- {{{ return function(t) return #t > n diff --git a/luarocks-reqs.txt b/luarocks-reqs.txt new file mode 100644 index 0000000..29bdac2 --- /dev/null +++ b/luarocks-reqs.txt @@ -0,0 +1 @@ +luarocks install moses \ No newline at end of file diff --git a/stackline/config.lua b/stackline/config.lua index 8ed9ad9..4e4ace5 100644 --- a/stackline/config.lua +++ b/stackline/config.lua @@ -1,4 +1,3 @@ -local _ = require 'stackline.utils.utils' local handleSignal = function(_, msgID, msg) -- {{{ if msgID == 900 then return "version:2.0a" @@ -72,14 +71,14 @@ function StackConfig:registerWatchers() local identifier = self:makePath(key .. '-handler') local settingPath = self:makePath(key) self.store.watchKey(identifier, settingPath, function(_val) - sm:toggleIcons() + Sm:toggleIcons() end) return self end -- One very out-of-place hotkey binding (•_•) hs.hotkey.bind({'alt', 'ctrl'}, 't', function() - sm:toggleIcons() + Sm:toggleIcons() end) -- luacheck: ignore diff --git a/stackline/query.lua b/stackline/query.lua index 789af06..f047846 100644 --- a/stackline/query.lua +++ b/stackline/query.lua @@ -6,8 +6,7 @@ -- DONE: remove dependency on hs._asm.undocumented.spaces -- Affects line at ./stackline/stackline.lua:48 using hs.window.filter.windowNotInCurrentSpace -- local spaces = require 'hs._asm.undocumented.spaces' -local _ = require 'stackline.utils.utils' -local u = require 'stackline.utils.underscore' +local u = require 'stackline.lib.utils' local scriptPath = hs.configdir .. '/stackline/bin/yabai-get-stack-idx' @@ -30,17 +29,17 @@ function Query:makeStacksFromWindows(ws) -- {{{ local byStack local byApp - local windows = _.map(ws, function(w) + local windows = u.map(ws, function(w) return Window:new(w) end) -- See 'stackId' def @ /window.lua:233 - byStack = _.filter(_.groupBy(windows, 'stackId'), _.greaterThan(1)) -- stacks have >1 window, so ignore 'groups' of 1 + byStack = u.filter(u.groupBy(windows, 'stackId'), u.greaterThan(1)) -- stacks have >1 window, so ignore 'groups' of 1 - if _.length(byStack) > 0 then + if u.length(byStack) > 0 then -- app names are keys in group - local stackedWins = _.reduce(u.values(byStack), _.concat) - byApp = _.groupBy(stackedWins, 'app') + local stackedWins = u.reduce(u.values(byStack), u.concat) + byApp = u.groupBy(stackedWins, 'app') end self.appWindows = byApp @@ -54,8 +53,8 @@ function Query:mergeWinStackIdxs() -- {{{ win.stackIdx = self.winStackIdxs[tostring(win.id)] end - _.each(self.stacks, function(stack) - _.each(stack, assignStackIndex) + u.each(self.stacks, function(stack) + u.each(stack, assignStackIndex) end) end -- }}} @@ -66,20 +65,20 @@ function shouldRestack(new) -- {{{ -- • change position -- • change num windows (win added / removed) - local curr = sm:getSummary() - new = sm:getSummary(u.values(new)) + local curr = Sm:getSummary() + new = Sm:getSummary(u.values(new)) if curr.numStacks ~= new.numStacks then print('num stacks changed') return true end - if not _.equal(curr.topLeft, new.topLeft) then + if not u.equal(curr.topLeft, new.topLeft) then print('position changed') return true end - if not _.equal(curr.numWindows, new.numWindows) then + if not u.equal(curr.numWindows, new.numWindows) then print('num windows changed') return true end @@ -89,8 +88,8 @@ function Query:windowsCurrentSpace() -- {{{ self:makeStacksFromWindows(wfd:getWindows()) -- set self.stacks & self.appWindows local shouldRefresh - local extantStacks = sm:get() - local extantStackSummary = sm:getSummary() + local extantStacks = Sm:get() + local extantStackSummary = Sm:getSummary() local extantStackExists = extantStackSummary.numStacks > 0 if extantStackExists then @@ -106,7 +105,7 @@ function Query:windowsCurrentSpace() -- {{{ function whenStackIdxDone() self:mergeWinStackIdxs() -- Add the stack indexes from yabai to the hs window data - sm:ingest(self.stacks, self.appWindows, extantStackExists) -- hand over to the Stack module + Sm:ingest(self.stacks, self.appWindows, extantStackExists) -- hand over to the Stack module end local pollingInterval = 0.1 diff --git a/stackline/stack.lua b/stackline/stack.lua index 40105c4..b8f14f2 100644 --- a/stackline/stack.lua +++ b/stackline/stack.lua @@ -1,7 +1,5 @@ -local _ = require 'stackline.utils.utils' -local u = require 'stackline.utils.underscore' - -local Class = require 'stackline.utils.self' +local u = require 'stackline.lib.utils' +local Class = require 'stackline.lib.self' -- NOTE: using simple 'self' library fixed the issue of only 1 of N stacks -- responding to focus events. Experimented with even smaller libs, but only -- 'self' worked so far. @@ -23,7 +21,7 @@ local Stack = Class("Stack", nil, { end, -- }}} getHs = function(self) -- {{{ - return _.map(self.windows, function(w) + return u.map(self.windows, function(w) return w._win end) end, -- }}} @@ -44,9 +42,9 @@ local Stack = Class("Stack", nil, { getOtherAppWindows = function(self, win) -- {{{ -- NOTE: may not need when HS issue #2400 is closed - return _.filter(self:get(), function(w) - _.pheader('window in getOtherAppWindows') - _.p(w) + return u.filter(self:get(), function(w) + u.pheader('window in getOtherAppWindows') + u.p(w) return w.app == win.app end) end, -- }}} @@ -99,11 +97,11 @@ local Stack = Class("Stack", nil, { end local windowsCurrSpace = wfd:getWindows() - local nonStackWindows = _.filter(windowsCurrSpace, notInStack) + local nonStackWindows = u.filter(windowsCurrSpace, notInStack) -- true if *any* non-stacked windows occlude the stack's frame -- NOTE: u.any() works, hs.fnutils.some does NOT work :~ - local stackIsOccluded = u.any(_.map(nonStackWindows, function(w) + local stackIsOccluded = u.any(u.map(nonStackWindows, function(w) return self:isWindowOccludedBy(w) end)) return stackIsOccluded diff --git a/stackline/stackline.lua b/stackline/stackline.lua index b3b129a..5a3dbf0 100644 --- a/stackline/stackline.lua +++ b/stackline/stackline.lua @@ -2,7 +2,7 @@ require("hs.ipc") local StackConfig = require 'stackline.stackline.config' local Stackmanager = require 'stackline.stackline.stackmanager' -local _ = require 'stackline.utils.utils' +local u = require 'stackline.lib.utils' print(hs.settings.bundleID) @@ -15,7 +15,7 @@ stackConfig = StackConfig:new():setEach({ }):registerWatchers() -- instantiate an instance of the stack manager globally -sm = Stackmanager:new(showIcons) +Sm = Stackmanager:new(showIcons) -- ┌─────────┐ -- │ globals │ @@ -51,7 +51,7 @@ local windowEvents = { -- TO CONFIRM: Compared to calling wsi.update() directly in wf:subscribe -- callback, even a delay of "0" appears to coalesce events as desired. local queryWindowState = hs.timer.delayed.new(0.30, function() - sm:update() + Sm:update() end) -- ┌───────────────────────────────┐ @@ -79,7 +79,7 @@ function unfocusOtherAppWindows(win) -- {{{ -- To workaround HS BUG "windowUnfocused event not fired for same-app windows " -- https://github.com/Hammerspoon/hammerspoon/issues/2400 -- ../notes-query.md:103 - _.each(win.otherAppWindows, function(w) + u.each(win.otherAppWindows, function(w) w:redrawIndicator(false) end) end -- }}} @@ -89,7 +89,7 @@ function redrawWinIndicator(hsWin, _app, event) -- {{{ -- faster than deleting the entire indicator & rebuilding it from scratch, -- particularly since this skips querying the app icon & building the icon image. local id = hsWin:id() - local stackedWin = sm:findWindow(id) + local stackedWin = Sm:findWindow(id) if stackedWin then -- when falsey, the focused win is not stacked -- BUG: Unfocused window(s) flicker when an app has 2+ win in a stack {{{ -- Wouldn't be an issue if Hammerspon #2400 is fixed @@ -113,5 +113,5 @@ wfd:subscribe(wf.windowFocused, redrawWinIndicator) wfd:subscribe({wf.windowNotVisible, wf.windowUnfocused}, redrawWinIndicator) -- always update on load -sm:update() +Sm:update() diff --git a/stackline/stackmanager.lua b/stackline/stackmanager.lua index 65adf5a..51c1e4b 100644 --- a/stackline/stackmanager.lua +++ b/stackline/stackmanager.lua @@ -1,6 +1,5 @@ -- TODO: consolidate these utils! -local _ = require 'stackline.utils.utils' -local u = require 'stackline.utils.underscore' +local u = require 'stackline.lib.utils' -- stackline modules local Query = require 'stackline.stackline.query' @@ -23,7 +22,7 @@ end -- }}} function Stackmanager:ingest(stacks, appWindows, shouldClean) -- {{{ - local stacksCount = _.length(stacks) + local stacksCount = u.length(stacks) print('\n\nlength of ingested stacks is', stacksCount, '\n\n') if shouldClean or (stacksCount == 0) then @@ -31,7 +30,7 @@ function Stackmanager:ingest(stacks, appWindows, shouldClean) -- {{{ end for _stackId, stack in pairs(stacks) do - _.each(stack, function(win) + u.each(stack, function(win) win.otherAppWindows = u.filter(appWindows[win.app], function(w) return w.id ~= win.id end) @@ -74,15 +73,15 @@ function Stackmanager:getSummary(external) -- {{{ local stacks = external or self.tabStacks return { numStacks = #stacks, - topLeft = _.map(stacks, function(s) + topLeft = u.map(stacks, function(s) local windows = external and s or s.windows return windows[1].topLeft end), - dimensions = _.map(stacks, function(s) + dimensions = u.map(stacks, function(s) local windows = external and s or s.windows return windows[1].stackId end), - numWindows = _.map(stacks, function(s) + numWindows = u.map(stacks, function(s) local windows = external and s or s.windows return #windows end), diff --git a/stackline/window.lua b/stackline/window.lua index 660f248..14ac524 100644 --- a/stackline/window.lua +++ b/stackline/window.lua @@ -1,5 +1,4 @@ -local _ = require 'stackline.utils.utils' -local u = require 'stackline.utils.underscore' +local u = require 'stackline.lib.utils' -- ┌───────────────┐ -- │ Window module │ @@ -34,7 +33,7 @@ end -- }}} function Window:setupIndicator() -- {{{ -- Config - local showIcons = sm:getShowIconsState() + local showIcons = Sm:getShowIconsState() -- Padding self.padding = 4 @@ -111,7 +110,7 @@ function Window:drawIndicator(overrideOpts) -- {{{ self.iconAlphaFocused = opts.alphaFocused self.iconAlphaUnfocused = math.min(opts.alphaUnfocused * 2.25, 1) - local showIcons = sm:getShowIconsState() + local showIcons = Sm:getShowIconsState() local radius = showIcons and self.iconRadius or self.indicatorRadius local fadeDuration = opts.shouldFade and self.fadeDuration or 0 @@ -174,7 +173,7 @@ function Window:redrawIndicator(isFocused) -- {{{ rect.fillColor = f and self.colorFocused or self.colorUnfocused rect.shadow = self:getShadowAttrs(f) - if sm:getShowIconsState() then + if Sm:getShowIconsState() then icon.imageAlpha = f and self.iconAlphaFocused or self.iconAlphaUnfocused end end -- }}} diff --git a/utils/flatten.lua b/utils/flatten.lua deleted file mode 100644 index 537f7ec..0000000 --- a/utils/flatten.lua +++ /dev/null @@ -1,132 +0,0 @@ ---[[ - - Copyright (C) 2018 Masatoshi Teruya - - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in - all copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - THE SOFTWARE. - - flatten.lua - lua-table-flatten - Created by Masatoshi Teruya on 18/05/21. - ---]] --- file-scope variables -local type = type -local next = next -local tostring = tostring ---- constants -local INF_POS = math.huge -local INF_NEG = -INF_POS - ---- isFinite --- @param n --- @return ok -local function isFinite(n) - return type(n) == 'number' and (n < INF_POS and n > INF_NEG) -end - ---- encode --- @param key --- @param val --- @return key --- @return val -local function encode(key, val) - -- default do-nothing - return key, val -end - ---- setAsTable --- @param tbl --- @param key --- @param val -local function setAsTable(tbl, key, val) tbl[key] = val end - ---- _flatten --- @param tbl --- @param maxdepth --- @param encoder --- @param depth --- @param prefix --- @param circular --- @param setter --- @param res --- @return res --- @return err -local function _flatten(tbl, maxdepth, encoder, depth, prefix, res, circular, - setter) - local k, v = next(tbl) - - while k do - if type(v) ~= 'table' then - setter(res, encoder(prefix .. k, v)) - else - local ref = tostring(v) - - -- set value except circular referenced value - if not circular[ref] then - if maxdepth > 0 and depth >= maxdepth then - setter(res, prefix .. k, v) - else - circular[ref] = true - _flatten(v, maxdepth, encoder, depth + 1, - prefix .. k .. '.', res, circular, setter) - circular[ref] = nil - end - end - end - - k, v = next(tbl, k) - end - - return res -end - ---- flatten --- @param tbl --- @param maxdepth --- @param encoder --- @param setter --- @return res -local function flatten(tbl, maxdepth, encoder, setter) - -- veirfy arguments - if type(tbl) ~= 'table' then error('tbl must be table') end - - if maxdepth == nil then - maxdepth = 0 - elseif not isFinite(maxdepth) then - error('maxdepth must be finite number') - end - - -- use default encode function - if encoder == nil then - encoder = encode - elseif type(encoder) ~= 'function' then - error('encoder must be function') - end - - -- use default setter - if setter == nil then - setter = setAsTable - elseif type(setter) ~= 'function' then - error('setter must be function') - end - - return _flatten(tbl, maxdepth, encoder, 1, '', {}, {[tostring(tbl)] = true}, - setter) -end - -return flatten diff --git a/utils/table-utils.lua b/utils/table-utils.lua deleted file mode 100644 index 3f21174..0000000 --- a/utils/table-utils.lua +++ /dev/null @@ -1,760 +0,0 @@ --- https://github.com/aryajur/tableUtils/blob/master/src/tableUtils.lua --- Table utils -local tostring = tostring -local type = type -local pairs = pairs -local string = string -local table = table -local load = load -local pcall = pcall -local setmetatable = setmetatable - --- Create the module table here -local M = {} -package.loaded[...] = M -if setfenv and type(setfenv) == "function" then - setfenv(1, M) -- Lua 5.1 -else - _ENV = M -- Lua 5.2+ -end - -_VERSION = "1.20.07.18" - --- Function to convert a table to a string --- Metatables not followed --- Unless key is a number it will be taken and converted to a string -function t2s(t) -- {{{ - -- local levels = 0 - local rL = {cL = 1} -- Table to track recursion into nested tables (cL = current recursion level) - rL[rL.cL] = {} - local result = {} - do - rL[rL.cL]._f, rL[rL.cL]._s, rL[rL.cL]._var = pairs(t) - -- result[#result + 1] = "{\n"..string.rep("\t",levels+1) - result[#result + 1] = "{" -- Non pretty version - rL[rL.cL].t = t - while true do - local k, v = rL[rL.cL]._f(rL[rL.cL]._s, rL[rL.cL]._var) - rL[rL.cL]._var = k - if k == nil and rL.cL == 1 then - break - elseif k == nil then - -- go up in recursion level - -- If condition for pretty printing - -- if result[#result]:sub(-1,-1) == "," then - -- result[#result] = result[#result]:sub(1,-3) -- remove the tab and the comma - -- else - -- result[#result] = result[#result]:sub(1,-2) -- just remove the tab - -- end - result[#result + 1] = "}," -- non pretty version - -- levels = levels - 1 - rL.cL = rL.cL - 1 - rL[rL.cL + 1] = nil - -- rL[rL.cL].str = rL[rL.cL].str..",\n"..string.rep("\t",levels+1) - else - -- Handle the key and value here - if type(k) == "number" or type(k) == "boolean" then - result[#result + 1] = "[" .. tostring(k) .. "]=" - elseif type(k) == "table" then - result[#result + 1] = "[" .. t2s(k) .. "]=" - else - local kp = tostring(k) - if kp:match([["]]) then - result[#result + 1] = - "[" .. [[']] .. kp .. [[']] .. "]=" - else - result[#result + 1] = - "[" .. [["]] .. kp .. [["]] .. "]=" - end - end - if type(v) == "table" then - -- Check if this is not a recursive table - local goDown = true - for i = 1, rL.cL do - if v == rL[i].t then - -- This is recursive do not go down - goDown = false - break - end - end - if goDown then - -- Go deeper in recursion - -- levels = levels + 1 - rL.cL = rL.cL + 1 - rL[rL.cL] = {} - rL[rL.cL]._f, rL[rL.cL]._s, rL[rL.cL]._var = pairs(v) - -- result[#result + 1] = "{\n"..string.rep("\t",levels+1) - result[#result + 1] = "{" -- non pretty version - rL[rL.cL].t = v - else - -- result[#result + 1] = "\""..tostring(v).."\",\n"..string.rep("\t",levels+1) - result[#result + 1] = "\"" .. tostring(v) .. "\"," -- non pretty version - end - elseif type(v) == "number" or type(v) == "boolean" then - -- result[#result + 1] = tostring(v)..",\n"..string.rep("\t",levels+1) - result[#result + 1] = tostring(v) .. "," -- non pretty version - else - -- result[#result + 1] = string.format("%q",tostring(v))..",\n"..string.rep("\t",levels+1) - result[#result + 1] = - string.format("%q", tostring(v)) .. "," -- non pretty version - end -- if type(v) == "table" then ends - end -- if not rL[rL.cL]._var and rL.cL == 1 then ends - end -- while true ends here - end -- do ends - -- If condition for pretty printing - -- if result[#result]:sub(-1,-1) == "," then - -- result[#result] = result[#result]:sub(1,-3) -- remove the tab and the comma - -- else - -- result[#result] = result[#result]:sub(1,-2) -- just remove the tab - -- end - result[#result + 1] = "}" -- non pretty version - return table.concat(result) -end -- }}} - --- Function to convert a table to a string with indentation for pretty printing --- Metatables not followed --- Unless key is a number it will be taken and converted to a string -function t2spp(t) -- {{{ - local levels = 0 - local rL = {cL = 1} -- Table to track recursion into nested tables (cL = current recursion level) - rL[rL.cL] = {} - local result = {} - do - rL[rL.cL]._f, rL[rL.cL]._s, rL[rL.cL]._var = pairs(t) - result[#result + 1] = "{\n" .. string.rep("\t", levels + 1) - -- result[#result + 1] = "{" -- Non pretty version - rL[rL.cL].t = t - while true do - local k, v = rL[rL.cL]._f(rL[rL.cL]._s, rL[rL.cL]._var) - rL[rL.cL]._var = k - if k == nil and rL.cL == 1 then - break - elseif k == nil then - -- go up in recursion level - -- If condition for pretty printing - if result[#result]:sub(-1, -1) == "," then - result[#result] = result[#result]:sub(1, -3) -- remove the tab and the comma - else - result[#result] = result[#result]:sub(1, -2) -- just remove the tab - end - -- result[#result + 1] = "}," -- non pretty version - levels = levels - 1 - rL.cL = rL.cL - 1 - rL[rL.cL + 1] = nil - result[#result + 1] = "},\n" .. string.rep("\t", levels + 1) -- for pretty printing - else - -- Handle the key and value here - if type(k) == "number" or type(k) == "boolean" then - result[#result + 1] = "[" .. tostring(k) .. "]=" - elseif type(k) == "table" then - result[#result + 1] = "[" .. t2spp(k) .. "]=" - else - local kp = tostring(k) - if kp:match([["]]) then - result[#result + 1] = - "[" .. [[']] .. kp .. [[']] .. "]=" - else - result[#result + 1] = - "[" .. [["]] .. kp .. [["]] .. "]=" - end - end - if type(v) == "table" then - -- Check if this is not a recursive table - local goDown = true - for i = 1, rL.cL do - if v == rL[i].t then - -- This is recursive do not go down - goDown = false - break - end - end - if goDown then - -- Go deeper in recursion - levels = levels + 1 - rL.cL = rL.cL + 1 - rL[rL.cL] = {} - rL[rL.cL]._f, rL[rL.cL]._s, rL[rL.cL]._var = pairs(v) - result[#result + 1] = - "{\n" .. string.rep("\t", levels + 1) -- For pretty printing - -- result[#result + 1] = "{" -- non pretty version - rL[rL.cL].t = v - else - result[#result + 1] = - "\"" .. tostring(v) .. "\",\n" .. - string.rep("\t", levels + 1) -- For pretty printing - -- result[#result + 1] = "\""..tostring(v).."\"," -- non pretty version - end - elseif type(v) == "number" or type(v) == "boolean" then - result[#result + 1] = - tostring(v) .. ",\n" .. string.rep("\t", levels + 1) -- For pretty printing - -- result[#result + 1] = tostring(v).."," -- non pretty version - else - result[#result + 1] = - string.format("%q", tostring(v)) .. ",\n" .. - string.rep("\t", levels + 1) -- For pretty printing - -- result[#result + 1] = string.format("%q",tostring(v)).."," -- non pretty version - end -- if type(v) == "table" then ends - end -- if not rL[rL.cL]._var and rL.cL == 1 then ends - end -- while true ends here - end -- do ends - -- If condition for pretty printing - if result[#result]:sub(-1, -1) == "," then - result[#result] = result[#result]:sub(1, -3) -- remove the tab and the comma - else - result[#result] = result[#result]:sub(1, -2) -- just remove the tab - end - result[#result + 1] = "}" - return table.concat(result) -end -- }}} - --- Function to convert a table to string following the recursive tables also --- Metatables are not followed --- Lua has 8 basic types: --- 1. nil --- 2. boolean --- 3. number --- 4. string --- 5. function --- 6. userdata --- 7. thread --- 8. table --- The table to string and string to table conversion will maintain the following types: --- nil, boolean, number, string, table --- The other three types (function, userdata and thread) get their tostring --- values stored and end up as a string ID. -function t2sr(t) -- {{{ - if type(t) ~= 'table' then - return nil, 'Expected table parameter' - end - local rL = {cL = 1} -- Table to track recursion into nested tables (cL = current recursion level) - rL[rL.cL] = {} - local tabIndex = {} -- Table to store a list of tables indexed into a string and their variable name - local latestTab = 0 - local result = {} - do - rL[rL.cL]._f, rL[rL.cL]._s, rL[rL.cL]._var = pairs(t) -- Start the key value traveral for the table and store the iterator returns - result[#result + 1] = 't0={}' -- t0 would be the main table - -- rL[rL.cL].str = 't0={}' - rL[rL.cL].t = t -- Table to stringify at this level - rL[rL.cL].tabIndex = 0 - tabIndex[t] = rL[rL.cL].tabIndex - while true do - local key - local k, v = rL[rL.cL]._f(rL[rL.cL]._s, rL[rL.cL]._var) -- Get the 1st key and value from the iterator in k,v - rL[rL.cL]._var = k - if k == nil and rL.cL == 1 then - break -- All done! - elseif k == nil then - -- go up in recursion level - -- rL[rL.cL-1].str = rL[rL.cL-1].str..'\\n'..rL[rL.cL].str - rL.cL = rL.cL - 1 - if rL[rL.cL].vNotDone then - -- We were converting a key to string since that was a table. Now do the same for the value at this level - key = 't' .. rL[rL.cL].tabIndex .. '[t' .. - tostring(rL[rL.cL + 1].tabIndex) .. ']' - -- rL[rL.cL].str = rL[rL.cL].str..'\\n'..key..'=' - result[#result + 1] = "\n" .. key .. "=" - v = rL[rL.cL].vNotDone - end - rL[rL.cL + 1] = nil - else - -- Handle the key and value here - if type(k) == 'number' or type(k) == 'boolean' then - key = 't' .. rL[rL.cL].tabIndex .. '[' .. tostring(k) .. ']' - -- rL[rL.cL].str = rL[rL.cL].str..'\\n'..key..'=' - result[#result + 1] = "\n" .. key .. "=" - elseif type(k) == 'string' then - key = 't' .. rL[rL.cL].tabIndex .. '.' .. tostring(k) - -- rL[rL.cL].str = rL[rL.cL].str..'\\n'..key..'=' - result[#result + 1] = "\n" .. key .. "=" - elseif type(k) == 'table' then - -- Table key - -- Check if the table already exists - if tabIndex[k] then - key = - 't' .. rL[rL.cL].tabIndex .. '[t' .. tabIndex[k] .. - ']' - -- rL[rL.cL].str = rL[rL.cL].str..'\\n'..key..'=' - result[#result + 1] = "\n" .. key .. "=" - else - -- Go deeper to stringify this table - latestTab = latestTab + 1 - -- rL[rL.cL].str = rL[rL.cL].str..'\\nt'..tostring(latestTab)..'={}' - result[#result + 1] = - "\nt" .. tostring(latestTab) .. "={}" - rL[rL.cL].vNotDone = v - rL.cL = rL.cL + 1 - rL[rL.cL] = {} - rL[rL.cL]._f, rL[rL.cL]._s, rL[rL.cL]._var = pairs(k) - rL[rL.cL].tabIndex = latestTab - rL[rL.cL].t = k - -- rL[rL.cL].str = '' - tabIndex[k] = rL[rL.cL].tabIndex - end -- if tabIndex[k] then ends - else - -- k is of the type function, userdata or thread - key = 't' .. rL[rL.cL].tabIndex .. '.' .. tostring(k) - -- rL[rL.cL].str = rL[rL.cL].str..'\\n'..key..'=' - result[#result + 1] = "\n" .. key .. "=" - end -- if type(k)ends - end -- if not k and rL.cL == 1 then ends - if key then - rL[rL.cL].vNotDone = nil - if type(v) == 'table' then - -- Check if this table is already indexed - if tabIndex[v] then - -- rL[rL.cL].str = rL[rL.cL].str..'t'..tabIndex[v] - result[#result + 1] = 't' .. tabIndex[v] - else - -- Go deeper in recursion - latestTab = latestTab + 1 - -- rL[rL.cL].str = rL[rL.cL].str..'{}' - -- rL[rL.cL].str = rL[rL.cL].str..'\\nt'..tostring(latestTab)..'='..key - result[#result + 1] = - "{}\nt" .. tostring(latestTab) .. '=' .. key -- New table - rL.cL = rL.cL + 1 - rL[rL.cL] = {} - rL[rL.cL]._f, rL[rL.cL]._s, rL[rL.cL]._var = pairs(v) - rL[rL.cL].tabIndex = latestTab - rL[rL.cL].t = v - -- rL[rL.cL].str = '' - tabIndex[v] = rL[rL.cL].tabIndex - end - elseif type(v) == 'number' then - -- rL[rL.cL].str = rL[rL.cL].str..tostring(v) - result[#result + 1] = tostring(v) - elseif type(v) == 'boolean' then - -- rL[rL.cL].str = rL[rL.cL].str..tostring(v) - result[#result + 1] = tostring(v) - else - -- rL[rL.cL].str = rL[rL.cL].str..string.format('%q',tostring(v)) - result[#result + 1] = string.format('%q', tostring(v)) - end -- if type(v) == "table" then ends - end -- if key then ends - end -- while true ends here - end -- do ends - -- return rL[rL.cL].str - return table.concat(result) -end -- }}} - --- Function to convert a string containing a lua table to a lua table object -function s2t(str) -- {{{ - local fileFunc - local safeenv = {} - if loadstring and setfenv then - fileFunc = loadstring("t=" .. str) - setfenv(f, safeenv) - else - fileFunc = load("t=" .. str, "stringToTable", "t", safeenv) - end - local err, msg = pcall(fileFunc) - if not err or not safeenv.t or type(safeenv.t) ~= "table" then - return nil, msg or type(safeenv.t) ~= "table" and "Not a table" - end - return safeenv.t -end -- }}} - --- Function to convert a string containing a lua recursive table (from t2sr) to --- a lua table object -function s2tr(str) -- {{{ - local fileFunc - local safeenv = {} - if loadstring and setfenv then - fileFunc = loadstring(str) - setfenv(f, safeenv) - else - fileFunc = load(str, "stringToTable", "t", safeenv) - end - local err, msg = pcall(fileFunc) - if not err or not safeenv.t0 or type(safeenv.t0) ~= "table" then - return nil, msg or type(safeenv.t0) ~= "table" and "Not a table" - end - return safeenv.t0 -end -- }}} - --- Merge arrays t1 to t2 --- if duplicates flag is false then duplicates are skipped --- if isduplicate is a given function then that is used to check whether the --- value of t1 and value of t2 are duplicate using a call like this: --- isduplicate(t1[i],t2[j]) --- returns table t2 -function mergeArrays(t1, t2, duplicates, isduplicate) -- {{{ - isduplicate = (type(isduplicate) == "function" and isduplicate) or - function(v1, v2) - return v1 == v2 - end - for i = 1, #t1 do - local add = true - if not duplicates then - -- Check if this is a duplicate - for j = 1, #t2 do - if isduplicate(t1[i], t2[j]) then - add = false - break - end - end - end - if add then - table.insert(t2, t1[i]) - end - end - return t2 -end -- }}} - --- Function to check whether value v is in array t1 --- if equal is a given function then equal is called with a value from the table --- and the value to compare. If it returns true then the values are considered --- equal -function inArray(t1, v, equal) -- {{{ - equal = (type(equal) == "function" and equal) or function(v1, v2) - return v1 == v2 - end - for i = 1, #t1 do - if equal(t1[i], v) then - return i -- Value v found in t1 at ith location - end - end - return false -- Value v not in t1 -end -- }}} - -function emptyTable(t) -- {{{ - for k, v in pairs(t) do - t[k] = nil - end - return true -end -- }}} - -function emptyArray(t) -- {{{ - for i = 1, #t do - t[i] = nil - end - return true -end -- }}} - -local WEAKK = {__mode = "k"} -local WEAKV = {__mode = "v"} - --- Copy table t1 to t2 overwriting any common keys --- If full is true then copy is recursively going down into nested tables --- returns t2 and mapping of source to destination and destination to source tables -function copyTable(t1, t2, full, map, tabDone) -- {{{ - map = map or {s2d = setmetatable({}, WEAKK), d2s = setmetatable({}, WEAKV)} - map.s2d[t1] = t2 -- s2d contains mapping of source table tables to destination tables - map.d2s[t2] = t1 -- d2s contains mapping of destination table tables to source tables - tabDone = tabDone or {[t1] = t2} -- To keep track of recursive tables - for k, v in pairs(t1) do - if type(v) == "number" or type(v) == "string" or type(v) == "boolean" or - type(v) == "function" or type(v) == "thread" or type(v) == - "userdata" then - if type(k) == "table" then - if full then - local kp - if not tabDone[k] then - kp = {} - tabDone[k] = kp - copyTable(k, kp, true, map, tabDone) - map.d2s[kp] = k - map.s2d[k] = kp - else - kp = tabDone[k] - end - t2[kp] = v - else - t2[k] = v - end - else - t2[k] = v - end - else - -- type(v) = ="table" - if full then - if type(k) == "table" then - local kp - if not tabDone[k] then - kp = {} - tabDone[k] = kp - copyTable(k, kp, true, map, tabDone) - map.d2s[kp] = k - map.s2d[k] = kp - else - kp = tabDone[k] - end - t2[kp] = {} - if not tabDone[v] then - tabDone[v] = t2[kp] - copyTable(v, t2[kp], true, map, tabDone) - map.d2s[t2[kp]] = v - map.s2d[v] = t2[kp] - else - t2[kp] = tabDone[v] - end - else - t2[k] = {} - if not tabDone[v] then - tabDone[v] = t2[k] - copyTable(v, t2[k], true, map, tabDone) - map.d2s[t2[k]] = v - map.s2d[v] = t2[k] - else - t2[k] = tabDone[v] - end - end - else - t2[k] = v - end - end - end - return t2, map -end -- }}} - --- Function to compare 2 tables. Returns nil if they are not equal in value or --- do not have the same recursive link structure --- Recursive tables are allowed -function compareTables(t1, t2, traversing) -- {{{ - if not t2 then - return false - end - traversing = traversing or {} - traversing[t1] = t2 -- t1 is being traversed to match it to t2 - local donet2 = {} -- To mark which keys are taken - for k, v in pairs(t1) do - -- print(k,v) - if type(v) == "number" or type(v) == "string" or type(v) == "boolean" or - type(v) == "function" or type(v) == "thread" or type(v) == - "userdata" then - if type(k) == "table" then - -- Find a matching key - local found - for k2, v2 in pairs(t2) do - if not donet2[k2] and type(k2) == "table" then - -- Check if k2 is already traversed or is being traversed - local traversal - for k3, v3 in pairs(traversing) do - if v3 == k2 then - traversal = k3 - break - end - end - if not traversal then - if compareTables(k, k2, traversing) and v2 == v then - found = k2 - break - end - elseif traversal == k and v2 == v then - found = k2 - break - end - end - end - if not found then - return false - end - donet2[found] = true - else - if v ~= t2[k] then - return false - end - donet2[k] = true - end - else - -- type(v) = ="table" - -- print("-------->Going In "..tostring(v)) - if type(k) == "table" then - -- Find a matching key - local found - for k2, v2 in pairs(t2) do - if not donet2[k2] and type(k2) == "table" then - -- Check if k2 is already traversed or is being traversed - local traversal - for k3, v3 in pairs(traversing) do - if v3 == k2 then - traversal = k3 - break - end - end - if not traversal then - if compareTables(k, k2, traversing) and v2 == v then - found = k2 - break - end - elseif traversal == k and v2 == v then - found = k2 - break - end - end - end - if not found then - return false - end - donet2[found] = true - else - -- k is not a table - if not traversing[v] then - if not compareTables(v, t2[k], traversing) then - return false - end - else - -- This is a recursive table so it should match - if traversing[v] ~= t2[k] then - return false - end - end - donet2[k] = true - end - end - end - -- Check if any keys left in t2 - for k, v in pairs(t2) do - if not donet2[k] then - return false -- extra stuff in t2 - end - end - traversing[t1] = nil - return true -end -- }}} - -local setnil = {} -- Marker table for diff to set nil - --- Function to patch table t with the diff provided to convert it to the next table --- diff is a structure as returned by the diffTable function -function patch(t, diff) -- {{{ - local tabDone = {[t] = true} - for k, v in pairs(diff[t]) do - if v == setnil then - t[k] = nil - else - t[k] = v - end - end - -- Any other table keys in diff are the child tables in t so go through them and patch them - for k, v in pairs(diff) do - if k ~= t and type(k) == "table" and not tabDone[k] then - for k1, v1 in pairs(v) do - if v1 == setnil then - k[k1] = nil - else - k[k1] = v1 - end - end - end - end - return t -end -- }}} - --- Function to return the diff patch of t2-t1. The patch when applied to t1 will --- make it equal in value to t2 such that compareTables will return true --- Use the patch function the apply the patch - --- map is the table that can provide mapping of any table in t2 to a table in t1 --- i.e. they can be considered the referring to the same table i.e. that table --- in t2 after the patch operation would be the same in value as the table in t1 --- that the map defines but its address will still be the address it was in t2. --- If there is no mapping for the table found then the same table is looked up --- at that level to match. But if there is a same table then the diff for that --- table is obviously 0 - --- NOTE: a diff object is temporary and cannot be saved for a later session(This --- is because of setnil being unique to a session). To save it is better to --- serialize and save t1 and t2 using t2s functions -function diffTable(t1, t2, map, tabDone, diff) -- {{{ - map = map or {[t2] = t1} - tabDone = tabDone or {[t2] = true} -- To keep track of recursive tables - diff = diff or {} - local diffDirty - diff[t1] = diff[t1] or {} - local keyTabs = {} - -- To convert t1 to t2 let us iterate over all elements of t2 first - for k, v in pairs(t2) do - -- There are 8 types in Lua (except nil and table we check everything here - if type(v) ~= "table" then -- - if type(k) == "table" then - -- Check if there is a mapping else the mapping in t1 is k - local kt1 = k - if map[k] then - kt1 = map[k] - -- Get diff of kt1 and k - if not tabDone[k] then - tabDone[k] = true - diffTable(kt1, k, map, tabDone, diff) - diffDirty = diffDirty or diff[kt1] - end - end - keyTabs[kt1] = k - if t1[kt1] == nil or t1[kt1] ~= v then - diff[t1][kt1] = v - diffDirty = true - end - else -- if type(k) == "table" then else - -- Neither v is a table not k is a table - if t1[k] ~= v then - diff[t1][k] = v - diffDirty = true - end - end -- if type(k) == "table" then ends - else -- if type(v) ~= "table" then - -- v == "table" - if type(k) == "table" then - -- Both v and k are tables - local kt1 = k - if map[k] then - kt1 = map[k] - if not tabDone[k] then - tabDone[k] = true - diffTable(kt1, k, map, tabDone, diff) - diffDirty = diffDirty or diff[kt1] - end - end - keyTabs[kt1] = k - local vt1 = v - if map[v] then - vt1 = map[v] - if not tabDone[v] then - tabDone[v] = true - diffTable(vt1, v, map, tabDone, diff) - diffDirty = diffDirty or diff[vt1] - end - end - if t1[kt1] == nil or t1[kt1] ~= vt1 then - diff[t1][kt1] = vt1 - diffDirty = true - end - else - local vt1 = v - if map[v] then - vt1 = map[v] - -- Get the diff of vt1 and v - if not tabDone[v] then - tabDone[v] = true - diffTable(vt1, v, map, tabDone, diff) - diffDirty = diffDirty or diff[vt1] - end - end - if t1[k] == nil or t1[k] ~= vt1 then - diff[t1][k] = vt1 - diffDirty = true - end - end - end -- if type(v) ~= "table" then ends - end -- for k,v in pairs(t2) do ends - -- Now to find extra stuff in t1 which should be removed - for k, v in pairs(t1) do - if type(k) ~= "table" then - if t2[k] == nil then - diff[t1][k] = setnil - diffDirty = true - end - else - -- k is a table - -- get the t2 counterpart if it was found - if not keyTabs[k] then - diff[t1][k] = setnil - diffDirty = true - end - end - end - if not diffDirty then - diff[t1] = nil - end - return diffDirty and diff -end -- }}} - diff --git a/utils/underscore.lua b/utils/underscore.lua deleted file mode 100644 index a9522ca..0000000 --- a/utils/underscore.lua +++ /dev/null @@ -1,457 +0,0 @@ --- Copyright (c) 2009 Marcus Irven --- --- Permission is hereby granted, free of charge, to any person --- obtaining a copy of this software and associated documentation --- files (the "Software"), to deal in the Software without --- restriction, including without limitation the rights to use, --- copy, modify, merge, publish, distribute, sublicense, and/or sell --- copies of the Software, and to permit persons to whom the --- Software is furnished to do so, subject to the following --- conditions: --- --- The above copyright notice and this permission notice shall be --- included in all copies or substantial portions of the Software. --- --- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, --- EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES --- OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND --- NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT --- HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, --- WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING --- FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR --- OTHER DEALINGS IN THE SOFTWARE. ---- Underscore is a set of utility functions for dealing with --- iterators, arrays, tables, and functions. -local Underscore = {funcs = {}} -Underscore.__index = Underscore - -function Underscore.__call(_, value) - return Underscore:new(value) -end - -function Underscore:new(value, chained) - return setmetatable({_val = value, chained = chained or false}, self) -end - -function Underscore.iter(list_or_iter) - if type(list_or_iter) == "function" then - return list_or_iter - end - - return coroutine.wrap(function() - for i = 1, #list_or_iter do - coroutine.yield(list_or_iter[i]) - end - end) -end - -function Underscore.range(start_i, end_i, step) - if end_i == nil then - end_i = start_i - start_i = 1 - end - step = step or 1 - local range_iter = coroutine.wrap(function() - for i = start_i, end_i, step do - coroutine.yield(i) - end - end) - return Underscore:new(range_iter) -end - ---- Identity function. This function looks useless, but is used throughout Underscore as a default. --- @name _.identity --- @param value any object --- @return value --- @usage _.identity("foo") --- => "foo" -function Underscore.identity(value) - return value -end - --- chaining - -function Underscore:chain() - self.chained = true - return self -end - -function Underscore:value() - return self._val -end - --- iter - -function Underscore.funcs.each(list, func) - for i in Underscore.iter(list) do - func(i) - end - return list -end - -function Underscore.funcs.map(list, func) - local mapped = {} - for i in Underscore.iter(list) do - mapped[#mapped + 1] = func(i) - end - return mapped -end - -function Underscore.funcs.reduce(list, memo, func) - for i in Underscore.iter(list) do - memo = func(memo, i) - end - return memo -end - -function Underscore.funcs.detect(list, func) - for i in Underscore.iter(list) do - if func(i) then - return i - end - end - return nil -end - -function Underscore.funcs.select(list, func) - local selected = {} - for i in Underscore.iter(list) do - if func(i) then - selected[#selected + 1] = i - end - end - return selected -end - -function Underscore.funcs.reject(list, func) - local selected = {} - for i in Underscore.iter(list) do - if not func(i) then - selected[#selected + 1] = i - end - end - return selected -end - -function Underscore.funcs.all(list, func) - func = func or Underscore.identity - - -- TODO what should happen with an empty list? - for i in Underscore.iter(list) do - if not func(i) then - return false - end - end - return true -end - -function Underscore.funcs.any(list, func) - func = func or Underscore.identity - - -- TODO what should happen with an empty list? - for i in Underscore.iter(list) do - if func(i) then - return true - end - end - return false -end - -function Underscore.funcs.include(list, value) - for i in Underscore.iter(list) do - if i == value then - return true - end - end - return false -end - -function Underscore.funcs.invoke(list, function_name, ...) - local args = {...} - Underscore.funcs.each(list, function(i) - i[function_name](i, unpack(args)) - end) - return list -end - -function Underscore.funcs.pluck(list, propertyName) - return Underscore.funcs.map(list, function(i) - return i[propertyName] - end) -end - -function Underscore.funcs.min(list, func) - func = func or Underscore.identity - - return Underscore.funcs.reduce(list, {item = nil, value = nil}, - function(min, item) - if min.item == nil then - min.item = item - min.value = func(item) - else - local value = func(item) - if value < min.value then - min.item = item - min.value = value - end - end - return min - end).item -end - -function Underscore.funcs.max(list, func) - func = func or Underscore.identity - - return Underscore.funcs.reduce(list, {item = nil, value = nil}, - function(max, item) - if max.item == nil then - max.item = item - max.value = func(item) - else - local value = func(item) - if value > max.value then - max.item = item - max.value = value - end - end - return max - end).item -end - -function Underscore.funcs.to_array(list) - local array = {} - for i in Underscore.iter(list) do - array[#array + 1] = i - end - return array -end - -function Underscore.funcs.reverse(list) - local reversed = {} - for i in Underscore.iter(list) do - table.insert(reversed, 1, i) - end - return reversed -end - -function Underscore.funcs.sort(iter, comparison_func) - local array = iter - if type(iter) == "function" then - array = Underscore.funcs.to_array(iter) - end - table.sort(array, comparison_func) - return array -end - --- arrays - -function Underscore.funcs.first(array, n) - if n == nil then - return array[1] - else - local first = {} - n = math.min(n, #array) - for i = 1, n do - first[i] = array[i] - end - return first - end -end - -function Underscore.funcs.rest(array, index) - index = index or 2 - local rest = {} - for i = index, #array do - rest[#rest + 1] = array[i] - end - return rest -end - -function Underscore.funcs.slice(array, start_index, length) - local sliced_array = {} - - start_index = math.max(start_index, 1) - local end_index = math.min(start_index + length - 1, #array) - for i = start_index, end_index do - sliced_array[#sliced_array + 1] = array[i] - end - return sliced_array -end - -function Underscore.funcs.flatten(array) - local all = {} - - for ele in Underscore.iter(array) do - if type(ele) == "table" then - local flattened_element = Underscore.funcs.flatten(ele) - Underscore.funcs.each(flattened_element, function(e) - all[#all + 1] = e - end) - else - all[#all + 1] = ele - end - end - return all -end - -function Underscore.funcs.push(array, item) - table.insert(array, item) - return array -end - -function Underscore.funcs.pop(array) - return table.remove(array) -end - -function Underscore.funcs.shift(array) - return table.remove(array, 1) -end - -function Underscore.funcs.unshift(array, item) - table.insert(array, 1, item) - return array -end - -function Underscore.funcs.join(array, separator) - return table.concat(array, separator) -end - --- objects - -function Underscore.funcs.keys(obj) - local keys = {} - for k, v in pairs(obj) do - keys[#keys + 1] = k - end - return keys -end - -function Underscore.funcs.values(obj) - local values = {} - for k, v in pairs(obj) do - values[#values + 1] = v - end - return values -end - -function Underscore.funcs.extend(destination, source) - for k, v in pairs(source) do - destination[k] = v - end - return destination -end - -function Underscore.funcs.is_empty(obj) - return next(obj) == nil -end - --- Originally based on penlight's deepcompare() -- http://luaforge.net/projects/penlight/ -function Underscore.funcs.is_equal(o1, o2, ignore_mt) - local ty1 = type(o1) - local ty2 = type(o2) - if ty1 ~= ty2 then - return false - end - - -- non-table types can be directly compared - if ty1 ~= 'table' then - return o1 == o2 - end - - -- as well as tables which have the metamethod __eq - local mt = getmetatable(o1) - if not ignore_mt and mt and mt.__eq then - return o1 == o2 - end - - local is_equal = Underscore.funcs.is_equal - - for k1, v1 in pairs(o1) do - local v2 = o2[k1] - if v2 == nil or not is_equal(v1, v2, ignore_mt) then - return false - end - end - for k2, v2 in pairs(o2) do - local v1 = o1[k2] - if v1 == nil then - return false - end - end - return true -end - --- functions - -function Underscore.funcs.compose(...) - local function call_funcs(funcs, ...) - if #funcs > 1 then - return funcs[1](call_funcs(_.rest(funcs), ...)) - else - return funcs[1](...) - end - end - - local funcs = {...} - return function(...) - return call_funcs(funcs, ...) - end -end - -function Underscore.funcs.wrap(func, wrapper) - return function(...) - return wrapper(func, ...) - end -end - -function Underscore.funcs.curry(func, argument) - return function(...) - return func(argument, ...) - end -end - -function Underscore.functions() - return Underscore.keys(Underscore.funcs) -end - --- add aliases -Underscore.methods = Underscore.functions - -Underscore.funcs.for_each = Underscore.funcs.each -Underscore.funcs.collect = Underscore.funcs.map -Underscore.funcs.inject = Underscore.funcs.reduce -Underscore.funcs.foldl = Underscore.funcs.reduce -Underscore.funcs.filter = Underscore.funcs.select -Underscore.funcs.every = Underscore.funcs.all -Underscore.funcs.some = Underscore.funcs.any -Underscore.funcs.head = Underscore.funcs.first -Underscore.funcs.tail = Underscore.funcs.rest - -local function wrap_functions_for_oo_support() - local function value_and_chained(value_or_self) - local chained = false - if getmetatable(value_or_self) == Underscore then - chained = value_or_self.chained - value_or_self = value_or_self._val - end - return value_or_self, chained - end - - local function value_or_wrap(value, chained) - if chained then - value = Underscore:new(value, true) - end - return value - end - - for fn, func in pairs(Underscore.funcs) do - Underscore[fn] = function(obj_or_self, ...) - local obj, chained = value_and_chained(obj_or_self) - return value_or_wrap(func(obj, ...), chained) - end - end -end - -wrap_functions_for_oo_support() - -return Underscore:new() From c30e8556950b1dc39b21ea96009063edde475dcd Mon Sep 17 00:00:00 2001 From: adamwagner Date: Sun, 16 Aug 2020 13:59:52 -0700 Subject: [PATCH 26/33] Track stack focus state, indicate last-focused window in unfocused stack, and centralize indicator config settings & retrieval. --- lib/utils.lua | 8 +- refactor-notes-2020-08-09.md | 19 ++- stackline/query.lua | 24 ++-- stackline/stack.lua | 106 +++++++------- stackline/stackline.lua | 61 ++------- stackline/stackmanager.lua | 46 ++++--- stackline/window.lua | 258 +++++++++++++++++++++++------------ 7 files changed, 296 insertions(+), 226 deletions(-) diff --git a/lib/utils.lua b/lib/utils.lua index cf4e4e8..67664a2 100644 --- a/lib/utils.lua +++ b/lib/utils.lua @@ -2,12 +2,8 @@ -- https://github.com/luapower/glue/blob/master/glue.lua -- https://github.com/Desvelao/f/blob/master/f/table.lua (new in 2020) -- https://github.com/moriyalb/lamda (based on ramda, updated May 2020, 27 stars) --- u.values( --- u.values( --- u.extend( --- u.include( --- u.any( --- u.filter( +-- https://github.com/EvandroLG/Hash.lua (new - updated Aug 2020, 7 stars) +-- https://github.com/Mudlet/Mudlet/tree/development/src/mudlet-lua/lua ← Very unusual / interesting lua utils -- utils = {} diff --git a/refactor-notes-2020-08-09.md b/refactor-notes-2020-08-09.md index 99e9d99..0744177 100644 --- a/refactor-notes-2020-08-09.md +++ b/refactor-notes-2020-08-09.md @@ -21,7 +21,24 @@ I also hope for tertiary benefits: ┌────────┐ │ Status │ └────────┘ -2020-08-02 + +## 2020-08-16 + +### Changes + +- Support stack focus "events"! Now, a stack takes on a new look when all windows become unfocused, with the last-active window distinct from the rest. This required a fair bit more complexity than expected, but is unavoidable (I think). There's a minor, barely noticable performance hit, too (not yet a problem, tho). +- Centralized indicator config settings & consistent "current style" retrieval. Reduced reliance on magic numbers (indicator style is more purely from user config settings now). +- Store a reference to the stack on each window, so any window can easily call stack methods. This allowed `redrawOtherAppWindows()` to move into the window class, where it's less awkward. +- Resolved bug in which unfocused same-app windows would 'flash focus' briefly + +### Multi-monitor support is still a `?` + +Stacks refresh on every space/monitor change, wasting resources shelling out to yabai & redrawing all indicators from scratch. + +Instead, it might better to update our data model to store: `screens[] → spaces[] → stacks[] → windows[]` … and then only update on *window* change events. + +## 2020-08-02 + We're not yet using any of the code in this file to actually render the indiators or query ata — all of that is still achieved via the "old" methods. However, `query.lua` IS being required by ./core.lua and runs one every window focus event, and the resulting "stack" data is printed to the hammerspoon console. diff --git a/stackline/query.lua b/stackline/query.lua index f047846..349f9b0 100644 --- a/stackline/query.lua +++ b/stackline/query.lua @@ -3,9 +3,6 @@ -- riding a bit too fast and found myself in a place where nothing worked, and I -- didn't know why. So, this mess lives another day. Conceptually, it'll be -- pretty easy to put this stuff where it belongs. --- DONE: remove dependency on hs._asm.undocumented.spaces --- Affects line at ./stackline/stackline.lua:48 using hs.window.filter.windowNotInCurrentSpace --- local spaces = require 'hs._asm.undocumented.spaces' local u = require 'stackline.lib.utils' local scriptPath = hs.configdir .. '/stackline/bin/yabai-get-stack-idx' @@ -21,14 +18,14 @@ function Query:getWinStackIdxs() -- {{{ end, {scriptPath}):start() end -- }}} -function Query:makeStacksFromWindows(ws) -- {{{ +function Query: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 byApp + local windows = u.map(ws, function(w) return Window:new(w) end) @@ -48,7 +45,6 @@ end -- }}} function Query:mergeWinStackIdxs() -- {{{ -- merge windowID <> stack-index mapping queried from yabai into window objs - function assignStackIndex(win) win.stackIdx = self.winStackIdxs[tostring(win.id)] end @@ -85,22 +81,18 @@ function shouldRestack(new) -- {{{ end -- }}} function Query:windowsCurrentSpace() -- {{{ - self:makeStacksFromWindows(wfd:getWindows()) -- set self.stacks & self.appWindows + self:groupWindows(wfd:getWindows()) -- set self.stacks & self.appWindows - local shouldRefresh local extantStacks = Sm:get() local extantStackSummary = Sm:getSummary() local extantStackExists = extantStackSummary.numStacks > 0 - if extantStackExists then - shouldRefresh = shouldRestack(self.stacks, extantStacks) - -- stacksMgr:dimOccluded() TODO: revisit in a future update. This is - -- kind of an edge case — there are bigger fish to fry. - else - shouldRefresh = true - end - + local shouldRefresh = extantStackExists and + shouldRestack(self.stacks, extantStacks) or true if shouldRefresh then + -- TODO: revisit in a future update. This is + -- kind of an edge case — there are bigger fish to fry. + -- stacksMgr:dimOccluded() self:getWinStackIdxs() -- set self.winStackIdxs (async shell call to yabai) function whenStackIdxDone() diff --git a/stackline/stack.lua b/stackline/stack.lua index b8f14f2..47de6e8 100644 --- a/stackline/stack.lua +++ b/stackline/stack.lua @@ -4,16 +4,12 @@ local Class = require 'stackline.lib.self' -- responding to focus events. Experimented with even smaller libs, but only -- 'self' worked so far. --- ARGS: Class(className, --- parentClass, --- table [define methods], --- isGlobal) +-- args: Class(className, parentClass, table [define methods], isGlobal) local Stack = Class("Stack", nil, { windows = {}, new = function(self, stackedWindows) -- {{{ self.windows = stackedWindows - self.id = stackedWindows[1].stackId end, -- }}} get = function(self) -- {{{ @@ -49,63 +45,79 @@ local Stack = Class("Stack", nil, { end) end, -- }}} - redrawAllIndicators = function(self) -- {{{ - self:eachWin(function(win) - win:setupIndicator() - win:drawIndicator() + anyFocused = function(self) -- {{{ + return u.any(self.windows, function(w) + return w:isFocused() end) end, -- }}} - deleteAllIndicators = function(self) -- {{{ + resetAllIndicators = function(self) -- {{{ self:eachWin(function(win) - win:deleteIndicator() + win:setupIndicator() + win:drawIndicator() end) end, -- }}} - dimAllIndicators = function(self) -- {{{ + redrawAllIndicators = function(self, opts) -- {{{ self:eachWin(function(win) - win:drawIndicator({unfocusedAlpha = 1}) + if win.id ~= opts.except then + win:redrawIndicator() + end end) end, -- }}} - restoreAlpha = function(self) -- {{{ + deleteAllIndicators = function(self) -- {{{ self:eachWin(function(win) - win:drawIndicator({unfocusedAlpha = nil}) + win:deleteIndicator() end) end, -- }}} - isWindowOccludedBy = function(self, otherWin, win) -- {{{ - -- Test uses optional 'win' arg if provided, - -- otherwise test uses 1st window of stack - local stackedFrame = win and win:frame() or self:frame() - return stackedFrame:inside(otherWin:frame()) - end, -- }}} - - isOccluded = function(self) -- {{{ - -- FIXES: https://github.com/AdamWagner/stackline/issues/11 - -- When a stack that has "zoom-parent": 1 occludes another stack, the - -- occluded stack's indicators shouldn't be displaed - - -- Returns true if any non-stack window occludes the stack's frame. - -- This can occur when an unstacked window is zoomed to cover a stack. - -- In this situation, we want to hide or dim the occluded stack's indicators - - local stackedHsWins = self:getHs() - - function notInStack(hsWin) - return not u.include(stackedHsWins, hsWin) - end - - local windowsCurrSpace = wfd:getWindows() - local nonStackWindows = u.filter(windowsCurrSpace, notInStack) - - -- true if *any* non-stacked windows occlude the stack's frame - -- NOTE: u.any() works, hs.fnutils.some does NOT work :~ - local stackIsOccluded = u.any(u.map(nonStackWindows, function(w) - return self:isWindowOccludedBy(w) - end)) - return stackIsOccluded - end, -- }}} + -- all occlusion-related methods currently disabled, but should be revisted + -- soon + -- dimAllIndicators = function(self) -- {{{ + -- self:eachWin(function(win) + -- win:drawIndicator({unfocusedAlpha = 1}) + -- end) + -- end, -- }}} + + -- restoreAlpha = function(self) -- {{{ + -- self:eachWin(function(win) + -- win:drawIndicator({unfocusedAlpha = nil}) + -- end) + -- end, -- }}} + + -- isWindowOccludedBy = function(self, otherWin, win) -- {{{ + -- -- Test uses optional 'win' arg if provided, + -- -- otherwise test uses 1st window of stack + -- local stackedFrame = win and win:frame() or self:frame() + -- return stackedFrame:inside(otherWin:frame()) + -- end, -- }}} + + -- isOccluded = function(self) -- {{{ + -- -- FIXES: https://github.com/AdamWagner/stackline/issues/11 + -- -- When a stack that has "zoom-parent": 1 occludes another stack, the + -- -- occluded stack's indicators shouldn't be displaed + + -- -- Returns true if any non-stack window occludes the stack's frame. + -- -- This can occur when an unstacked window is zoomed to cover a stack. + -- -- In this situation, we want to hide or dim the occluded stack's indicators + + -- local stackedHsWins = self:getHs() + + -- function notInStack(hsWin) + -- return not u.include(stackedHsWins, hsWin) + -- end + + -- local windowsCurrSpace = wfd:getWindows() + -- local nonStackWindows = u.filter(windowsCurrSpace, notInStack) + + -- -- true if *any* non-stacked windows occlude the stack's frame + -- -- NOTE: u.any() works, hs.fnutils.some does NOT work :~ + -- local stackIsOccluded = u.any(u.map(nonStackWindows, function(w) + -- return self:isWindowOccludedBy(w) + -- end)) + -- return stackIsOccluded + -- end, -- }}} }) return Stack diff --git a/stackline/stackline.lua b/stackline/stackline.lua index 5a3dbf0..c1cb97e 100644 --- a/stackline/stackline.lua +++ b/stackline/stackline.lua @@ -1,27 +1,22 @@ require("hs.ipc") +print(hs.settings.bundleID) local StackConfig = require 'stackline.stackline.config' local Stackmanager = require 'stackline.stackline.stackmanager' -local u = require 'stackline.lib.utils' - -print(hs.settings.bundleID) +local wf = hs.window.filter -- ┌────────┐ -- │ config │ -- └────────┘ -stackConfig = StackConfig:new():setEach({ - showIcons = true, - enableTmpFixForHsBug = true, -}):registerWatchers() - --- instantiate an instance of the stack manager globally -Sm = Stackmanager:new(showIcons) +local config = {showIcons = true, enableTmpFixForHsBug = true} -- ┌─────────┐ -- │ globals │ -- └─────────┘ -wf = hs.window.filter -wfd = wf.new():setOverrideFilter{ +-- instantiate instances of key classes and assign to global table (_G) +_G.stackConfig = StackConfig:new():setEach(config):registerWatchers() +_G.Sm = Stackmanager:new(showIcons) +_G.wfd = wf.new():setOverrideFilter{ visible = true, -- (i.e. not hidden and not minimized) fullscreen = false, currentSpace = true, @@ -29,7 +24,7 @@ wfd = wf.new():setOverrideFilter{ } -- TODO: review how @alin32 structured window (and config!) events into --- 'shouldRestack' and 'shouldClean' and integrate the good parts here. +-- 'shouldRestack' and 'shouldClean' and apply those ideas here. local windowEvents = { -- window added wf.windowCreated, @@ -45,7 +40,6 @@ local windowEvents = { wf.windowDestroyed, wf.windowHidden, wf.windowMinimized, - -- wf.windowNotInCurrentSpace, -- depends on hs._asm.undocumented.spaces } -- TO CONFIRM: Compared to calling wsi.update() directly in wf:subscribe @@ -62,49 +56,18 @@ wfd:subscribe(windowEvents, function() queryWindowState:start() end) --- Added 2020-08-12 to fill the gap of hs._asm.undocumented.spaces --- Stacks refresh on every space/monitor change, wasting resources shelling out to yabai --- & redrawing all indicators from scratch. --- TODO: It would better to update our data model to store: --- screens[] → spaces[] → stacks[] → windows[] --- … and then only update on *window* change events hs.spaces.watcher.new(function() + -- Added 2020-08-12 to fill the gap of hs._asm.undocumented.spaces queryWindowState:start() end):start() --- ┌───────────────────────────────────────────────┐ --- │ special case: focus events → optimized redraw │ --- └───────────────────────────────────────────────┘ -function unfocusOtherAppWindows(win) -- {{{ - -- To workaround HS BUG "windowUnfocused event not fired for same-app windows " - -- https://github.com/Hammerspoon/hammerspoon/issues/2400 - -- ../notes-query.md:103 - u.each(win.otherAppWindows, function(w) - w:redrawIndicator(false) - end) -end -- }}} - -function redrawWinIndicator(hsWin, _app, event) -- {{{ +function redrawWinIndicator(hsWin, _app, _event) -- {{{ -- Dedicated redraw method to *adjust* the existing canvas element is WAY -- faster than deleting the entire indicator & rebuilding it from scratch, -- particularly since this skips querying the app icon & building the icon image. - local id = hsWin:id() - local stackedWin = Sm:findWindow(id) + local stackedWin = Sm:findWindow(hsWin:id()) if stackedWin then -- when falsey, the focused win is not stacked - -- BUG: Unfocused window(s) flicker when an app has 2+ win in a stack {{{ - -- Wouldn't be an issue if Hammerspon #2400 is fixed - -- TODO: If there are 2+ windows of the same app in a stack, then the - -- *unfocused* window(s) indicator(s) flash 'focused' styles for a split second *before* the - -- the actually focused window's indicator :< - -- REPRO TIP #1: A non-common app window must be between the same-app windows. - -- REPRO TIP #2: You must be switching FROM a non-common app window TO a a shared app-window. - -- Switching between same-app windows is fine, even when a - -- non-common window is in the same stack. You must. }}} - if stackConfig:get('enableTmpFixForHsBug') then - unfocusOtherAppWindows(stackedWin) - end - local focused = (event == wf.windowFocused) - stackedWin:redrawIndicator(focused) -- draw instantly on focus change + stackedWin:redrawIndicator() end end -- }}} diff --git a/stackline/stackmanager.lua b/stackline/stackmanager.lua index 51c1e4b..61e7bdd 100644 --- a/stackline/stackmanager.lua +++ b/stackline/stackmanager.lua @@ -20,23 +20,24 @@ function Stackmanager:new() -- {{{ return self end -- }}} -function Stackmanager:ingest(stacks, appWindows, shouldClean) -- {{{ - - local stacksCount = u.length(stacks) - print('\n\nlength of ingested stacks is', stacksCount, '\n\n') - +function Stackmanager:ingest(windowGroups, appWindows, shouldClean) -- {{{ + local stacksCount = u.length(windowGroups) if shouldClean or (stacksCount == 0) then self:cleanup() end - for _stackId, stack in pairs(stacks) do - u.each(stack, function(win) + for stackId, groupedWindows in pairs(windowGroups) do + local stack = Stack(groupedWindows) + stack.id = stackId + u.each(stack.windows, function(win) + -- win.otherAppWindows needed to workaround Hammerspoon issue #2400 win.otherAppWindows = u.filter(appWindows[win.app], function(w) return w.id ~= win.id end) + win.stack = stack -- enables calling stack methods from window end) - table.insert(self.tabStacks, Stack(stack)) - self:redrawAllIndicators() + table.insert(self.tabStacks, stack) + self:resetAllIndicators() end end -- }}} @@ -50,16 +51,6 @@ function Stackmanager:eachStack(fn) -- {{{ end end -- }}} -function Stackmanager:dimOccluded() -- {{{ - self:eachStack(function(stack) - if stack:isOccluded() then - stack:dimAllIndicators() - else - stack:restoreAlpha() - end - end) -end -- }}} - function Stackmanager:cleanup() -- {{{ self:eachStack(function(stack) stack:deleteAllIndicators() @@ -88,15 +79,15 @@ function Stackmanager:getSummary(external) -- {{{ } end -- }}} -function Stackmanager:redrawAllIndicators() -- {{{ +function Stackmanager:resetAllIndicators() -- {{{ self:eachStack(function(stack) - stack:redrawAllIndicators() + stack:resetAllIndicators() end) end -- }}} function Stackmanager:toggleIcons() -- {{{ self.showIcons = not self.showIcons - self:redrawAllIndicators() + self:resetAllIndicators() end -- }}} function Stackmanager:findWindow(wid) -- {{{ @@ -125,4 +116,15 @@ function Stackmanager:getShowIconsState() -- {{{ return self.showIcons end -- }}} +-- function Stackmanager:dimOccluded() -- {{{ +-- -- disabled for now, but should revisit soon +-- self:eachStack(function(stack) +-- if stack:isOccluded() then +-- stack:dimAllIndicators() +-- else +-- stack:restoreAlpha() +-- end +-- end) +-- end -- }}} + return Stackmanager diff --git a/stackline/window.lua b/stackline/window.lua index 14ac524..1c42c06 100644 --- a/stackline/window.lua +++ b/stackline/window.lua @@ -31,32 +31,43 @@ function Window:isFocused() -- {{{ return isFocused end -- }}} +function Window:isStackFocused() -- {{{ + return self.stack:anyFocused() +end -- }}} + function Window:setupIndicator() -- {{{ -- Config - local showIcons = Sm:getShowIconsState() + self.showIcons = Sm:getShowIconsState() + self:isStackFocused() + + -- TODO: move into stackConfig module (somehow… despite its lack of support for nested keys :/) + self.config = { + color = {white = 0.90}, + alpha = 1, + + dimmer = 2.5, -- larger numbers increase contrast b/n focused & unfocused states + iconDimmer = 1.1, -- custom dimmer for icons - -- Padding - self.padding = 4 - self.iconPadding = 4 + size = 32, + radius = 3, + padding = 4, + iconPadding = 4, + pillThinness = 6, - -- Size - self.aspectRatio = 6 -- determines width of pills when showIcons = false - self.size = 32 - self.width = showIcons and self.size or (self.size / self.aspectRatio) + vertSpacing = 1.2, + offset = {y = 2, x = 4}, + -- example: overlapped with window + percent top offset + -- offset = { y = self.frame.h * 0.1, x = -(self.width / 2) } - -- Position - self.offsetY = 2 - self.offsetX = 4 - -- example: overlapped with window + percent top offset - -- self.offsetY = self.frame.h * 0.1 - -- self.offsetX = -(self.width / 2) + shouldFade = true, + fadeDuration = 0.2, + } - -- Roundness - self.indicatorRadius = 3 - self.iconRadius = self.width / 4.0 + local c = self.config -- alias config for convenience - -- Fade-in/out duration - self.fadeDuration = 0.2 + -- computed from config + self.width = self.showIcons and c.size or (c.size / c.pillThinness) + self.iconRadius = self.width / 3 -- Display indicators on -- left edge of windows on the left side of the screen, & @@ -64,9 +75,9 @@ function Window:setupIndicator() -- {{{ self.side = self:getScreenSide() local xval if self.side == 'right' then - xval = (self.frame.x + self.frame.w) + self.offsetX + xval = (self.frame.x + self.frame.w) + c.offset.x else - xval = self.frame.x - (self.width + self.offsetX) + xval = self.frame.x - (self.width + c.offset.x) end -- Set canvas to fill entire screen @@ -80,42 +91,29 @@ function Window:setupIndicator() -- {{{ -- NOTE: self.stackIdx comes from yabai self.indicator_rect = { x = xval, - y = self.frame.y + ((self.stackIdx - 1) * self.size * 1.1), + y = self.frame.y + c.offset.y + + ((self.stackIdx - 1) * c.size * c.vertSpacing), w = self.width, - h = self.size, + h = c.size, } self.icon_rect = { - x = xval + self.iconPadding, - y = self.indicator_rect.y + self.iconPadding, - w = self.indicator_rect.w - (self.iconPadding * 2), - h = self.indicator_rect.h - (self.iconPadding * 2), + x = xval + c.iconPadding, + y = self.indicator_rect.y + c.iconPadding, + w = self.indicator_rect.w - (c.iconPadding * 2), + h = self.indicator_rect.h - (c.iconPadding * 2), } end -- }}} function Window:drawIndicator(overrideOpts) -- {{{ - self.defaultOpts = { - shouldFade = true, - alphaFocused = 1, - alphaUnfocused = 0.33, - } + -- should there be a dedicated "Indicator" class to perform the actual drawing? - local opts = u.extend(self.defaultOpts, overrideOpts or {}) - - -- Color - self.colorFocused = {white = 0.9, alpha = opts.alphaFocused} - self.colorUnfocused = {white = 0.9, alpha = opts.alphaUnfocused} - - -- Unfocused icons less transparent than bg color, but no more than 1 - self.iconAlphaFocused = opts.alphaFocused - self.iconAlphaUnfocused = math.min(opts.alphaUnfocused * 2.25, 1) - - local showIcons = Sm:getShowIconsState() - local radius = showIcons and self.iconRadius or self.indicatorRadius - local fadeDuration = opts.shouldFade and self.fadeDuration or 0 + local opts = u.extend(self.config, overrideOpts or {}) + local radius = self.showIcons and self.iconRadius or opts.radius + local fadeDuration = opts.shouldFade and opts.fadeDuration or 0 self.focus = self:isFocused() - -- Speed profile: 0.0123s / 75 (0.0002s) :: isFocused + self.stackFocus = true if self.indicator then self.indicator:delete() @@ -123,16 +121,10 @@ function Window:drawIndicator(overrideOpts) -- {{{ self.indicator = hs.canvas.new(self.canvas_frame) - self.currStyle = { - fillColor = self.focus and self.colorFocused or self.colorUnfocused, - imageAlpha = self.focus and self.iconAlphaFocused or - self.iconAlphaUnfocused, - } - self.indicator:insertElement({ type = "rectangle", - action = "fill", - fillColor = self.currStyle.fillColor, + action = "fill", -- options: strokeAndFill, stroke, fill + fillColor = self:getColorAttrs(self.stackFocus, self.focus).bg, frame = self.indicator_rect, roundedRectRadii = {xRadius = radius, yRadius = radius}, padding = 60, @@ -140,42 +132,83 @@ function Window:drawIndicator(overrideOpts) -- {{{ shadow = self:getShadowAttrs(), }, self.rectIdx) - if showIcons then - -- TODO: Figure out how to prevent clipping when adding a subtle shadow + if self.showIcons then + -- TODO [low priority]: Figure out how to prevent clipping when adding a subtle shadow -- to the icon to help distinguish icons with a near-white edge.Note -- that `padding` attribute, which works for rects, does not work for images. self.indicator:insertElement({ type = "image", image = self:iconFromAppName(), frame = self.icon_rect, - imageAlpha = self.currStyle.imageAlpha, + imageAlpha = self:getColorAttrs(self.stackFocus, self.focus).img, }, self.iconIdx) end self.indicator:show(fadeDuration) end -- }}} -function Window:redrawIndicator(isFocused) -- {{{ - -- bail early if there's nothing to do - if isFocused == self.focus then +function Window:redrawIndicator() -- {{{ + local isWindowFocused = self:isFocused() + local isStackFocused = self:isStackFocused() + + -- has stack, window focus changed? + local stackFocusChange = isStackFocused ~= self.stackFocus + local windowFocusChange = isWindowFocused ~= self.focus + + -- permutations of stack, window change combos + local noChange = not stackFocusChange and not windowFocusChange + local bothChange = stackFocusChange and windowFocusChange + local onlyStackChange = stackFocusChange and not windowFocusChange + local onlyWinChange = not stackFocusChange and windowFocusChange + + -- LOGIC: Redraw according to what changed. + -- Supports indicating the *last-active* window in an unfocused stack. + if noChange then + -- bail early if there's nothing to do return false - else - self.focus = isFocused + + elseif bothChange then + -- If both change, it means a *focused* window's stack is now unfocused. + self.stackFocus = isStackFocused + self.stack:redrawAllIndicators({except = self.id}) + -- Despite the window being unfocused, do *not* update self.focus + -- (unfocused stack + focused window = last-active window) + + elseif onlyWinChange then + -- changing window focus within a stack + self.focus = isWindowFocused + + if self.focus and stackConfig:get('enableTmpFixForHsBug') then + self:unfocusOtherAppWindows() + end + + elseif onlyStackChange then + -- aka, already unfocused window's stack is now unfocused, too + -- in this case, we *do* update self.focus + self.stackFocus = isStackFocused + + -- if only stack changed *and* win is focused, it means a previously + -- unfocused stack is now focused, so redraw other window indicators + if isWindowFocused then + self.stack:redrawAllIndicators({except = self.id}) + end end if not self.indicator then self:setupIndicator() end + -- ACTION: Update canvas values local f = self.focus local rect = self.indicator[self.rectIdx] local icon = self.indicator[self.iconIdx] - rect.fillColor = f and self.colorFocused or self.colorUnfocused - rect.shadow = self:getShadowAttrs(f) - if Sm:getShowIconsState() then - icon.imageAlpha = f and self.iconAlphaFocused or self.iconAlphaUnfocused + local colorAttrs = self:getColorAttrs(self.stackFocus, self.focus) + rect.fillColor = colorAttrs.bg + if self.showIcons then + icon.imageAlpha = colorAttrs.img end + rect.shadow = self:getShadowAttrs(f) end -- }}} function Window:getScreenSide() -- {{{ @@ -192,51 +225,100 @@ function Window:getScreenSide() -- {{{ return side - -- TODO: BUG: Right-side window incorrectly reports as a left-side window with + -- TODO [low-priority]: BUG: Right-side window incorrectly reports as a left-side window with -- very large padding settings. Will need to consider coordinates from both - -- sides of a window. + -- sides of a window. Impact is minimal with smaller threshold (<= 0.75). - -- TODO: find a way to use hs.window.filter.windowsTo{Dir} - -- to determine side instead of percLeft/Right ↑ + -- TODO [very-low-priority]: find a way to use hs.window.filter.windowsTo{Dir} + -- to determine side instead of percLeft/Right �� -- https://www.hammerspoon.org/docs/hs.window.filter.html#windowsToWest -- wfd:windowsToWest(self._win) -- https://www.hammerspoon.org/docs/hs.window.html#windowsToWest -- self._win:windowsToSouth() end -- }}} -function Window:iconFromAppName() -- {{{ - appBundle = hs.appfinder.appFromName(self.app):bundleID() - return hs.image.imageFromAppBundle(appBundle) +function Window:getColorAttrs(isStackFocused, isWinFocused) -- {{{ + local opts = self.config + -- Lookup bg color and image alpha based on stack + window focus + -- e.g., fillColor = self:getColorAttrs(self.stackFocus, self.focus).bg + -- iconAlpha = self:getColorAttrs(self.stackFocus, self.focus).img + local colorLookup = { + stack = { + ['true'] = { + window = { + ['true'] = { + bg = u.extend(opts.color, {alpha = opts.alpha}), + img = opts.alpha, + }, + ['false'] = { + bg = u.extend(u.copy(opts.color), + {alpha = opts.alpha / opts.dimmer}), + img = opts.alpha / opts.iconDimmer, + }, + }, + }, + ['false'] = { + window = { + ['true'] = { + bg = u.extend(u.copy(opts.color), { + alpha = opts.alpha / (opts.dimmer / 1.2), + }), + -- last-focused icon stays full alpha when stack unfocused + img = opts.alpha, + }, + ['false'] = { + bg = u.extend(u.copy(opts.color), { + alpha = Sm:getShowIconsState() and 0 or 0.2, + }), + -- unfocused icon has slightly lower alpha when stack also unfocused + img = opts.alpha / + (opts.iconDimmer + (opts.iconDimmer * 0.70)), + }, + }, + }, + }, + } + -- end + + local isStackFocusedKey = tostring(isStackFocused) + local isWinFocusedKey = tostring(isWinFocused) + return colorLookup.stack[isStackFocusedKey].window[isWinFocusedKey] end -- }}} function Window:getShadowAttrs() -- {{{ - local shadowAlpha = self.focus and 3 or 3.75 -- denominator in 1 / N, so "2" == 50% alpha - local shadowBlur = self.focus and 18.0 or 5.0 + -- less opaque & blurry when iconsDisabled + -- even less opaque & blurry when unfocused + local iconsDisabledDimmer = Sm:getShowIconsState() and 1 or 5 + local alphaDimmer = (self.focus and 3 or 4) * iconsDisabledDimmer + local blurDimmer = (self.focus and 15.0 or 7.0) / iconsDisabledDimmer -- Shadows should cast outwards toward the screen edges as if due to the glow of onscreen windows… -- …or, if you prefer, from a light source originating from the center of the screen. - local shadowXDirection = (self.side == 'left') and -1 or 1 - local shadowOffset = { + local xDirection = (self.side == 'left') and -1 or 1 + local offset = { h = (self.focus and 3.0 or 2.0) * -1.0, - w = (self.focus and 7.0 or 6.0) * shadowXDirection, + w = ((self.focus and 7.0 or 6.0) * xDirection) / iconsDisabledDimmer, } -- TODO [just for fun]: Dust off an old Geometry textbook and try get the -- shadow's angle to rotate around a point at the center of the screen (aka, 'light source'). -- Here's a super crude POC that uses the indicator's stack index such that -- higher indicators have a negative Y offset and lower indicators have a -- positive Y offset ;-) - -- h = (self.focus and 3.0 or 2.0 - (2 + (self.stackIdx * 5))) * -1.0, + -- h = (self.focus and 3.0 or 2.0 - (2 + (self.stackIdx * 5))) * -1.0, return { - blurRadius = shadowBlur, - color = {alpha = 1 / shadowAlpha}, -- TODO align all alpha values to be defined like this (1/X) - offset = shadowOffset, + blurRadius = blurDimmer, + color = {alpha = 1 / alphaDimmer}, -- TODO align all alpha values to be defined like this (1/X) + offset = offset, } end -- }}} +function Window:iconFromAppName() -- {{{ + appBundle = hs.appfinder.appFromName(self.app):bundleID() + return hs.image.imageFromAppBundle(appBundle) +end -- }}} + function Window:makeStackId(hsWin) -- {{{ - -- stackId is top-left window frame coordinates - -- example: "302|35|63|1185|741" local frame = hsWin:frame():floor() local x = frame.x local y = frame.y @@ -250,8 +332,14 @@ end -- }}} function Window:deleteIndicator() -- {{{ if self.indicator then - self.indicator:delete(self.fadeDuration) + self.indicator:delete(self.config.fadeDuration) end end -- }}} +function Window:unfocusOtherAppWindows() -- {{{ + u.each(self.otherAppWindows, function(w) + w:redrawIndicator() + end) +end -- }}} + return Window From db32689df8b885e17ae3a97839d96630c8b1e22c Mon Sep 17 00:00:00 2001 From: adamwagner Date: Sun, 16 Aug 2020 14:13:32 -0700 Subject: [PATCH 27/33] Fix misleading comment --- stackline/window.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stackline/window.lua b/stackline/window.lua index 1c42c06..e1dbef0 100644 --- a/stackline/window.lua +++ b/stackline/window.lua @@ -184,7 +184,7 @@ function Window:redrawIndicator() -- {{{ elseif onlyStackChange then -- aka, already unfocused window's stack is now unfocused, too - -- in this case, we *do* update self.focus + -- so update stackFocus self.stackFocus = isStackFocused -- if only stack changed *and* win is focused, it means a previously From 59cd9f72e0ccab1ebfd5e36fa2ca20d70bd34e04 Mon Sep 17 00:00:00 2001 From: adamwagner Date: Sun, 16 Aug 2020 14:14:59 -0700 Subject: [PATCH 28/33] Remove garbage characters in comment --- stackline/window.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stackline/window.lua b/stackline/window.lua index e1dbef0..081b1d4 100644 --- a/stackline/window.lua +++ b/stackline/window.lua @@ -230,7 +230,7 @@ function Window:getScreenSide() -- {{{ -- sides of a window. Impact is minimal with smaller threshold (<= 0.75). -- TODO [very-low-priority]: find a way to use hs.window.filter.windowsTo{Dir} - -- to determine side instead of percLeft/Right �� + -- to determine side instead of percLeft/Right -- https://www.hammerspoon.org/docs/hs.window.filter.html#windowsToWest -- wfd:windowsToWest(self._win) -- https://www.hammerspoon.org/docs/hs.window.html#windowsToWest From ca991cd6adc5371fa2635c9d12e42d632a38d2d6 Mon Sep 17 00:00:00 2001 From: adamwagner Date: Fri, 21 Aug 2020 15:00:45 -0700 Subject: [PATCH 29/33] Fix error stackline/window.lua:95: attempt to perform arithmetic on a nil value (field 'stackIdx') by checking for stackIdx > 0 --- stackline/query.lua | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/stackline/query.lua b/stackline/query.lua index 349f9b0..6107151 100644 --- a/stackline/query.lua +++ b/stackline/query.lua @@ -43,15 +43,37 @@ function Query:groupWindows(ws) -- {{{ self.stacks = byStack end -- }}} +function Query:removeGroupedWin(win) + self.stacks = u.map(self.stacks, function(stack) + return u.filter(stack, function(w) + return w.id ~= win.id + end) + end) +end + function Query:mergeWinStackIdxs() -- {{{ -- merge windowID <> stack-index mapping queried from yabai into window objs + function assignStackIndex(win) - win.stackIdx = self.winStackIdxs[tostring(win.id)] + local stackIdx = self.winStackIdxs[tostring(win.id)] + + if stackIdx == 0 then + -- DONE: Fix error stackline/window.lua:95: attempt to perform arithmetic on a nil value (field 'stackIdx') + -- 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. + -- Windows that belong to a *real* stack have stackIdx > 0. + self:removeGroupedWin(win) + end + + -- set the stack idx + win.stackIdx = stackIdx end u.each(self.stacks, function(stack) u.each(stack, assignStackIndex) end) + end -- }}} function shouldRestack(new) -- {{{ @@ -90,8 +112,7 @@ function Query:windowsCurrentSpace() -- {{{ local shouldRefresh = extantStackExists and shouldRestack(self.stacks, extantStacks) or true if shouldRefresh then - -- TODO: revisit in a future update. This is - -- kind of an edge case — there are bigger fish to fry. + -- TODO: revisit in a future update. This is kind of an edge case — there are bigger fish to fry. -- stacksMgr:dimOccluded() self:getWinStackIdxs() -- set self.winStackIdxs (async shell call to yabai) From b94605b010abb9cacd9599363abe2e8781b5efa7 Mon Sep 17 00:00:00 2001 From: adamwagner Date: Fri, 21 Aug 2020 15:02:47 -0700 Subject: [PATCH 30/33] Remove completed TODO comments, try to keep TODO comments on single line to work well with leasot / rg --- stackline/config.lua | 1 + stackline/stackmanager.lua | 1 - stackline/window.lua | 12 +++++++----- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/stackline/config.lua b/stackline/config.lua index 4e4ace5..bf8d4d6 100644 --- a/stackline/config.lua +++ b/stackline/config.lua @@ -76,6 +76,7 @@ function StackConfig:registerWatchers() return self end +-- TODO: Remove keybinding from codebase, add to readme -- One very out-of-place hotkey binding (•_•) hs.hotkey.bind({'alt', 'ctrl'}, 't', function() Sm:toggleIcons() diff --git a/stackline/stackmanager.lua b/stackline/stackmanager.lua index 61e7bdd..593a0eb 100644 --- a/stackline/stackmanager.lua +++ b/stackline/stackmanager.lua @@ -1,4 +1,3 @@ --- TODO: consolidate these utils! local u = require 'stackline.lib.utils' -- stackline modules diff --git a/stackline/window.lua b/stackline/window.lua index 081b1d4..2682476 100644 --- a/stackline/window.lua +++ b/stackline/window.lua @@ -4,6 +4,7 @@ local u = require 'stackline.lib.utils' -- │ Window module │ -- └───────────────┘ local Window = {} +-- TODO: Click on indicator to activate target window (like tabs) https://github.com/AdamWagner/stackline/issues/19 function Window:new(hsWin) -- {{{ local ws = { @@ -69,6 +70,8 @@ function Window:setupIndicator() -- {{{ self.width = self.showIcons and c.size or (c.size / c.pillThinness) self.iconRadius = self.width / 3 + -- TODO: Limit the stack left/right side to the screen boundary so it doesn't go off screen https://github.com/AdamWagner/stackline/issues/21 + -- Display indicators on -- left edge of windows on the left side of the screen, & -- right edge of windows on the right side of the screen @@ -88,7 +91,7 @@ function Window:setupIndicator() -- {{{ self.rectIdx = 1 self.iconIdx = 2 - -- NOTE: self.stackIdx comes from yabai + -- NOTE: self.stackIdx comes from yabai. Window is stacked if stackIdx > 0 self.indicator_rect = { x = xval, y = self.frame.y + c.offset.y + @@ -299,11 +302,10 @@ function Window:getShadowAttrs() -- {{{ h = (self.focus and 3.0 or 2.0) * -1.0, w = ((self.focus and 7.0 or 6.0) * xDirection) / iconsDisabledDimmer, } - -- TODO [just for fun]: Dust off an old Geometry textbook and try get the - -- shadow's angle to rotate around a point at the center of the screen (aka, 'light source'). + + -- TODO [just for fun]: Dust off an old Geometry textbook and try get the shadow's angle to rotate around a point at the center of the screen (aka, 'light source') -- Here's a super crude POC that uses the indicator's stack index such that - -- higher indicators have a negative Y offset and lower indicators have a - -- positive Y offset ;-) + -- higher indicators have a negative Y offset and lower indicators have a positive Y offset -- h = (self.focus and 3.0 or 2.0 - (2 + (self.stackIdx * 5))) * -1.0, return { From dc07bb6e9c63269ec552efbbd176183a3d0cb3bc Mon Sep 17 00:00:00 2001 From: adamwagner Date: Fri, 21 Aug 2020 15:07:49 -0700 Subject: [PATCH 31/33] Enable keybindings in init.lua by returning stackConfig & stackManager instances from main stackline.lua Remove keybinding from config.lua & move to readme. --- README.md | 9 +++++++++ stackline/config.lua | 6 ------ stackline/stackline.lua | 1 + 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 1adee5e..0fb2ac1 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,15 @@ To toggle icons: echo ":toggle_icons:1" | hs -m stackline-config ``` +You can also configure a keybinding in your `init.lua` to toggle icons: + +```lua +stackline = require "stackline.stackline.stackline" +hs.hotkey.bind({'alt', 'ctrl'}, 't', function() + stackline.manager:toggleIcons() +end) +``` + ![stackline setup 02](assets/stackline-icon-indicators.png) Image (and feature!) courtesy of [@alin23](https://github.com/alin23). diff --git a/stackline/config.lua b/stackline/config.lua index bf8d4d6..2132bcf 100644 --- a/stackline/config.lua +++ b/stackline/config.lua @@ -76,12 +76,6 @@ function StackConfig:registerWatchers() return self end --- TODO: Remove keybinding from codebase, add to readme --- One very out-of-place hotkey binding (•_•) -hs.hotkey.bind({'alt', 'ctrl'}, 't', function() - Sm:toggleIcons() -end) - -- luacheck: ignore ipcConfigPort = hs.ipc.localPort('stackline-config', handleSignal) diff --git a/stackline/stackline.lua b/stackline/stackline.lua index c1cb97e..bc9a5fb 100644 --- a/stackline/stackline.lua +++ b/stackline/stackline.lua @@ -78,3 +78,4 @@ wfd:subscribe({wf.windowNotVisible, wf.windowUnfocused}, redrawWinIndicator) -- always update on load Sm:update() +return {config = _G.stackConfig, manager = _G.Sm} From 741b27d2601f1dc5aedc895b8e04d0458f4ad5db Mon Sep 17 00:00:00 2001 From: adamwagner Date: Fri, 21 Aug 2020 15:53:09 -0700 Subject: [PATCH 32/33] Fix https://github.com/AdamWagner/stackline/issues/21 - Limit stack left/right edge to screen boundary so it doesn't go off screen --- stackline/window.lua | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/stackline/window.lua b/stackline/window.lua index 2682476..fcf0b8e 100644 --- a/stackline/window.lua +++ b/stackline/window.lua @@ -70,22 +70,28 @@ function Window:setupIndicator() -- {{{ self.width = self.showIcons and c.size or (c.size / c.pillThinness) self.iconRadius = self.width / 3 - -- TODO: Limit the stack left/right side to the screen boundary so it doesn't go off screen https://github.com/AdamWagner/stackline/issues/21 + -- Set canvas to fill entire screen + local screenFrame = self._win:screen():frame() + self.canvas_frame = screenFrame -- Display indicators on -- left edge of windows on the left side of the screen, & -- right edge of windows on the right side of the screen self.side = self:getScreenSide() local xval + + -- DONE: Limit the stack left/right side to the screen boundary so it doesn't go off screen https://github.com/AdamWagner/stackline/issues/21 if self.side == 'right' then xval = (self.frame.x + self.frame.w) + c.offset.x + if xval + self.width > screenFrame.w then + -- don't go beyond the right screen edge + xval = screenFrame.w - self.width + end else xval = self.frame.x - (self.width + c.offset.x) + xval = math.max(xval, 0) -- don't go beyond left screen edge end - -- Set canvas to fill entire screen - self.canvas_frame = self._win:screen():frame() - -- Store canvas elements indexes to reference via :elementAttribute() -- https://www.hammerspoon.org/docs/hs.canvas.html#elementAttribute self.rectIdx = 1 From cdd8cba4d2ed8dd627260612e555c46c8388f324 Mon Sep 17 00:00:00 2001 From: adamwagner Date: Fri, 21 Aug 2020 19:17:34 -0700 Subject: [PATCH 33/33] Update version to 0.1.50 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b640c33..e418ad8 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ ![stackline-logo](assets/stackline-github-banner@2x.png)

- Version + Version License: MIT