mirror of
https://github.com/AquaMorph/dotfiles.git
synced 2026-11-03 08:17:59 +00:00
202 lines
8.3 KiB
Lua
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
|