diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 82fa604..b64bf3e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -120,7 +120,7 @@ jobs: with: type: 'zip' filename: 'wm.spoon.zip' - path: 'wm/' + path: 'wm.spoon/' - name: Upload Release uses: ncipollo/release-action@v1 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index c6d302d..3c6819e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,14 @@ All notable changes to this project are documented here. - `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. + - Packaging & structure + - Rename and restructure to canonical Spoon bundle: `wm.spoon/` with implementation consolidated into `init.lua`. + - Remove legacy `wm/` source layout and `spoon.lua`; entrypoint is now `wm.spoon/init.lua`. + - Update release workflow to zip `wm.spoon/` so the artifact unpacks to a proper `.spoon` bundle. + - Documentation + - Update README usage to the concise pattern: `hs.loadSpoon("wm"); spoon.wm.config.layouts = {...}; spoon.wm:init()`. + - Breaking changes + - Direct `require` of the old module path is no longer supported; consumers should use `hs.loadSpoon("wm")` and access the object via `spoon.wm`. ## 0.2.2 diff --git a/README.md b/README.md index 9a78797..2e6a96e 100644 --- a/README.md +++ b/README.md @@ -24,48 +24,49 @@ After moving windows to their desired positions, the state can be saved using th When initializing `wm.spoon`, the user is required to define their layouts, however, they also have the option to tweak key bindings along with a variety of other options. -A collection of pre-defined geometries can be found in `wm.builtins`. +A collection of pre-defined geometries can be found in `spoon.wm.builtins`. ```lua -local wm = require("modules.window") +-- Load the Spoon and use spoon.wm directly +hs.loadSpoon("wm") -wm.config.layouts = { +spoon.wm.config.layouts = { -- ┌-----------─┐ -- | [ ] | -- | [ ] | -- └------------┘ { - wm.builtins.full, - wm.builtins.pip_bottom_right, + spoon.wm.builtins.full, + spoon.wm.builtins.pip_bottom_right, }, -- ┌-----------─┐ -- | [ ][ ] | -- | [ ][ ] | -- └------------┘ { - wm.builtins.padded_left, - wm.builtins.padded_right, - wm.builtins.pip_bottom_right, + spoon.wm.builtins.padded_left, + spoon.wm.builtins.padded_right, + spoon.wm.builtins.pip_bottom_right, }, -- ┌-----------─┐ -- | [ ] | -- | [ ] | -- └------------┘ { - wm.builtins.padded_center, - wm.builtins.pip_bottom_right, + spoon.wm.builtins.padded_center, + spoon.wm.builtins.pip_bottom_right, }, -- ┌-----------─┐ -- | [ ] | -- | [ ] | -- └------------┘ { - wm.builtins.skinny, - wm.builtins.pip_top_right, + spoon.wm.builtins.skinny, + spoon.wm.builtins.pip_top_right, }, } -wm:init() +spoon.wm:init() ``` ## Releases diff --git a/wm/spoon.lua b/wm.spoon/init.lua similarity index 84% rename from wm/spoon.lua rename to wm.spoon/init.lua index 3589e55..beb17a3 100644 --- a/wm/spoon.lua +++ b/wm.spoon/init.lua @@ -525,31 +525,33 @@ function obj:set_layout(layout) end) local display_name = app_success and app_name or "unknown" - -- Check if window should be ignored or is not manageable + -- Skip if application is in ignore list if should_ignore_window(window) then - log("debug", " ⊘ Ignoring %s (in ignore list)", display_name) - elseif not (window:isStandard() and window:isMaximizable()) then - log( - "debug", - " ⊘ Skipping %s (non-standard or non-maximizable)", - display_name - ) + log("debug", "Skipping %s (ignored)", display_name) else - log("debug", "Window %d: %s - attempting to move", i, display_name) - - -- Try to move the window regardless of validation - local window_id = get_window_id(window) - local ix = get_window_geometry_index(layout, window_id) - - if ix > #active_layout then - ix = 1 - set_window_geometry_index(layout, window_id, ix) - end - - local target_geometry = active_layout[ix] - if target_geometry then + local is_standard_success, is_standard = pcall(function() + return window:isStandard() + end) + local is_max_success, is_maximizable = pcall(function() + return window:isMaximizable() + end) + + if is_standard_success and is_standard and is_max_success and is_maximizable then + disable_ax_enhanced_ui(window) + local cached_index = get_window_geometry_index(self.layout, get_window_id(window)) + local target_geometry = active_layout[cached_index] or active_layout[1] + + -- Verified move (retries only when needed) move_window_to_unit_ensured(window, target_geometry) moved = moved + 1 + else + log( + "debug", + "Skipping %s (standard=%s maximizable=%s)", + display_name, + tostring(is_standard_success and is_standard), + tostring(is_max_success and is_maximizable) + ) end end end @@ -557,78 +559,99 @@ function obj:set_layout(layout) return moved end) - -- Restore frame correctness regardless of loop outcome - if self.config.bulk_apply_disable_frame_correctness then - hs.window.setFrameCorrectness = original_correctness - log("debug", "Restored setFrameCorrectness after bulk apply") - end - - if ok then - log("info", "=== Layout Setting Complete - Moved %d windows ===", moved_count) + if not ok then + log("error", "Error during layout application: %s", tostring(moved_count)) else - log("warn", "Layout apply encountered an error: %s", tostring(moved_count)) + log("info", "Moved %d windows", moved_count or 0) end + + -- Restore original correctness setting + hs.window.setFrameCorrectness = original_correctness end +--- Persist object state to the file defined in config function obj:save_state() - cleanup_stale_window_state() + local success, json = pcall(function() + return hs.json.encode(self.state, true) + end) + if not success or not json then + hs.alert("Failed to encode state to JSON") + return + end local path = get_config("state_file_path") - hs.json.write(self.state, path, true, true) - hs.alert(string.format("wm.spoon state written to file: %s", path)) + local ok, err = pcall(function() + local file = io.open(path, "w") + if not file then + error("Could not open file for writing: " .. tostring(path)) + end + file:write(json) + file:close() + end) + if ok then + hs.alert(string.format("wm.spoon state written to file: %s", path)) + else + hs.alert("Failed to persist state: " .. tostring(err)) + end end +--- Restore object state from the file defined in config function obj:load_state() local path = get_config("state_file_path") - local s = hs.json.read(path) - if type(s) == "table" then - obj.state = s + local ok, res = pcall(function() + local file = io.open(path, "r") + if not file then + return nil + end + local data = file:read("*a") + file:close() + return data + end) + + if not ok or not res then + hs.alert(string.format("wm.spoon no valid state to load at: %s", path)) + return + end + + local ok_json, state = pcall(function() + return hs.json.decode(res) + end) + + if ok_json and type(state) == "table" then + self.state = state hs.alert(string.format("wm.spoon state loaded from file: %s", path)) else hs.alert(string.format("wm.spoon no valid state to load at: %s", path)) end end -function obj:debug_window_filter() - print("=== Window Filter Debug ===") - print(string.format("Filter object: %s", tostring(self.window_filter_all))) - - local filter_windows = self.window_filter_all:getWindows() - local all_windows = hs.window.allWindows() - - print(string.format("Filter returned: %d windows", filter_windows and #filter_windows or 0)) - print(string.format("hs.window.allWindows returned: %d windows", #all_windows)) - - print("All windows from hs.window.allWindows():") - for i, window in ipairs(all_windows) do +--- Debug helper to print all windows and basic attributes +function obj:debug_windows() + print("=== Windows Debug ===") + local windows = hs.window.allWindows() + for i, window in ipairs(windows) do if window and type(window) == "userdata" then - local success, is_valid = pcall(function() - return window:isValid() + local app_success, app_name = pcall(function() + return window:application():name() + end) + local is_standard_success, is_standard = pcall(function() + return window:isStandard() end) - if success and is_valid then - local app_success, app_name = pcall(function() - return window:application():name() - end) - local is_standard_success, is_standard = pcall(function() - return window:isStandard() - end) - local is_max_success, is_maximizable = pcall(function() - return window:isMaximizable() - end) - print( - string.format( - " %d: %s - standard:%s, maximizable:%s", - i, - app_success and app_name or "unknown", - is_standard_success and tostring(is_standard) or "error", - is_max_success and tostring(is_maximizable) or "error" - ) + local is_max_success, is_maximizable = pcall(function() + return window:isMaximizable() + end) + + print( + string.format( + " %d: %s - standard:%s, maximizable:%s", + i, + app_success and app_name or "unknown", + is_standard_success and tostring(is_standard) or "error", + is_max_success and tostring(is_maximizable) or "error" ) - else - print(string.format(" %d: INVALID WINDOW", i)) - end + ) else - print(string.format(" %d: NIL or non-userdata", i)) + print(string.format(" %d: INVALID WINDOW", i)) end end print("=== Debug Complete ===") @@ -739,3 +762,4 @@ function obj:init() end return obj + diff --git a/wm/init.lua b/wm/init.lua deleted file mode 100644 index aaf7586..0000000 --- a/wm/init.lua +++ /dev/null @@ -1,10 +0,0 @@ --- Entry point for Hammerspoon when loading as a Spoon via hs.loadSpoon("wm") --- Delegates to spoon.lua to keep a single implementation. - -local resourcePath = hs and hs.spoons and hs.spoons.resourcePath -if resourcePath then - return dofile(resourcePath("spoon.lua")) -else - -- Fallback for direct require from source tree - return dofile((... and (...):gsub("%.init$", "") or ".") .. "/spoon.lua") -end