Skip to content

Feature fuzzy stacked win detection

Adam Wagner edited this page Sep 20, 2020 · 1 revision

https://github.com/AdamWagner/stackline/issues/32 turned out to be a lot trickier than expected.

I attempted to rewrite the window grouping logic to be more forgiving than simply rounding the window frame points before stringifying the frame and grouping windows into stacks.

The grouping logic below produces worse results, and (still) has tricky bugs, such as non-deterministic results when simply refreshing Hammerspoon. I think these bugs are due to a combination of the way lua tables can have two table keys that have the same data, and yet are distinct instance plus the fact that table order is not guaranteed. The first issue requires tables to be hashed as strings before being used as unique table keys, and the second issue makes it difficult to produce a consistent hash from a table.

At any rate, I experimented with much larger frameFuzz values, and did not observe negative side effects with values up to 200. Further, a value of 200 was sufficient to mask the minor differences in window size for apps that constrain dimensions such as iTerm2.

function sumTable(xs) 
    local sum = function(x, y) return x + y end
    return hs.fnutils.reduce(xs, sum)
end 

function hash(frame) 
    local f = frame:floor().table
    local hash = table.concat({f.x, f.y, f.w, f.h}, '|'),
    return hash
end  -- }}}

function unhash(s)  -- {{{
    local ks = {'x', 'y', 'w', 'h'}
    local vs = u.map(u.split(s, '|'), tonumber)
    local unhashed = u.zip(ks,vs)
    return unhashed
end  -- }}}

function compareFrames(a, b)  -- {{{
    local delta = {}

    if a.table then
        print('a is a frame')
        a = a.table
    else
        a = unhash(a)
    end

    for k, _v in pairs(b.table) do
        delta[k] = math.abs(a[k] - b.table[k])
    end

    local values = u.values(delta)
    local max    = math.max(table.unpack(values))
    local avg    = sumTable(values) / #values

    local frameDiff = {
        delta = delta,
        max   = max,
        avg   = avg,
    }

    if frameDiff.max < 50 and frameDiff.avg < 10 then
        -- I didn't simply return ↑ b/c I was logging debug info here
        return true
    else 
        return false
    end
end

function getAggregation(a, b)
    if a.frame then
         local groups = {}
         groups[hash(a.frame)] = { a }
         return groups
    else
        return a
    end
end

-- The first iteration of the reducer will call fn with the first and second
-- elements of the table. The second iteration will call fn with the result of
-- the first iteration, and the third element. This repeats until there is only
-- one element left
function fuzzyGroupByFrame(a, b)
    local groups = getAggregation(a, b)

    for frame, _wins in pairs(groups) do
        if compareFrames(frame, b.frame) then
            groups[frame] = u.concat(groups[frame], {b})
        else
            groups[hash(b.frame)] = { b }
        end
    end
    return groups
end