Files

202 lines
8.3 KiB
Lua

-- Placement rules inferred from the running windows on this Mac.
require("hs.ipc")
local clickthrough = require("clickthrough")
local spaces = require("hs.spaces")
local log = hs.logger.new("placement", "info")
local monitors = {
main = "CBBC3C70-2EE1-4D28-8B7D-DC03A186F6EB", -- DELL P2715Q (2)
portrait = "22CE41A6-C177-4B4D-85D3-43337F9566E8", -- DELL P2715Q (1)
right = "3C0512D4-DAFF-4EE2-8CC8-8EA148E1ECB0", -- T24i-2L
}
-- Desktop numbers are per monitor, excluding native fullscreen Spaces.
-- Frames are fractions of the entire display, including the menu bar area.
local rules = {
{app = "com.mitchellh.ghostty", monitor = "main", desktop = 1,
frame = {0, 30 / 1890, 1, 1860 / 1890}},
{app = "io.robbie.HomeAssistant", monitor = "portrait", desktop = 1,
frame = {0, 30 / 3360, 1, 1665 / 3360}},
{app = "org.whispersystems.signal-desktop", monitor = "right", desktop = 2,
frame = {0, 30 / 1080, 1, 1050 / 1080}},
-- Match specialized browser windows; general browsing uses the fallback below.
{app = "com.brave.Browser", title = "YouTube", monitor = "portrait", desktop = 1,
frame = {0, 1695 / 3360, 1, 1665 / 3360}, singleOccupancy = true},
-- Other browser windows start on the right; a later YouTube title moves them.
{app = "com.brave.Browser", monitor = "right", desktop = 1,
frame = {0, 30 / 1080, 1, 1050 / 1080}, singleOccupancy = true},
-- Steam's library is owned by Steam Helper, not the launcher process.
{app = "com.valvesoftware.steam", monitor = "main", desktop = 5,
frame = {0, 0, 1, 1}},
{name = "Steam Helper", monitor = "main", desktop = 5,
frame = {0, 0, 1, 1}},
}
local applied, pending = {}, {}
local filter
local function ghosttyWindows()
local windows = {}
for _, window in ipairs(filter:getWindows()) do
local app = window:application()
if app and app:bundleID() == "com.mitchellh.ghostty"
and window:isStandard() and not window:isMinimized()
and not window:isFullScreen() and window:id() then
table.insert(windows, window)
end
end
-- Window IDs keep the older window on the left, regardless of focus order.
table.sort(windows, function(a, b) return a:id() < b:id() end)
return windows
end
local function spotOccupied(window, rule)
if not rule.singleOccupancy or not filter then return false end
for _, other in ipairs(filter:getWindows()) do
local app, screen = other:application(), other:screen()
if other:id() ~= window:id() and app and app:bundleID() == rule.app
and other:isStandard() and not other:isMinimized() and not other:isFullScreen()
and (not rule.title or other:title():find(rule.title, 1, true))
and screen and screen:getUUID() == monitors[rule.monitor] then
local desktops = {}
for _, space in ipairs(spaces.spacesForScreen(screen) or {}) do
if spaces.spaceType(space) == "user" then table.insert(desktops, space) end
end
local current = spaces.windowSpaces(other) or {}
local bounds, frame, actual = screen:fullFrame(), rule.frame, other:frame()
if #current == 1 and current[1] == desktops[rule.desktop]
and math.abs(actual.x - (bounds.x + frame[1] * bounds.w)) < 20
and math.abs(actual.y - (bounds.y + frame[2] * bounds.h)) < 20
and math.abs(actual.w - frame[3] * bounds.w) < 20
and math.abs(actual.h - frame[4] * bounds.h) < 20 then
return true
end
end
end
return false
end
local function ruleFor(window)
local app = window:application()
if not app or not window:isStandard() or window:isFullScreen()
or window:isMinimized() then return end
-- Ignore Steam's small helper/tooltip windows even if exposed as standard.
if app:name() == "Steam Helper" and window:frame().w < 600 then return end
for index, rule in ipairs(rules) do
if ((rule.app and app:bundleID() == rule.app)
or (rule.name and app:name() == rule.name))
and (not rule.title or window:title():find(rule.title, 1, true))
and not spotOccupied(window, rule) then
return rule, index
end
end
end
local function place(window, force)
local rule, index = ruleFor(window)
local id = window:id()
if not rule or not id then return end
local frame, placement = rule.frame, index
if rule.app == "com.mitchellh.ghostty" then
local windows = ghosttyWindows()
if #windows == 2 then
local left = windows[1]:id() == id
frame = {left and 0 or 0.5, frame[2], 0.5, frame[4]}
placement = tostring(index) .. (left and ":left" or ":right")
end
end
if not force and applied[id] == placement then return end
local screen
for _, candidate in ipairs(hs.screen.allScreens()) do
if candidate:getUUID() == monitors[rule.monitor] then screen = candidate; break end
end
if not screen then return end -- Leave windows alone when a monitor is unplugged.
local desktops = {}
for _, space in ipairs(spaces.spacesForScreen(screen) or {}) do
if spaces.spaceType(space) == "user" then table.insert(desktops, space) end
end
local target = desktops[rule.desktop]
local current = spaces.windowSpaces(window) or {}
if not target or #current ~= 1 or spaces.spaceType(current[1]) ~= "user" then return end
window:moveToScreen(screen, false, true, 0)
if current[1] ~= target then
local ok, err = spaces.moveWindowToSpace(window, target)
if not ok then
log.w("Desktop placement failed: " .. tostring(err))
return
end
end
local bounds = screen:fullFrame()
window:setFrame(hs.geometry.rect(
bounds.x + frame[1] * bounds.w, bounds.y + frame[2] * bounds.h,
frame[3] * bounds.w, frame[4] * bounds.h
), 0)
-- On newer macOS versions moveWindowToSpace can silently do nothing.
local actual = spaces.windowSpaces(window) or {}
if #actual ~= 1 or actual[1] ~= target then
log.w("Desktop placement did not take effect for " .. window:title()
.. " (target Space " .. tostring(target) .. ")")
return
end
applied[id] = placement
end
filter = hs.window.filter.new(true):setOverrideFilter({
allowRoles = "AXStandardWindow", fullscreen = false,
})
local function restore()
for _, window in ipairs(filter:getWindows()) do place(window, true) end
end
filter:subscribe({hs.window.filter.windowCreated, hs.window.filter.windowTitleChanged}, function(window)
local id = window:id()
if not id then return end
if pending[id] then return end
-- Briefly let new windows settle without title changes delaying placement.
pending[id] = hs.timer.doAfter(0.15, function()
pending[id] = nil
if window:id() then
place(window, false)
local app = window:application()
if app and app:bundleID() == "com.mitchellh.ghostty" then
for _, other in ipairs(ghosttyWindows()) do place(other, false) end
end
end
end)
end)
filter:subscribe(hs.window.filter.windowDestroyed, function(window)
local id = window:id()
if id then
applied[id] = nil
if pending[id] then pending[id]:stop(); pending[id] = nil end
end
-- Let the filter remove the closed window before checking the remaining one.
hs.timer.doAfter(0.2, function()
local windows = ghosttyWindows()
if #windows == 1 then place(windows[1], false) end
end)
end)
-- Retain the filter, watchers and menu for the lifetime of the configuration.
windowPlacement = {
rules = rules,
restore = restore,
filter = filter,
menu = hs.menubar.new(),
screenWatcher = hs.screen.watcher.new(function()
hs.timer.doAfter(3, restore)
end):start(),
}
windowPlacement.menu:setTitle("Windows")
windowPlacement.menu:setMenu({
{title = "Toggle single-click window activation", fn = clickthrough.toggle},
{title = "Reapply placement rules", fn = restore},
{title = "Reload configuration", fn = hs.reload},
{title = "Open Console", fn = hs.openConsole},
})
hs.autoLaunch(true)
if hs.accessibilityState(true) then hs.timer.doAfter(3, restore) end