Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,20 @@

All notable changes to this project are documented here.

## Unreleased

- Verified move behavior (replaces blind repeats)
- Add ensured-move helper that verifies final frame and retries only when needed using `hs.timer.doAfter`.
- Use ensured moves in focused-window cycling, bulk layout apply, and `windowCreated` subscription.
- Maintain zero-duration moves for responsiveness; avoids layout “wiggle” while ensuring stubborn apps settle.
- Configuration
- `ensure_move_verify` (default true): enable verification & conditional retry.
- `ensure_move_retries` (default 2): max retries when window hasn’t reached target.
- `ensure_move_delay_s` (default 0.05): delay between verification attempts.
- `ensure_move_tolerance_px` (default 2): pixel tolerance when comparing frames.
- Bug fixes
- Fix Lua scoping error by forward-declaring `disable_ax_enhanced_ui` so watchers can call ensured moves safely.

## 0.2.2

- Performance and behavior improvements
Expand Down
115 changes: 98 additions & 17 deletions wm/spoon.lua
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ local obj = {
log_level = "info",
-- Bulk apply: temporarily disable frame correctness during layout apply
bulk_apply_disable_frame_correctness = true,
-- Verify-and-retry move (only when needed)
ensure_move_verify = true,
ensure_move_retries = 2,
ensure_move_delay_s = 0.05,
ensure_move_tolerance_px = 2,
layouts = {},
application_ignore_list = {},
bindings = {
Expand Down Expand Up @@ -156,6 +161,93 @@ local function set_window_geometry_index(layout, window_id, index)
layout_state[window_id] = index
end

-- Convert a unit rect to a screen-absolute rect for a given window's screen
local function unit_rect_to_rect_for_window(unit_rect, window)
local screen = window and window:screen() or hs.screen.mainScreen()
local screen_frame = screen:frame()
return hs.geometry.rect(
screen_frame.x + (unit_rect.x * screen_frame.w),
screen_frame.y + (unit_rect.y * screen_frame.h),
unit_rect.w * screen_frame.w,
unit_rect.h * screen_frame.h
)
end

-- Forward declare to allow use before definition
local disable_ax_enhanced_ui

-- Per-window in-progress guard to avoid overlapping ensure cycles
obj._ensure_in_progress = {}

-- Move a window to a unit rect, then verify and retry only if needed.
-- Retries are scheduled with a small delay and capped by config.
local function move_window_to_unit_ensured(window, unit_rect)
if not window or not unit_rect then
return
end
disable_ax_enhanced_ui(window)
-- Initial attempt, zero-duration for speed
pcall(function()
window:moveToUnit(unit_rect, 0)
end)

if not obj.config.ensure_move_verify then
return
end

local ok_id, wid = pcall(function()
return window:id()
end)
local key = ok_id and tostring(wid) or tostring(window)
if obj._ensure_in_progress[key] then
return
end
obj._ensure_in_progress[key] = true

local retries = obj.config.ensure_move_retries or 2
local delay = obj.config.ensure_move_delay_s or 0.05
local tol = obj.config.ensure_move_tolerance_px or 2

local function close_enough(a, b)
return math.abs(a - b) <= tol
end

local function verify_and_retry()
-- Calculate desired rect against the window's current screen
local desired = unit_rect_to_rect_for_window(unit_rect, window):floor()
local current = window:frame():floor()

if
close_enough(current.x, desired.x)
and close_enough(current.y, desired.y)
and close_enough(current.w, desired.w)
and close_enough(current.h, desired.h)
then
obj._ensure_in_progress[key] = nil
return
end

if retries > 0 then
retries = retries - 1
pcall(function()
window:moveToUnit(unit_rect, 0)
end)
hs.timer.doAfter(delay, verify_and_retry)
else
log(
"debug",
"Move verification exhausted for %s (current=%s desired=%s)",
tostring(key),
tostring(current),
tostring(desired)
)
obj._ensure_in_progress[key] = nil
end
end

hs.timer.doAfter(delay, verify_and_retry)
end

local function get_window_id(window)
local app_name = "unknown"
local window_id = 0
Expand Down Expand Up @@ -230,7 +322,7 @@ local function cleanup_stale_window_state()
end
end

local function disable_ax_enhanced_ui(window)
disable_ax_enhanced_ui = function(window)
-- Disabling `AXEnhancedUserInterface` fixes the issue where some apps require retries to resize.
-- Cache the action per app to avoid repeating it on every move.
-- See: https://github.com/Hammerspoon/hammerspoon/issues/3224#issuecomment-2155567633
Expand Down Expand Up @@ -325,9 +417,8 @@ function obj:move_focused_window_next_geometry(direction)
set_window_geometry_index(self.layout, focused_window_id, next_index)

local target_geometry = _active_layout[next_index]
disable_ax_enhanced_ui(focused_window)
-- Force zero-duration move on the call site for speed
focused_window:moveToUnit(target_geometry, 0)
-- Verified move (retries only when needed)
move_window_to_unit_ensured(focused_window, target_geometry)
end

-- Select candidate windows according to configured constraints
Expand Down Expand Up @@ -457,17 +548,8 @@ function obj:set_layout(layout)

local target_geometry = active_layout[ix]
if target_geometry then
disable_ax_enhanced_ui(window)
local success, error = pcall(function()
window:moveToUnit(target_geometry, 0)
end)

if success then
log("debug", " ✓ Successfully moved %s", display_name)
moved = moved + 1
else
log("warn", " ✗ Failed to move %s: %s", display_name, error)
end
move_window_to_unit_ensured(window, target_geometry)
moved = moved + 1
end
end
end
Expand Down Expand Up @@ -579,11 +661,10 @@ function obj:init()
--
if window:isStandard() and window:isMaximizable() then
hs.alert("Initializing " .. app_name)
disable_ax_enhanced_ui(window)
local window_id = get_window_id(window)
local ix = get_window_geometry_index(self.layout, window_id)
local target_geometry = self.layouts[self.layout][ix]
window:moveToUnit(target_geometry, 0)
move_window_to_unit_ensured(window, target_geometry)
end
end)

Expand Down