From 66d9def043cee539786735b8180cf9988d6c510f Mon Sep 17 00:00:00 2001 From: John Randall Date: Thu, 9 Apr 2026 18:10:45 -0400 Subject: [PATCH 1/4] Add SpacesSync Spoon: synchronize macOS Spaces across monitors Keeps Spaces in lockstep across configurable monitor groups. When you switch Spaces on one monitor, all others in the same sync group follow to the matching Space index. Requires macOS 15.0+ (uses private hs.spaces APIs). Tested on macOS 15.5 with Hammerspoon 1.1.1 on a 4-monitor setup. Homepage: https://github.com/johntrandall/hammerspoon-spaces-sync Co-Authored-By: Claude Opus 4.6 (1M context) --- Source/SpacesSync.spoon/docs.json | 586 ++++++++++++++++++++++++++++ Source/SpacesSync.spoon/init.lua | 624 ++++++++++++++++++++++++++++++ 2 files changed, 1210 insertions(+) create mode 100644 Source/SpacesSync.spoon/docs.json create mode 100644 Source/SpacesSync.spoon/init.lua diff --git a/Source/SpacesSync.spoon/docs.json b/Source/SpacesSync.spoon/docs.json new file mode 100644 index 00000000..97a7335f --- /dev/null +++ b/Source/SpacesSync.spoon/docs.json @@ -0,0 +1,586 @@ +[ + { + "Constant" : [ + + ], + "submodules" : [ + + ], + "Function" : [ + + ], + "Variable" : [ + { + "doc" : "Logger object used within the Spoon. Can be accessed to set the default\nlog level for the messages coming from the Spoon.\n\nDefault log level: `info`. Set to `debug` for verbose watcher state dumps\nand per-target dispatch details. Set to `warning` to suppress routine sync\nmessages.\n\nExample:\n```lua\nspoon.SpacesSync.logger.setLogLevel('debug')\n```", + "stripped_doc" : [ + "Logger object used within the Spoon. Can be accessed to set the default", + "log level for the messages coming from the Spoon.", + "", + "Default log level: `info`. Set to `debug` for verbose watcher state dumps", + "and per-target dispatch details. Set to `warning` to suppress routine sync", + "messages.", + "", + "Example:", + "```lua", + "spoon.SpacesSync.logger.setLogLevel('debug')", + "```" + ], + "def" : "SpacesSync.logger", + "desc" : "Logger object used within the Spoon. Can be accessed to set the default", + "notes" : [ + + ], + "signature" : "SpacesSync.logger", + "type" : "Variable", + "returns" : [ + + ], + "name" : "logger", + "parameters" : [ + + ] + }, + { + "doc" : "List of sync groups. Each group is a list of monitor position numbers.\nPositions are assigned in reading order (left-to-right, top-to-bottom).\nMonitors not in any group are independent.\n\nDefault value: `{ {1, 2} }`\n\nExamples:\n * `{ {1, 2} }` — monitors 1 and 2 sync together\n * `{ {1, 2}, {3, 4} }` — two independent pairs\n * `{ {1, 2, 3} }` — three monitors sync together", + "stripped_doc" : [ + "List of sync groups. Each group is a list of monitor position numbers.", + "Positions are assigned in reading order (left-to-right, top-to-bottom).", + "Monitors not in any group are independent.", + "", + "Default value: `{ {1, 2} }`", + "", + "Examples:", + " * `{ {1, 2} }` — monitors 1 and 2 sync together", + " * `{ {1, 2}, {3, 4} }` — two independent pairs", + " * `{ {1, 2, 3} }` — three monitors sync together" + ], + "def" : "SpacesSync.syncGroups", + "desc" : "List of sync groups. Each group is a list of monitor position numbers.", + "notes" : [ + + ], + "signature" : "SpacesSync.syncGroups", + "type" : "Variable", + "returns" : [ + + ], + "name" : "syncGroups", + "parameters" : [ + + ] + }, + { + "doc" : "Delay in seconds between each `hs.spaces.gotoSpace()` call.\nmacOS silently drops rapid back-to-back space switches.\n\nDefault value: `0.3`", + "stripped_doc" : [ + "Delay in seconds between each `hs.spaces.gotoSpace()` call.", + "macOS silently drops rapid back-to-back space switches.", + "", + "Default value: `0.3`" + ], + "def" : "SpacesSync.switchDelay", + "desc" : "Delay in seconds between each `hs.spaces.gotoSpace()` call.", + "notes" : [ + + ], + "signature" : "SpacesSync.switchDelay", + "type" : "Variable", + "returns" : [ + + ], + "name" : "switchDelay", + "parameters" : [ + + ] + }, + { + "doc" : "Seconds to wait after all switches complete before re-enabling the watcher.\nPrevents the watcher from reacting to our own programmatic space switches.\n\nDefault value: `0.8`", + "stripped_doc" : [ + "Seconds to wait after all switches complete before re-enabling the watcher.", + "Prevents the watcher from reacting to our own programmatic space switches.", + "", + "Default value: `0.8`" + ], + "def" : "SpacesSync.debounceSeconds", + "desc" : "Seconds to wait after all switches complete before re-enabling the watcher.", + "notes" : [ + + ], + "signature" : "SpacesSync.debounceSeconds", + "type" : "Variable", + "returns" : [ + + ], + "name" : "debounceSeconds", + "parameters" : [ + + ] + }, + { + "doc" : "Default hotkey mapping. Use with `:bindHotkeys()` for a quick setup:\n\n```lua\nspoon.SpacesSync:bindHotkeys(spoon.SpacesSync.defaultHotkeys)\n```\n\nDefault value:\n```lua\n{\n toggle = {{\"ctrl\", \"alt\", \"cmd\"}, \"Y\"},\n}\n```", + "stripped_doc" : [ + "Default hotkey mapping. Use with `:bindHotkeys()` for a quick setup:", + "", + "```lua", + "spoon.SpacesSync:bindHotkeys(spoon.SpacesSync.defaultHotkeys)", + "```", + "", + "Default value:", + "```lua", + "{", + " toggle = {{\"ctrl\", \"alt\", \"cmd\"}, \"Y\"},", + "}", + "```" + ], + "def" : "SpacesSync.defaultHotkeys", + "desc" : "Default hotkey mapping. Use with `:bindHotkeys()` for a quick setup:", + "notes" : [ + + ], + "signature" : "SpacesSync.defaultHotkeys", + "type" : "Variable", + "returns" : [ + + ], + "name" : "defaultHotkeys", + "parameters" : [ + + ] + } + ], + "stripped_doc" : [ + + ], + "Deprecated" : [ + + ], + "type" : "Module", + "desc" : "Synchronize macOS Spaces across monitors.", + "Constructor" : [ + + ], + "doc" : "Synchronize macOS Spaces across monitors.\n\nWhen you switch Spaces on one monitor, all other monitors in the same\nsync group follow to the matching Space index.\n\nMonitors are identified by position number (reading order:\nleft-to-right, top-to-bottom). Define sync groups as sets of position\nnumbers.\n\n**Requirements:**\n * macOS Sequoia 15.0+ (uses private `hs.spaces` APIs)\n * Two or more monitors with multiple Spaces configured\n * \"Displays have separate Spaces\" must be ON (System Settings > Desktop & Dock > Mission Control)\n * \"Automatically rearrange Spaces based on most recent use\" should be OFF\n\nDownload: [https:\/\/github.com\/Hammerspoon\/Spoons\/raw\/master\/Spoons\/SpacesSync.spoon.zip](https:\/\/github.com\/Hammerspoon\/Spoons\/raw\/master\/Spoons\/SpacesSync.spoon.zip)", + "Method" : [ + { + "doc" : "Initializes the SpacesSync spoon. Called automatically by `hs.loadSpoon()`.\nDoes not start syncing — call `:start()` to begin.\n\nParameters:\n * None\n\nReturns:\n * The SpacesSync object", + "stripped_doc" : [ + "Initializes the SpacesSync spoon. Called automatically by `hs.loadSpoon()`.", + "Does not start syncing — call `:start()` to begin.", + "" + ], + "def" : "SpacesSync:init()", + "desc" : "Initializes the SpacesSync spoon. Called automatically by `hs.loadSpoon()`.", + "notes" : [ + + ], + "signature" : "SpacesSync:init()", + "type" : "Method", + "returns" : [ + " * The SpacesSync object" + ], + "name" : "init", + "parameters" : [ + " * None", + "" + ] + }, + { + "doc" : "Starts Space syncing.\nChecks macOS version and Mission Control settings, builds the monitor\nposition map, and enables the Space watcher.\n\nParameters:\n * None\n\nReturns:\n * The SpacesSync object", + "stripped_doc" : [ + "Starts Space syncing.", + "Checks macOS version and Mission Control settings, builds the monitor", + "position map, and enables the Space watcher.", + "" + ], + "def" : "SpacesSync:start()", + "desc" : "Starts Space syncing.", + "notes" : [ + + ], + "signature" : "SpacesSync:start()", + "type" : "Method", + "returns" : [ + " * The SpacesSync object" + ], + "name" : "start", + "parameters" : [ + " * None", + "" + ] + }, + { + "doc" : "Stops Space syncing and cleans up watchers and timers.\n\nParameters:\n * None\n\nReturns:\n * The SpacesSync object", + "stripped_doc" : [ + "Stops Space syncing and cleans up watchers and timers.", + "" + ], + "def" : "SpacesSync:stop()", + "desc" : "Stops Space syncing and cleans up watchers and timers.", + "notes" : [ + + ], + "signature" : "SpacesSync:stop()", + "type" : "Method", + "returns" : [ + " * The SpacesSync object" + ], + "name" : "stop", + "parameters" : [ + " * None", + "" + ] + }, + { + "doc" : "Toggles Space syncing on or off.\n\nParameters:\n * None\n\nReturns:\n * The SpacesSync object", + "stripped_doc" : [ + "Toggles Space syncing on or off.", + "" + ], + "def" : "SpacesSync:toggle()", + "desc" : "Toggles Space syncing on or off.", + "notes" : [ + + ], + "signature" : "SpacesSync:toggle()", + "type" : "Method", + "returns" : [ + " * The SpacesSync object" + ], + "name" : "toggle", + "parameters" : [ + " * None", + "" + ] + }, + { + "doc" : "Returns whether Space syncing is currently active.\n\nParameters:\n * None\n\nReturns:\n * A boolean", + "stripped_doc" : [ + "Returns whether Space syncing is currently active.", + "" + ], + "def" : "SpacesSync:isEnabled()", + "desc" : "Returns whether Space syncing is currently active.", + "notes" : [ + + ], + "signature" : "SpacesSync:isEnabled()", + "type" : "Method", + "returns" : [ + " * A boolean" + ], + "name" : "isEnabled", + "parameters" : [ + " * None", + "" + ] + }, + { + "doc" : "Binds hotkeys for SpacesSync.\n\nParameters:\n * mapping - A table containing hotkey modifier\/key details for the following items:\n * toggle - Toggle Space syncing on\/off\n\nReturns:\n * The SpacesSync object\n\nNotes:\n * For a quick setup with defaults, use:\n `spoon.SpacesSync:bindHotkeys(spoon.SpacesSync.defaultHotkeys)`", + "stripped_doc" : [ + "Binds hotkeys for SpacesSync.", + "" + ], + "def" : "SpacesSync:bindHotkeys(mapping)", + "desc" : "Binds hotkeys for SpacesSync.", + "notes" : [ + " * For a quick setup with defaults, use:", + " `spoon.SpacesSync:bindHotkeys(spoon.SpacesSync.defaultHotkeys)`" + ], + "signature" : "SpacesSync:bindHotkeys(mapping)", + "type" : "Method", + "returns" : [ + " * The SpacesSync object", + "" + ], + "name" : "bindHotkeys", + "parameters" : [ + " * mapping - A table containing hotkey modifier\/key details for the following items:", + " * toggle - Toggle Space syncing on\/off", + "" + ] + } + ], + "Field" : [ + + ], + "items" : [ + { + "doc" : "Seconds to wait after all switches complete before re-enabling the watcher.\nPrevents the watcher from reacting to our own programmatic space switches.\n\nDefault value: `0.8`", + "stripped_doc" : [ + "Seconds to wait after all switches complete before re-enabling the watcher.", + "Prevents the watcher from reacting to our own programmatic space switches.", + "", + "Default value: `0.8`" + ], + "def" : "SpacesSync.debounceSeconds", + "desc" : "Seconds to wait after all switches complete before re-enabling the watcher.", + "notes" : [ + + ], + "signature" : "SpacesSync.debounceSeconds", + "type" : "Variable", + "returns" : [ + + ], + "name" : "debounceSeconds", + "parameters" : [ + + ] + }, + { + "doc" : "Default hotkey mapping. Use with `:bindHotkeys()` for a quick setup:\n\n```lua\nspoon.SpacesSync:bindHotkeys(spoon.SpacesSync.defaultHotkeys)\n```\n\nDefault value:\n```lua\n{\n toggle = {{\"ctrl\", \"alt\", \"cmd\"}, \"Y\"},\n}\n```", + "stripped_doc" : [ + "Default hotkey mapping. Use with `:bindHotkeys()` for a quick setup:", + "", + "```lua", + "spoon.SpacesSync:bindHotkeys(spoon.SpacesSync.defaultHotkeys)", + "```", + "", + "Default value:", + "```lua", + "{", + " toggle = {{\"ctrl\", \"alt\", \"cmd\"}, \"Y\"},", + "}", + "```" + ], + "def" : "SpacesSync.defaultHotkeys", + "desc" : "Default hotkey mapping. Use with `:bindHotkeys()` for a quick setup:", + "notes" : [ + + ], + "signature" : "SpacesSync.defaultHotkeys", + "type" : "Variable", + "returns" : [ + + ], + "name" : "defaultHotkeys", + "parameters" : [ + + ] + }, + { + "doc" : "Logger object used within the Spoon. Can be accessed to set the default\nlog level for the messages coming from the Spoon.\n\nDefault log level: `info`. Set to `debug` for verbose watcher state dumps\nand per-target dispatch details. Set to `warning` to suppress routine sync\nmessages.\n\nExample:\n```lua\nspoon.SpacesSync.logger.setLogLevel('debug')\n```", + "stripped_doc" : [ + "Logger object used within the Spoon. Can be accessed to set the default", + "log level for the messages coming from the Spoon.", + "", + "Default log level: `info`. Set to `debug` for verbose watcher state dumps", + "and per-target dispatch details. Set to `warning` to suppress routine sync", + "messages.", + "", + "Example:", + "```lua", + "spoon.SpacesSync.logger.setLogLevel('debug')", + "```" + ], + "def" : "SpacesSync.logger", + "desc" : "Logger object used within the Spoon. Can be accessed to set the default", + "notes" : [ + + ], + "signature" : "SpacesSync.logger", + "type" : "Variable", + "returns" : [ + + ], + "name" : "logger", + "parameters" : [ + + ] + }, + { + "doc" : "Delay in seconds between each `hs.spaces.gotoSpace()` call.\nmacOS silently drops rapid back-to-back space switches.\n\nDefault value: `0.3`", + "stripped_doc" : [ + "Delay in seconds between each `hs.spaces.gotoSpace()` call.", + "macOS silently drops rapid back-to-back space switches.", + "", + "Default value: `0.3`" + ], + "def" : "SpacesSync.switchDelay", + "desc" : "Delay in seconds between each `hs.spaces.gotoSpace()` call.", + "notes" : [ + + ], + "signature" : "SpacesSync.switchDelay", + "type" : "Variable", + "returns" : [ + + ], + "name" : "switchDelay", + "parameters" : [ + + ] + }, + { + "doc" : "List of sync groups. Each group is a list of monitor position numbers.\nPositions are assigned in reading order (left-to-right, top-to-bottom).\nMonitors not in any group are independent.\n\nDefault value: `{ {1, 2} }`\n\nExamples:\n * `{ {1, 2} }` — monitors 1 and 2 sync together\n * `{ {1, 2}, {3, 4} }` — two independent pairs\n * `{ {1, 2, 3} }` — three monitors sync together", + "stripped_doc" : [ + "List of sync groups. Each group is a list of monitor position numbers.", + "Positions are assigned in reading order (left-to-right, top-to-bottom).", + "Monitors not in any group are independent.", + "", + "Default value: `{ {1, 2} }`", + "", + "Examples:", + " * `{ {1, 2} }` — monitors 1 and 2 sync together", + " * `{ {1, 2}, {3, 4} }` — two independent pairs", + " * `{ {1, 2, 3} }` — three monitors sync together" + ], + "def" : "SpacesSync.syncGroups", + "desc" : "List of sync groups. Each group is a list of monitor position numbers.", + "notes" : [ + + ], + "signature" : "SpacesSync.syncGroups", + "type" : "Variable", + "returns" : [ + + ], + "name" : "syncGroups", + "parameters" : [ + + ] + }, + { + "doc" : "Binds hotkeys for SpacesSync.\n\nParameters:\n * mapping - A table containing hotkey modifier\/key details for the following items:\n * toggle - Toggle Space syncing on\/off\n\nReturns:\n * The SpacesSync object\n\nNotes:\n * For a quick setup with defaults, use:\n `spoon.SpacesSync:bindHotkeys(spoon.SpacesSync.defaultHotkeys)`", + "stripped_doc" : [ + "Binds hotkeys for SpacesSync.", + "" + ], + "def" : "SpacesSync:bindHotkeys(mapping)", + "desc" : "Binds hotkeys for SpacesSync.", + "notes" : [ + " * For a quick setup with defaults, use:", + " `spoon.SpacesSync:bindHotkeys(spoon.SpacesSync.defaultHotkeys)`" + ], + "signature" : "SpacesSync:bindHotkeys(mapping)", + "type" : "Method", + "returns" : [ + " * The SpacesSync object", + "" + ], + "name" : "bindHotkeys", + "parameters" : [ + " * mapping - A table containing hotkey modifier\/key details for the following items:", + " * toggle - Toggle Space syncing on\/off", + "" + ] + }, + { + "doc" : "Initializes the SpacesSync spoon. Called automatically by `hs.loadSpoon()`.\nDoes not start syncing — call `:start()` to begin.\n\nParameters:\n * None\n\nReturns:\n * The SpacesSync object", + "stripped_doc" : [ + "Initializes the SpacesSync spoon. Called automatically by `hs.loadSpoon()`.", + "Does not start syncing — call `:start()` to begin.", + "" + ], + "def" : "SpacesSync:init()", + "desc" : "Initializes the SpacesSync spoon. Called automatically by `hs.loadSpoon()`.", + "notes" : [ + + ], + "signature" : "SpacesSync:init()", + "type" : "Method", + "returns" : [ + " * The SpacesSync object" + ], + "name" : "init", + "parameters" : [ + " * None", + "" + ] + }, + { + "doc" : "Returns whether Space syncing is currently active.\n\nParameters:\n * None\n\nReturns:\n * A boolean", + "stripped_doc" : [ + "Returns whether Space syncing is currently active.", + "" + ], + "def" : "SpacesSync:isEnabled()", + "desc" : "Returns whether Space syncing is currently active.", + "notes" : [ + + ], + "signature" : "SpacesSync:isEnabled()", + "type" : "Method", + "returns" : [ + " * A boolean" + ], + "name" : "isEnabled", + "parameters" : [ + " * None", + "" + ] + }, + { + "doc" : "Starts Space syncing.\nChecks macOS version and Mission Control settings, builds the monitor\nposition map, and enables the Space watcher.\n\nParameters:\n * None\n\nReturns:\n * The SpacesSync object", + "stripped_doc" : [ + "Starts Space syncing.", + "Checks macOS version and Mission Control settings, builds the monitor", + "position map, and enables the Space watcher.", + "" + ], + "def" : "SpacesSync:start()", + "desc" : "Starts Space syncing.", + "notes" : [ + + ], + "signature" : "SpacesSync:start()", + "type" : "Method", + "returns" : [ + " * The SpacesSync object" + ], + "name" : "start", + "parameters" : [ + " * None", + "" + ] + }, + { + "doc" : "Stops Space syncing and cleans up watchers and timers.\n\nParameters:\n * None\n\nReturns:\n * The SpacesSync object", + "stripped_doc" : [ + "Stops Space syncing and cleans up watchers and timers.", + "" + ], + "def" : "SpacesSync:stop()", + "desc" : "Stops Space syncing and cleans up watchers and timers.", + "notes" : [ + + ], + "signature" : "SpacesSync:stop()", + "type" : "Method", + "returns" : [ + " * The SpacesSync object" + ], + "name" : "stop", + "parameters" : [ + " * None", + "" + ] + }, + { + "doc" : "Toggles Space syncing on or off.\n\nParameters:\n * None\n\nReturns:\n * The SpacesSync object", + "stripped_doc" : [ + "Toggles Space syncing on or off.", + "" + ], + "def" : "SpacesSync:toggle()", + "desc" : "Toggles Space syncing on or off.", + "notes" : [ + + ], + "signature" : "SpacesSync:toggle()", + "type" : "Method", + "returns" : [ + " * The SpacesSync object" + ], + "name" : "toggle", + "parameters" : [ + " * None", + "" + ] + } + ], + "Command" : [ + + ], + "name" : "SpacesSync" + } +] diff --git a/Source/SpacesSync.spoon/init.lua b/Source/SpacesSync.spoon/init.lua new file mode 100644 index 00000000..bc2f1280 --- /dev/null +++ b/Source/SpacesSync.spoon/init.lua @@ -0,0 +1,624 @@ +--- === SpacesSync === +--- +--- Synchronize macOS Spaces across monitors. +--- +--- When you switch Spaces on one monitor, all other monitors in the same +--- sync group follow to the matching Space index. +--- +--- Monitors are identified by position number (reading order: +--- left-to-right, top-to-bottom). Define sync groups as sets of position +--- numbers. +--- +--- **Requirements:** +--- * macOS Sequoia 15.0+ (uses private `hs.spaces` APIs) +--- * Two or more monitors with multiple Spaces configured +--- * "Displays have separate Spaces" must be ON (System Settings > Desktop & Dock > Mission Control) +--- * "Automatically rearrange Spaces based on most recent use" should be OFF +--- +--- Download: [https://github.com/Hammerspoon/Spoons/raw/master/Spoons/SpacesSync.spoon.zip](https://github.com/Hammerspoon/Spoons/raw/master/Spoons/SpacesSync.spoon.zip) + +local obj = {} +obj.__index = obj + +-- Metadata +obj.name = "SpacesSync" +obj.version = "1.0" +obj.author = "John Randall " +obj.homepage = "https://github.com/johntrandall/hammerspoon-spaces-sync" +obj.license = "MIT - https://opensource.org/licenses/MIT" + +-- Preload extensions to avoid lazy-load latency during sync. +-- require() alone returns Hammerspoon's lazy proxy without loading the +-- Objective-C bridge. Touching one function on each module forces the +-- actual load so it doesn't happen mid-sync. +require("hs.screen"); local _ = hs.screen.allScreens +require("hs.spaces"); _ = hs.spaces.activeSpaces +require("hs.application"); _ = hs.application.frontmostApplication +require("hs.timer"); _ = hs.timer.secondsSinceEpoch + +--- SpacesSync.logger +--- Variable +--- Logger object used within the Spoon. Can be accessed to set the default +--- log level for the messages coming from the Spoon. +--- +--- Default log level: `info`. Set to `debug` for verbose watcher state dumps +--- and per-target dispatch details. Set to `warning` to suppress routine sync +--- messages. +--- +--- Example: +--- ```lua +--- spoon.SpacesSync.logger.setLogLevel('debug') +--- ``` +obj.logger = hs.logger.new('SpacesSync', 'info') + +-- ============================================================================ +-- VERSION REQUIREMENTS +-- ============================================================================ + +local TESTED_OS = { major = 15, minor = 5, patch = 0 } +local MIN_OS_MAJOR = 15 +local TESTED_HS = "1.1.1" + +local function getOSVersion() + local raw = hs.host.operatingSystemVersion() + return { + major = raw.major, + minor = raw.minor, + patch = raw.patch, + str = raw.major .. "." .. raw.minor .. "." .. raw.patch, + } +end + +local function getHSVersion() + return hs.processInfo.version or "unknown" +end + +local function compareVersions(a, b) + local function parts(v) + local t = {} + for n in tostring(v):gmatch("(%d+)") do t[#t + 1] = tonumber(n) end + return t + end + local pa, pb = parts(a), parts(b) + for i = 1, math.max(#pa, #pb) do + local va, vb = pa[i] or 0, pb[i] or 0 + if va < vb then return -1 end + if va > vb then return 1 end + end + return 0 +end + +-- ============================================================================ +-- CONFIGURABLE VARIABLES +-- ============================================================================ + +--- SpacesSync.syncGroups +--- Variable +--- List of sync groups. Each group is a list of monitor position numbers. +--- Positions are assigned in reading order (left-to-right, top-to-bottom). +--- Monitors not in any group are independent. +--- +--- Default value: `{ {1, 2} }` +--- +--- Examples: +--- * `{ {1, 2} }` — monitors 1 and 2 sync together +--- * `{ {1, 2}, {3, 4} }` — two independent pairs +--- * `{ {1, 2, 3} }` — three monitors sync together +obj.syncGroups = { { 1, 2 } } + +--- SpacesSync.switchDelay +--- Variable +--- Delay in seconds between each `hs.spaces.gotoSpace()` call. +--- macOS silently drops rapid back-to-back space switches. +--- +--- Default value: `0.3` +obj.switchDelay = 0.3 + +--- SpacesSync.debounceSeconds +--- Variable +--- Seconds to wait after all switches complete before re-enabling the watcher. +--- Prevents the watcher from reacting to our own programmatic space switches. +--- +--- Default value: `0.8` +obj.debounceSeconds = 0.8 + +--- SpacesSync.defaultHotkeys +--- Variable +--- Default hotkey mapping. Use with `:bindHotkeys()` for a quick setup: +--- +--- ```lua +--- spoon.SpacesSync:bindHotkeys(spoon.SpacesSync.defaultHotkeys) +--- ``` +--- +--- Default value: +--- ```lua +--- { +--- toggle = {{"ctrl", "alt", "cmd"}, "Y"}, +--- } +--- ``` +obj.defaultHotkeys = { + toggle = { {"ctrl", "alt", "cmd"}, "Y" }, +} + +-- ============================================================================ +-- INTERNALS +-- ============================================================================ + +local state = { + enabled = false, + lastActiveSpaces = {}, + syncInProgress = false, + spaceWatcher = nil, + debounceTimer = nil, + pendingSyncTimer = nil, -- tracks the chained doAfter in syncNext + osBlocked = false, +} + +local positionToUUID = {} +local uuidToPosition = {} +local totalScreens = 0 + +-- ============================================================================ +-- POSITION MAP +-- ============================================================================ + +local function rebuildPositionMap() + local screens = hs.screen.allScreens() + local sorted = {} + for _, s in ipairs(screens) do + local f = s:frame() + table.insert(sorted, { uuid = s:getUUID(), x = f.x, y = f.y }) + end + table.sort(sorted, function(a, b) + if a.x ~= b.x then return a.x < b.x end + return a.y < b.y + end) + + positionToUUID = {} + uuidToPosition = {} + totalScreens = #sorted + for i, entry in ipairs(sorted) do + positionToUUID[i] = entry.uuid + uuidToPosition[entry.uuid] = i + end +end + +local function getDisplayLabel(uuid) + local screen = hs.screen.find(uuid) + local name = screen and screen:name() or uuid:sub(1, 8) + local pos = uuidToPosition[uuid] + if pos then + return name .. " [pos " .. pos .. "/" .. totalScreens .. "]" + end + return name +end + +-- ============================================================================ +-- SYNC GROUP LOOKUP +-- ============================================================================ + +local function getTargetsFor(self, triggerUUID) + local pos = uuidToPosition[triggerUUID] + if not pos then return nil end + + for _, group in ipairs(self.syncGroups) do + local inGroup = false + for _, gpos in ipairs(group) do + if gpos == pos then inGroup = true; break end + end + if inGroup then + local targets = {} + for _, gpos in ipairs(group) do + if gpos ~= pos then + local targetUUID = positionToUUID[gpos] + if targetUUID then + table.insert(targets, targetUUID) + else + obj.logger.w("Group references pos " .. gpos .. " but only " .. totalScreens .. " screens connected") + end + end + end + return targets + end + end + return nil +end + +-- ============================================================================ +-- SPACE HELPERS +-- ============================================================================ + +local function getSpaceIndex(uuid, spaceID) + local screen = hs.screen.find(uuid) + if not screen then return nil end + local spaces = hs.spaces.spacesForScreen(screen) + if not spaces then return nil end + for i, sid in ipairs(spaces) do + if sid == spaceID then return i end + end + return nil +end + +local function getSpaceAtIndex(uuid, index) + local screen = hs.screen.find(uuid) + if not screen then return nil end + local spaces = hs.spaces.spacesForScreen(screen) + if not spaces then return nil end + return spaces[index] +end + +local function getSpaceCount(uuid) + local screen = hs.screen.find(uuid) + if not screen then return 0 end + local spaces = hs.spaces.spacesForScreen(screen) + return spaces and #spaces or 0 +end + +-- ============================================================================ +-- SYNC ENGINE +-- ============================================================================ + +local function syncTarget(self, triggerUUID, triggerSpaceID, targetUUID) + local label = getDisplayLabel(targetUUID) + local targetCount = getSpaceCount(targetUUID) + + local triggerIndex = getSpaceIndex(triggerUUID, triggerSpaceID) + if not triggerIndex then + obj.logger.d(" " .. label .. ": SKIP (trigger space index not found)") + return + end + + if triggerIndex > targetCount then + obj.logger.i(" " .. label .. " (" .. targetCount .. " spaces): SKIP (no space at index " .. triggerIndex .. ")") + return + end + + local targetSpaceID = getSpaceAtIndex(targetUUID, triggerIndex) + if not targetSpaceID then + obj.logger.d(" " .. label .. ": SKIP (getSpaceAtIndex returned nil)") + return + end + + local targetScreen = hs.screen.find(targetUUID) + if not targetScreen then + obj.logger.d(" " .. label .. ": SKIP (screen not found)") + return + end + + local currentSpace = hs.spaces.activeSpaceOnScreen(targetScreen) + local currentIdx = getSpaceIndex(targetUUID, currentSpace) or "?" + if currentSpace == targetSpaceID then + obj.logger.d(" " .. label .. ": already at index " .. triggerIndex) + return + end + + obj.logger.i(" " .. label .. ": index " .. tostring(currentIdx) .. " -> " .. triggerIndex) + + local ok, err = pcall(function() + hs.spaces.gotoSpace(targetSpaceID) + end) + + if ok then + obj.logger.d(" " .. label .. ": dispatched") + else + obj.logger.e(" " .. label .. ": ERROR — " .. tostring(err)) + end +end + +-- ============================================================================ +-- WATCHER +-- ============================================================================ + +local function setupWatcher(self) + if state.spaceWatcher then + state.spaceWatcher:stop() + end + + state.lastActiveSpaces = hs.spaces.activeSpaces() or {} + + state.spaceWatcher = hs.spaces.watcher.new(function() + if not state.enabled then return end + if state.syncInProgress then + obj.logger.d("WATCHER: ignored (sync in progress)") + return + end + + local currentSpaces = hs.spaces.activeSpaces() or {} + + do + local parts = {} + for uuid, spaceID in pairs(currentSpaces) do + local idx = getSpaceIndex(uuid, spaceID) or "?" + table.insert(parts, getDisplayLabel(uuid) .. "=idx" .. tostring(idx)) + end + obj.logger.d("WATCHER: " .. table.concat(parts, ", ")) + end + + -- Find which display changed + local changedUUID, changedSpaceID, newIndex + + for uuid, spaceID in pairs(currentSpaces) do + local lastSpaceID = state.lastActiveSpaces[uuid] + if lastSpaceID and lastSpaceID ~= spaceID then + local oi = getSpaceIndex(uuid, lastSpaceID) or "?" + local ni = getSpaceIndex(uuid, spaceID) or "?" + obj.logger.d("CHANGED: " .. getDisplayLabel(uuid) .. " index " .. tostring(oi) .. " -> " .. tostring(ni)) + + if not changedUUID then + changedUUID = uuid + changedSpaceID = spaceID + newIndex = ni + else + obj.logger.d(" (multiple changed; syncing first only)") + end + end + end + + if not changedUUID then + state.lastActiveSpaces = currentSpaces + return + end + + -- Find targets for the triggering monitor + local targets = getTargetsFor(self, changedUUID) + if not targets or #targets == 0 then + obj.logger.d("SKIP: " .. getDisplayLabel(changedUUID) .. " not in any sync group") + state.lastActiveSpaces = currentSpaces + return + end + + local targetNames = {} + for _, uuid in ipairs(targets) do + table.insert(targetNames, getDisplayLabel(uuid)) + end + obj.logger.i("SYNC: " .. getDisplayLabel(changedUUID) .. " (trigger) -> index " .. tostring(newIndex) .. " | targets: " .. table.concat(targetNames, ", ")) + + state.syncInProgress = true + + local function syncNext(i) + if i > #targets then + state.lastActiveSpaces = hs.spaces.activeSpaces() or {} + + do + local parts = {} + for uuid, spaceID in pairs(state.lastActiveSpaces) do + local idx = getSpaceIndex(uuid, spaceID) or "?" + table.insert(parts, getDisplayLabel(uuid) .. "=idx" .. tostring(idx)) + end + obj.logger.d("DONE: " .. table.concat(parts, ", ")) + end + + if state.debounceTimer then state.debounceTimer:stop() end + state.debounceTimer = hs.timer.doAfter(self.debounceSeconds, function() + state.syncInProgress = false + state.lastActiveSpaces = hs.spaces.activeSpaces() or {} + obj.logger.d("Watcher re-enabled") + end) + return + end + + syncTarget(self, changedUUID, changedSpaceID, targets[i]) + state.pendingSyncTimer = hs.timer.doAfter(self.switchDelay, function() + state.pendingSyncTimer = nil + syncNext(i + 1) + end) + end + + syncNext(1) + end) + + state.spaceWatcher:start() + obj.logger.d("Watcher started") +end + +local function stopWatcher() + if state.spaceWatcher then + state.spaceWatcher:stop() + state.spaceWatcher = nil + obj.logger.d("Watcher stopped") + end +end + +-- ============================================================================ +-- ENVIRONMENT CHECKS +-- ============================================================================ + +local function checkEnvironment() + state.osBlocked = false + + -- Check macOS version + local os = getOSVersion() + if os.major < MIN_OS_MAJOR then + obj.logger.e("macOS " .. MIN_OS_MAJOR .. "+ required (you have " .. os.str .. "). Space sync will not activate.") + state.osBlocked = true + else + local testedStr = TESTED_OS.major .. "." .. TESTED_OS.minor .. "." .. TESTED_OS.patch + if os.major ~= TESTED_OS.major or os.minor ~= TESTED_OS.minor or os.patch ~= TESTED_OS.patch then + obj.logger.w("Tested on macOS " .. testedStr .. ", you have " .. os.str .. ". hs.spaces uses private APIs — behavior may differ.") + end + end + + -- Check Hammerspoon version + local hsVer = getHSVersion() + if compareVersions(hsVer, TESTED_HS) < 0 then + obj.logger.w("Tested on Hammerspoon " .. TESTED_HS .. ", you have " .. hsVer .. ". Older versions may behave differently.") + end + + -- Check macOS Mission Control settings + local separateSpaces = hs.execute("defaults read com.apple.spaces spans-displays 2>/dev/null"):gsub("%s+", "") + if separateSpaces == "1" then + obj.logger.e("'Displays have separate Spaces' is OFF. All monitors share one Space — nothing to sync. Enable it in System Settings > Desktop & Dock > Mission Control (requires logout).") + state.osBlocked = true + end + + local mruSpaces = hs.execute("defaults read com.apple.dock mru-spaces 2>/dev/null"):gsub("%s+", "") + if mruSpaces == "1" then + obj.logger.w("'Automatically rearrange Spaces based on most recent use' is ON. This reorders Space indices and will break sync. Disable it in System Settings > Desktop & Dock > Mission Control.") + end +end + +-- ============================================================================ +-- PUBLIC API +-- ============================================================================ + +--- SpacesSync:init() +--- Method +--- Initializes the SpacesSync spoon. Called automatically by `hs.loadSpoon()`. +--- Does not start syncing — call `:start()` to begin. +--- +--- Parameters: +--- * None +--- +--- Returns: +--- * The SpacesSync object +function obj:init() + return self +end + +--- SpacesSync:start() +--- Method +--- Starts Space syncing. +--- Checks macOS version and Mission Control settings, builds the monitor +--- position map, and enables the Space watcher. +--- +--- Parameters: +--- * None +--- +--- Returns: +--- * The SpacesSync object +function obj:start() + obj.logger.i("Starting (SpacesSync " .. self.version .. ")") + + checkEnvironment() + + if state.osBlocked then + obj.logger.e("Environment checks failed. Space sync will not activate.") + hs.alert.show("SpacesSync: blocked (see console)") + return self + end + + rebuildPositionMap() + + -- Log position map + obj.logger.i("Screens (" .. totalScreens .. ", reading order):") + for pos = 1, totalScreens do + local uuid = positionToUUID[pos] + if uuid then + local screen = hs.screen.find(uuid) + local f = screen:frame() + obj.logger.i(" pos " .. pos .. ": " .. screen:name() .. " (x=" .. f.x .. ", y=" .. f.y .. ")") + end + end + + -- Log sync groups + for gi, group in ipairs(self.syncGroups) do + local members = {} + for _, pos in ipairs(group) do + local uuid = positionToUUID[pos] + if uuid then + table.insert(members, "pos " .. pos .. " (" .. hs.screen.find(uuid):name() .. ")") + else + table.insert(members, "pos " .. pos .. " (not connected)") + end + end + obj.logger.i("Group " .. gi .. ": " .. table.concat(members, ", ")) + end + + -- Log independent monitors + for pos = 1, totalScreens do + local uuid = positionToUUID[pos] + if uuid and not getTargetsFor(self, uuid) then + obj.logger.i("Independent: pos " .. pos .. " (" .. hs.screen.find(uuid):name() .. ")") + end + end + + state.enabled = true + state.syncInProgress = false + state.lastActiveSpaces = hs.spaces.activeSpaces() or {} + setupWatcher(self) + hs.alert.show("SpacesSync: ON") + obj.logger.i("Enabled") + + return self +end + +--- SpacesSync:stop() +--- Method +--- Stops Space syncing and cleans up watchers and timers. +--- +--- Parameters: +--- * None +--- +--- Returns: +--- * The SpacesSync object +function obj:stop() + state.enabled = false + state.syncInProgress = false + stopWatcher() + if state.pendingSyncTimer then + state.pendingSyncTimer:stop() + state.pendingSyncTimer = nil + end + if state.debounceTimer then + state.debounceTimer:stop() + state.debounceTimer = nil + end + hs.alert.show("SpacesSync: OFF") + obj.logger.i("Disabled") + return self +end + +--- SpacesSync:toggle() +--- Method +--- Toggles Space syncing on or off. +--- +--- Parameters: +--- * None +--- +--- Returns: +--- * The SpacesSync object +function obj:toggle() + if state.enabled then + self:stop() + else + self:start() + end + return self +end + +--- SpacesSync:isEnabled() +--- Method +--- Returns whether Space syncing is currently active. +--- +--- Parameters: +--- * None +--- +--- Returns: +--- * A boolean +function obj:isEnabled() + return state.enabled +end + +--- SpacesSync:bindHotkeys(mapping) +--- Method +--- Binds hotkeys for SpacesSync. +--- +--- Parameters: +--- * mapping - A table containing hotkey modifier/key details for the following items: +--- * toggle - Toggle Space syncing on/off +--- +--- Returns: +--- * The SpacesSync object +--- +--- Notes: +--- * For a quick setup with defaults, use: +--- `spoon.SpacesSync:bindHotkeys(spoon.SpacesSync.defaultHotkeys)` +function obj:bindHotkeys(mapping) + local def = { + toggle = hs.fnutils.partial(self.toggle, self), + } + hs.spoons.bindHotkeysToSpec(def, mapping) + return self +end + +return obj From 866cdd70de2d75f88b93c0ff27dde3895d56afaa Mon Sep 17 00:00:00 2001 From: John Randall Date: Thu, 9 Apr 2026 19:07:17 -0400 Subject: [PATCH 2/4] Update SpacesSync Spoon with fixes from adversarial review - Make start() idempotent (stop first if already running) - Move extension preloads from module load to start() - Add config validation: type checks, overlap detection, timing warnings - Use obj consistently (remove self from internal functions) - Fix logger docstring for docs.json desc field - Simplify bindHotkeys to use closure instead of fnutils.partial - Restore logger.level guards for debug string building Co-Authored-By: Claude Opus 4.6 (1M context) --- Source/SpacesSync.spoon/docs.json | 382 +++++++++++++++--------------- Source/SpacesSync.spoon/init.lua | 126 +++++++--- 2 files changed, 278 insertions(+), 230 deletions(-) diff --git a/Source/SpacesSync.spoon/docs.json b/Source/SpacesSync.spoon/docs.json index 97a7335f..1b6c2352 100644 --- a/Source/SpacesSync.spoon/docs.json +++ b/Source/SpacesSync.spoon/docs.json @@ -11,10 +11,10 @@ ], "Variable" : [ { - "doc" : "Logger object used within the Spoon. Can be accessed to set the default\nlog level for the messages coming from the Spoon.\n\nDefault log level: `info`. Set to `debug` for verbose watcher state dumps\nand per-target dispatch details. Set to `warning` to suppress routine sync\nmessages.\n\nExample:\n```lua\nspoon.SpacesSync.logger.setLogLevel('debug')\n```", + "desc" : "Logger object used within the Spoon. Set the log level to control verbosity.", + "def" : "SpacesSync.logger", "stripped_doc" : [ - "Logger object used within the Spoon. Can be accessed to set the default", - "log level for the messages coming from the Spoon.", + "Logger object used within the Spoon. Set the log level to control verbosity.", "", "Default log level: `info`. Set to `debug` for verbose watcher state dumps", "and per-target dispatch details. Set to `warning` to suppress routine sync", @@ -25,8 +25,7 @@ "spoon.SpacesSync.logger.setLogLevel('debug')", "```" ], - "def" : "SpacesSync.logger", - "desc" : "Logger object used within the Spoon. Can be accessed to set the default", + "doc" : "Logger object used within the Spoon. Set the log level to control verbosity.\n\nDefault log level: `info`. Set to `debug` for verbose watcher state dumps\nand per-target dispatch details. Set to `warning` to suppress routine sync\nmessages.\n\nExample:\n```lua\nspoon.SpacesSync.logger.setLogLevel('debug')\n```", "notes" : [ ], @@ -41,7 +40,8 @@ ] }, { - "doc" : "List of sync groups. Each group is a list of monitor position numbers.\nPositions are assigned in reading order (left-to-right, top-to-bottom).\nMonitors not in any group are independent.\n\nDefault value: `{ {1, 2} }`\n\nExamples:\n * `{ {1, 2} }` — monitors 1 and 2 sync together\n * `{ {1, 2}, {3, 4} }` — two independent pairs\n * `{ {1, 2, 3} }` — three monitors sync together", + "desc" : "List of sync groups. Each group is a list of monitor position numbers.", + "def" : "SpacesSync.syncGroups", "stripped_doc" : [ "List of sync groups. Each group is a list of monitor position numbers.", "Positions are assigned in reading order (left-to-right, top-to-bottom).", @@ -54,8 +54,7 @@ " * `{ {1, 2}, {3, 4} }` — two independent pairs", " * `{ {1, 2, 3} }` — three monitors sync together" ], - "def" : "SpacesSync.syncGroups", - "desc" : "List of sync groups. Each group is a list of monitor position numbers.", + "doc" : "List of sync groups. Each group is a list of monitor position numbers.\nPositions are assigned in reading order (left-to-right, top-to-bottom).\nMonitors not in any group are independent.\n\nDefault value: `{ {1, 2} }`\n\nExamples:\n * `{ {1, 2} }` — monitors 1 and 2 sync together\n * `{ {1, 2}, {3, 4} }` — two independent pairs\n * `{ {1, 2, 3} }` — three monitors sync together", "notes" : [ ], @@ -70,15 +69,15 @@ ] }, { - "doc" : "Delay in seconds between each `hs.spaces.gotoSpace()` call.\nmacOS silently drops rapid back-to-back space switches.\n\nDefault value: `0.3`", + "desc" : "Delay in seconds between each `hs.spaces.gotoSpace()` call.", + "def" : "SpacesSync.switchDelay", "stripped_doc" : [ "Delay in seconds between each `hs.spaces.gotoSpace()` call.", "macOS silently drops rapid back-to-back space switches.", "", "Default value: `0.3`" ], - "def" : "SpacesSync.switchDelay", - "desc" : "Delay in seconds between each `hs.spaces.gotoSpace()` call.", + "doc" : "Delay in seconds between each `hs.spaces.gotoSpace()` call.\nmacOS silently drops rapid back-to-back space switches.\n\nDefault value: `0.3`", "notes" : [ ], @@ -93,15 +92,15 @@ ] }, { - "doc" : "Seconds to wait after all switches complete before re-enabling the watcher.\nPrevents the watcher from reacting to our own programmatic space switches.\n\nDefault value: `0.8`", + "desc" : "Seconds to wait after all switches complete before re-enabling the watcher.", + "def" : "SpacesSync.debounceSeconds", "stripped_doc" : [ "Seconds to wait after all switches complete before re-enabling the watcher.", "Prevents the watcher from reacting to our own programmatic space switches.", "", "Default value: `0.8`" ], - "def" : "SpacesSync.debounceSeconds", - "desc" : "Seconds to wait after all switches complete before re-enabling the watcher.", + "doc" : "Seconds to wait after all switches complete before re-enabling the watcher.\nPrevents the watcher from reacting to our own programmatic space switches.\n\nDefault value: `0.8`", "notes" : [ ], @@ -116,7 +115,8 @@ ] }, { - "doc" : "Default hotkey mapping. Use with `:bindHotkeys()` for a quick setup:\n\n```lua\nspoon.SpacesSync:bindHotkeys(spoon.SpacesSync.defaultHotkeys)\n```\n\nDefault value:\n```lua\n{\n toggle = {{\"ctrl\", \"alt\", \"cmd\"}, \"Y\"},\n}\n```", + "desc" : "Default hotkey mapping. Use with `:bindHotkeys()` for a quick setup:", + "def" : "SpacesSync.defaultHotkeys", "stripped_doc" : [ "Default hotkey mapping. Use with `:bindHotkeys()` for a quick setup:", "", @@ -131,8 +131,7 @@ "}", "```" ], - "def" : "SpacesSync.defaultHotkeys", - "desc" : "Default hotkey mapping. Use with `:bindHotkeys()` for a quick setup:", + "doc" : "Default hotkey mapping. Use with `:bindHotkeys()` for a quick setup:\n\n```lua\nspoon.SpacesSync:bindHotkeys(spoon.SpacesSync.defaultHotkeys)\n```\n\nDefault value:\n```lua\n{\n toggle = {{\"ctrl\", \"alt\", \"cmd\"}, \"Y\"},\n}\n```", "notes" : [ ], @@ -150,169 +149,29 @@ "stripped_doc" : [ ], + "desc" : "Synchronize macOS Spaces across monitors.", "Deprecated" : [ ], "type" : "Module", - "desc" : "Synchronize macOS Spaces across monitors.", "Constructor" : [ ], "doc" : "Synchronize macOS Spaces across monitors.\n\nWhen you switch Spaces on one monitor, all other monitors in the same\nsync group follow to the matching Space index.\n\nMonitors are identified by position number (reading order:\nleft-to-right, top-to-bottom). Define sync groups as sets of position\nnumbers.\n\n**Requirements:**\n * macOS Sequoia 15.0+ (uses private `hs.spaces` APIs)\n * Two or more monitors with multiple Spaces configured\n * \"Displays have separate Spaces\" must be ON (System Settings > Desktop & Dock > Mission Control)\n * \"Automatically rearrange Spaces based on most recent use\" should be OFF\n\nDownload: [https:\/\/github.com\/Hammerspoon\/Spoons\/raw\/master\/Spoons\/SpacesSync.spoon.zip](https:\/\/github.com\/Hammerspoon\/Spoons\/raw\/master\/Spoons\/SpacesSync.spoon.zip)", - "Method" : [ - { - "doc" : "Initializes the SpacesSync spoon. Called automatically by `hs.loadSpoon()`.\nDoes not start syncing — call `:start()` to begin.\n\nParameters:\n * None\n\nReturns:\n * The SpacesSync object", - "stripped_doc" : [ - "Initializes the SpacesSync spoon. Called automatically by `hs.loadSpoon()`.", - "Does not start syncing — call `:start()` to begin.", - "" - ], - "def" : "SpacesSync:init()", - "desc" : "Initializes the SpacesSync spoon. Called automatically by `hs.loadSpoon()`.", - "notes" : [ - - ], - "signature" : "SpacesSync:init()", - "type" : "Method", - "returns" : [ - " * The SpacesSync object" - ], - "name" : "init", - "parameters" : [ - " * None", - "" - ] - }, - { - "doc" : "Starts Space syncing.\nChecks macOS version and Mission Control settings, builds the monitor\nposition map, and enables the Space watcher.\n\nParameters:\n * None\n\nReturns:\n * The SpacesSync object", - "stripped_doc" : [ - "Starts Space syncing.", - "Checks macOS version and Mission Control settings, builds the monitor", - "position map, and enables the Space watcher.", - "" - ], - "def" : "SpacesSync:start()", - "desc" : "Starts Space syncing.", - "notes" : [ - - ], - "signature" : "SpacesSync:start()", - "type" : "Method", - "returns" : [ - " * The SpacesSync object" - ], - "name" : "start", - "parameters" : [ - " * None", - "" - ] - }, - { - "doc" : "Stops Space syncing and cleans up watchers and timers.\n\nParameters:\n * None\n\nReturns:\n * The SpacesSync object", - "stripped_doc" : [ - "Stops Space syncing and cleans up watchers and timers.", - "" - ], - "def" : "SpacesSync:stop()", - "desc" : "Stops Space syncing and cleans up watchers and timers.", - "notes" : [ - - ], - "signature" : "SpacesSync:stop()", - "type" : "Method", - "returns" : [ - " * The SpacesSync object" - ], - "name" : "stop", - "parameters" : [ - " * None", - "" - ] - }, - { - "doc" : "Toggles Space syncing on or off.\n\nParameters:\n * None\n\nReturns:\n * The SpacesSync object", - "stripped_doc" : [ - "Toggles Space syncing on or off.", - "" - ], - "def" : "SpacesSync:toggle()", - "desc" : "Toggles Space syncing on or off.", - "notes" : [ - - ], - "signature" : "SpacesSync:toggle()", - "type" : "Method", - "returns" : [ - " * The SpacesSync object" - ], - "name" : "toggle", - "parameters" : [ - " * None", - "" - ] - }, - { - "doc" : "Returns whether Space syncing is currently active.\n\nParameters:\n * None\n\nReturns:\n * A boolean", - "stripped_doc" : [ - "Returns whether Space syncing is currently active.", - "" - ], - "def" : "SpacesSync:isEnabled()", - "desc" : "Returns whether Space syncing is currently active.", - "notes" : [ - - ], - "signature" : "SpacesSync:isEnabled()", - "type" : "Method", - "returns" : [ - " * A boolean" - ], - "name" : "isEnabled", - "parameters" : [ - " * None", - "" - ] - }, - { - "doc" : "Binds hotkeys for SpacesSync.\n\nParameters:\n * mapping - A table containing hotkey modifier\/key details for the following items:\n * toggle - Toggle Space syncing on\/off\n\nReturns:\n * The SpacesSync object\n\nNotes:\n * For a quick setup with defaults, use:\n `spoon.SpacesSync:bindHotkeys(spoon.SpacesSync.defaultHotkeys)`", - "stripped_doc" : [ - "Binds hotkeys for SpacesSync.", - "" - ], - "def" : "SpacesSync:bindHotkeys(mapping)", - "desc" : "Binds hotkeys for SpacesSync.", - "notes" : [ - " * For a quick setup with defaults, use:", - " `spoon.SpacesSync:bindHotkeys(spoon.SpacesSync.defaultHotkeys)`" - ], - "signature" : "SpacesSync:bindHotkeys(mapping)", - "type" : "Method", - "returns" : [ - " * The SpacesSync object", - "" - ], - "name" : "bindHotkeys", - "parameters" : [ - " * mapping - A table containing hotkey modifier\/key details for the following items:", - " * toggle - Toggle Space syncing on\/off", - "" - ] - } - ], "Field" : [ ], "items" : [ { - "doc" : "Seconds to wait after all switches complete before re-enabling the watcher.\nPrevents the watcher from reacting to our own programmatic space switches.\n\nDefault value: `0.8`", + "desc" : "Seconds to wait after all switches complete before re-enabling the watcher.", + "def" : "SpacesSync.debounceSeconds", "stripped_doc" : [ "Seconds to wait after all switches complete before re-enabling the watcher.", "Prevents the watcher from reacting to our own programmatic space switches.", "", "Default value: `0.8`" ], - "def" : "SpacesSync.debounceSeconds", - "desc" : "Seconds to wait after all switches complete before re-enabling the watcher.", + "doc" : "Seconds to wait after all switches complete before re-enabling the watcher.\nPrevents the watcher from reacting to our own programmatic space switches.\n\nDefault value: `0.8`", "notes" : [ ], @@ -327,7 +186,8 @@ ] }, { - "doc" : "Default hotkey mapping. Use with `:bindHotkeys()` for a quick setup:\n\n```lua\nspoon.SpacesSync:bindHotkeys(spoon.SpacesSync.defaultHotkeys)\n```\n\nDefault value:\n```lua\n{\n toggle = {{\"ctrl\", \"alt\", \"cmd\"}, \"Y\"},\n}\n```", + "desc" : "Default hotkey mapping. Use with `:bindHotkeys()` for a quick setup:", + "def" : "SpacesSync.defaultHotkeys", "stripped_doc" : [ "Default hotkey mapping. Use with `:bindHotkeys()` for a quick setup:", "", @@ -342,8 +202,7 @@ "}", "```" ], - "def" : "SpacesSync.defaultHotkeys", - "desc" : "Default hotkey mapping. Use with `:bindHotkeys()` for a quick setup:", + "doc" : "Default hotkey mapping. Use with `:bindHotkeys()` for a quick setup:\n\n```lua\nspoon.SpacesSync:bindHotkeys(spoon.SpacesSync.defaultHotkeys)\n```\n\nDefault value:\n```lua\n{\n toggle = {{\"ctrl\", \"alt\", \"cmd\"}, \"Y\"},\n}\n```", "notes" : [ ], @@ -358,10 +217,10 @@ ] }, { - "doc" : "Logger object used within the Spoon. Can be accessed to set the default\nlog level for the messages coming from the Spoon.\n\nDefault log level: `info`. Set to `debug` for verbose watcher state dumps\nand per-target dispatch details. Set to `warning` to suppress routine sync\nmessages.\n\nExample:\n```lua\nspoon.SpacesSync.logger.setLogLevel('debug')\n```", + "desc" : "Logger object used within the Spoon. Set the log level to control verbosity.", + "def" : "SpacesSync.logger", "stripped_doc" : [ - "Logger object used within the Spoon. Can be accessed to set the default", - "log level for the messages coming from the Spoon.", + "Logger object used within the Spoon. Set the log level to control verbosity.", "", "Default log level: `info`. Set to `debug` for verbose watcher state dumps", "and per-target dispatch details. Set to `warning` to suppress routine sync", @@ -372,8 +231,7 @@ "spoon.SpacesSync.logger.setLogLevel('debug')", "```" ], - "def" : "SpacesSync.logger", - "desc" : "Logger object used within the Spoon. Can be accessed to set the default", + "doc" : "Logger object used within the Spoon. Set the log level to control verbosity.\n\nDefault log level: `info`. Set to `debug` for verbose watcher state dumps\nand per-target dispatch details. Set to `warning` to suppress routine sync\nmessages.\n\nExample:\n```lua\nspoon.SpacesSync.logger.setLogLevel('debug')\n```", "notes" : [ ], @@ -388,15 +246,15 @@ ] }, { - "doc" : "Delay in seconds between each `hs.spaces.gotoSpace()` call.\nmacOS silently drops rapid back-to-back space switches.\n\nDefault value: `0.3`", + "desc" : "Delay in seconds between each `hs.spaces.gotoSpace()` call.", + "def" : "SpacesSync.switchDelay", "stripped_doc" : [ "Delay in seconds between each `hs.spaces.gotoSpace()` call.", "macOS silently drops rapid back-to-back space switches.", "", "Default value: `0.3`" ], - "def" : "SpacesSync.switchDelay", - "desc" : "Delay in seconds between each `hs.spaces.gotoSpace()` call.", + "doc" : "Delay in seconds between each `hs.spaces.gotoSpace()` call.\nmacOS silently drops rapid back-to-back space switches.\n\nDefault value: `0.3`", "notes" : [ ], @@ -411,7 +269,8 @@ ] }, { - "doc" : "List of sync groups. Each group is a list of monitor position numbers.\nPositions are assigned in reading order (left-to-right, top-to-bottom).\nMonitors not in any group are independent.\n\nDefault value: `{ {1, 2} }`\n\nExamples:\n * `{ {1, 2} }` — monitors 1 and 2 sync together\n * `{ {1, 2}, {3, 4} }` — two independent pairs\n * `{ {1, 2, 3} }` — three monitors sync together", + "desc" : "List of sync groups. Each group is a list of monitor position numbers.", + "def" : "SpacesSync.syncGroups", "stripped_doc" : [ "List of sync groups. Each group is a list of monitor position numbers.", "Positions are assigned in reading order (left-to-right, top-to-bottom).", @@ -424,8 +283,7 @@ " * `{ {1, 2}, {3, 4} }` — two independent pairs", " * `{ {1, 2, 3} }` — three monitors sync together" ], - "def" : "SpacesSync.syncGroups", - "desc" : "List of sync groups. Each group is a list of monitor position numbers.", + "doc" : "List of sync groups. Each group is a list of monitor position numbers.\nPositions are assigned in reading order (left-to-right, top-to-bottom).\nMonitors not in any group are independent.\n\nDefault value: `{ {1, 2} }`\n\nExamples:\n * `{ {1, 2} }` — monitors 1 and 2 sync together\n * `{ {1, 2}, {3, 4} }` — two independent pairs\n * `{ {1, 2, 3} }` — three monitors sync together", "notes" : [ ], @@ -440,13 +298,13 @@ ] }, { - "doc" : "Binds hotkeys for SpacesSync.\n\nParameters:\n * mapping - A table containing hotkey modifier\/key details for the following items:\n * toggle - Toggle Space syncing on\/off\n\nReturns:\n * The SpacesSync object\n\nNotes:\n * For a quick setup with defaults, use:\n `spoon.SpacesSync:bindHotkeys(spoon.SpacesSync.defaultHotkeys)`", + "desc" : "Binds hotkeys for SpacesSync.", + "def" : "SpacesSync:bindHotkeys(mapping)", "stripped_doc" : [ "Binds hotkeys for SpacesSync.", "" ], - "def" : "SpacesSync:bindHotkeys(mapping)", - "desc" : "Binds hotkeys for SpacesSync.", + "doc" : "Binds hotkeys for SpacesSync.\n\nParameters:\n * mapping - A table containing hotkey modifier\/key details for the following items:\n * toggle - Toggle Space syncing on\/off\n\nReturns:\n * The SpacesSync object\n\nNotes:\n * For a quick setup with defaults, use:\n `spoon.SpacesSync:bindHotkeys(spoon.SpacesSync.defaultHotkeys)`", "notes" : [ " * For a quick setup with defaults, use:", " `spoon.SpacesSync:bindHotkeys(spoon.SpacesSync.defaultHotkeys)`" @@ -465,14 +323,14 @@ ] }, { - "doc" : "Initializes the SpacesSync spoon. Called automatically by `hs.loadSpoon()`.\nDoes not start syncing — call `:start()` to begin.\n\nParameters:\n * None\n\nReturns:\n * The SpacesSync object", + "desc" : "Initializes the SpacesSync spoon. Called automatically by `hs.loadSpoon()`.", + "def" : "SpacesSync:init()", "stripped_doc" : [ "Initializes the SpacesSync spoon. Called automatically by `hs.loadSpoon()`.", "Does not start syncing — call `:start()` to begin.", "" ], - "def" : "SpacesSync:init()", - "desc" : "Initializes the SpacesSync spoon. Called automatically by `hs.loadSpoon()`.", + "doc" : "Initializes the SpacesSync spoon. Called automatically by `hs.loadSpoon()`.\nDoes not start syncing — call `:start()` to begin.\n\nParameters:\n * None\n\nReturns:\n * The SpacesSync object", "notes" : [ ], @@ -488,13 +346,13 @@ ] }, { - "doc" : "Returns whether Space syncing is currently active.\n\nParameters:\n * None\n\nReturns:\n * A boolean", + "desc" : "Returns whether Space syncing is currently active.", + "def" : "SpacesSync:isEnabled()", "stripped_doc" : [ "Returns whether Space syncing is currently active.", "" ], - "def" : "SpacesSync:isEnabled()", - "desc" : "Returns whether Space syncing is currently active.", + "doc" : "Returns whether Space syncing is currently active.\n\nParameters:\n * None\n\nReturns:\n * A boolean", "notes" : [ ], @@ -510,15 +368,15 @@ ] }, { - "doc" : "Starts Space syncing.\nChecks macOS version and Mission Control settings, builds the monitor\nposition map, and enables the Space watcher.\n\nParameters:\n * None\n\nReturns:\n * The SpacesSync object", + "desc" : "Starts Space syncing.", + "def" : "SpacesSync:start()", "stripped_doc" : [ "Starts Space syncing.", "Checks macOS version and Mission Control settings, builds the monitor", "position map, and enables the Space watcher.", "" ], - "def" : "SpacesSync:start()", - "desc" : "Starts Space syncing.", + "doc" : "Starts Space syncing.\nChecks macOS version and Mission Control settings, builds the monitor\nposition map, and enables the Space watcher.\n\nParameters:\n * None\n\nReturns:\n * The SpacesSync object", "notes" : [ ], @@ -534,13 +392,13 @@ ] }, { - "doc" : "Stops Space syncing and cleans up watchers and timers.\n\nParameters:\n * None\n\nReturns:\n * The SpacesSync object", + "desc" : "Stops Space syncing and cleans up watchers and timers.", + "def" : "SpacesSync:stop()", "stripped_doc" : [ "Stops Space syncing and cleans up watchers and timers.", "" ], - "def" : "SpacesSync:stop()", - "desc" : "Stops Space syncing and cleans up watchers and timers.", + "doc" : "Stops Space syncing and cleans up watchers and timers.\n\nParameters:\n * None\n\nReturns:\n * The SpacesSync object", "notes" : [ ], @@ -556,13 +414,106 @@ ] }, { - "doc" : "Toggles Space syncing on or off.\n\nParameters:\n * None\n\nReturns:\n * The SpacesSync object", + "desc" : "Toggles Space syncing on or off.", + "def" : "SpacesSync:toggle()", "stripped_doc" : [ "Toggles Space syncing on or off.", "" ], - "def" : "SpacesSync:toggle()", + "doc" : "Toggles Space syncing on or off.\n\nParameters:\n * None\n\nReturns:\n * The SpacesSync object", + "notes" : [ + + ], + "signature" : "SpacesSync:toggle()", + "type" : "Method", + "returns" : [ + " * The SpacesSync object" + ], + "name" : "toggle", + "parameters" : [ + " * None", + "" + ] + } + ], + "Method" : [ + { + "desc" : "Initializes the SpacesSync spoon. Called automatically by `hs.loadSpoon()`.", + "def" : "SpacesSync:init()", + "stripped_doc" : [ + "Initializes the SpacesSync spoon. Called automatically by `hs.loadSpoon()`.", + "Does not start syncing — call `:start()` to begin.", + "" + ], + "doc" : "Initializes the SpacesSync spoon. Called automatically by `hs.loadSpoon()`.\nDoes not start syncing — call `:start()` to begin.\n\nParameters:\n * None\n\nReturns:\n * The SpacesSync object", + "notes" : [ + + ], + "signature" : "SpacesSync:init()", + "type" : "Method", + "returns" : [ + " * The SpacesSync object" + ], + "name" : "init", + "parameters" : [ + " * None", + "" + ] + }, + { + "desc" : "Starts Space syncing.", + "def" : "SpacesSync:start()", + "stripped_doc" : [ + "Starts Space syncing.", + "Checks macOS version and Mission Control settings, builds the monitor", + "position map, and enables the Space watcher.", + "" + ], + "doc" : "Starts Space syncing.\nChecks macOS version and Mission Control settings, builds the monitor\nposition map, and enables the Space watcher.\n\nParameters:\n * None\n\nReturns:\n * The SpacesSync object", + "notes" : [ + + ], + "signature" : "SpacesSync:start()", + "type" : "Method", + "returns" : [ + " * The SpacesSync object" + ], + "name" : "start", + "parameters" : [ + " * None", + "" + ] + }, + { + "desc" : "Stops Space syncing and cleans up watchers and timers.", + "def" : "SpacesSync:stop()", + "stripped_doc" : [ + "Stops Space syncing and cleans up watchers and timers.", + "" + ], + "doc" : "Stops Space syncing and cleans up watchers and timers.\n\nParameters:\n * None\n\nReturns:\n * The SpacesSync object", + "notes" : [ + + ], + "signature" : "SpacesSync:stop()", + "type" : "Method", + "returns" : [ + " * The SpacesSync object" + ], + "name" : "stop", + "parameters" : [ + " * None", + "" + ] + }, + { "desc" : "Toggles Space syncing on or off.", + "def" : "SpacesSync:toggle()", + "stripped_doc" : [ + "Toggles Space syncing on or off.", + "" + ], + "doc" : "Toggles Space syncing on or off.\n\nParameters:\n * None\n\nReturns:\n * The SpacesSync object", "notes" : [ ], @@ -576,6 +527,53 @@ " * None", "" ] + }, + { + "desc" : "Returns whether Space syncing is currently active.", + "def" : "SpacesSync:isEnabled()", + "stripped_doc" : [ + "Returns whether Space syncing is currently active.", + "" + ], + "doc" : "Returns whether Space syncing is currently active.\n\nParameters:\n * None\n\nReturns:\n * A boolean", + "notes" : [ + + ], + "signature" : "SpacesSync:isEnabled()", + "type" : "Method", + "returns" : [ + " * A boolean" + ], + "name" : "isEnabled", + "parameters" : [ + " * None", + "" + ] + }, + { + "desc" : "Binds hotkeys for SpacesSync.", + "def" : "SpacesSync:bindHotkeys(mapping)", + "stripped_doc" : [ + "Binds hotkeys for SpacesSync.", + "" + ], + "doc" : "Binds hotkeys for SpacesSync.\n\nParameters:\n * mapping - A table containing hotkey modifier\/key details for the following items:\n * toggle - Toggle Space syncing on\/off\n\nReturns:\n * The SpacesSync object\n\nNotes:\n * For a quick setup with defaults, use:\n `spoon.SpacesSync:bindHotkeys(spoon.SpacesSync.defaultHotkeys)`", + "notes" : [ + " * For a quick setup with defaults, use:", + " `spoon.SpacesSync:bindHotkeys(spoon.SpacesSync.defaultHotkeys)`" + ], + "signature" : "SpacesSync:bindHotkeys(mapping)", + "type" : "Method", + "returns" : [ + " * The SpacesSync object", + "" + ], + "name" : "bindHotkeys", + "parameters" : [ + " * mapping - A table containing hotkey modifier\/key details for the following items:", + " * toggle - Toggle Space syncing on\/off", + "" + ] } ], "Command" : [ diff --git a/Source/SpacesSync.spoon/init.lua b/Source/SpacesSync.spoon/init.lua index bc2f1280..3bab766e 100644 --- a/Source/SpacesSync.spoon/init.lua +++ b/Source/SpacesSync.spoon/init.lua @@ -27,19 +27,9 @@ obj.author = "John Randall " obj.homepage = "https://github.com/johntrandall/hammerspoon-spaces-sync" obj.license = "MIT - https://opensource.org/licenses/MIT" --- Preload extensions to avoid lazy-load latency during sync. --- require() alone returns Hammerspoon's lazy proxy without loading the --- Objective-C bridge. Touching one function on each module forces the --- actual load so it doesn't happen mid-sync. -require("hs.screen"); local _ = hs.screen.allScreens -require("hs.spaces"); _ = hs.spaces.activeSpaces -require("hs.application"); _ = hs.application.frontmostApplication -require("hs.timer"); _ = hs.timer.secondsSinceEpoch - --- SpacesSync.logger --- Variable ---- Logger object used within the Spoon. Can be accessed to set the default ---- log level for the messages coming from the Spoon. +--- Logger object used within the Spoon. Set the log level to control verbosity. --- --- Default log level: `info`. Set to `debug` for verbose watcher state dumps --- and per-target dispatch details. Set to `warning` to suppress routine sync @@ -197,11 +187,11 @@ end -- SYNC GROUP LOOKUP -- ============================================================================ -local function getTargetsFor(self, triggerUUID) +local function getTargetsFor(triggerUUID) local pos = uuidToPosition[triggerUUID] if not pos then return nil end - for _, group in ipairs(self.syncGroups) do + for _, group in ipairs(obj.syncGroups) do local inGroup = false for _, gpos in ipairs(group) do if gpos == pos then inGroup = true; break end @@ -258,7 +248,7 @@ end -- SYNC ENGINE -- ============================================================================ -local function syncTarget(self, triggerUUID, triggerSpaceID, targetUUID) +local function syncTarget(triggerUUID, triggerSpaceID, targetUUID) local label = getDisplayLabel(targetUUID) local targetCount = getSpaceCount(targetUUID) @@ -309,7 +299,7 @@ end -- WATCHER -- ============================================================================ -local function setupWatcher(self) +local function setupWatcher() if state.spaceWatcher then state.spaceWatcher:stop() end @@ -325,7 +315,7 @@ local function setupWatcher(self) local currentSpaces = hs.spaces.activeSpaces() or {} - do + if obj.logger.level >= 4 then -- debug level local parts = {} for uuid, spaceID in pairs(currentSpaces) do local idx = getSpaceIndex(uuid, spaceID) or "?" @@ -360,7 +350,7 @@ local function setupWatcher(self) end -- Find targets for the triggering monitor - local targets = getTargetsFor(self, changedUUID) + local targets = getTargetsFor(changedUUID) if not targets or #targets == 0 then obj.logger.d("SKIP: " .. getDisplayLabel(changedUUID) .. " not in any sync group") state.lastActiveSpaces = currentSpaces @@ -368,8 +358,8 @@ local function setupWatcher(self) end local targetNames = {} - for _, uuid in ipairs(targets) do - table.insert(targetNames, getDisplayLabel(uuid)) + for _, targetUUID in ipairs(targets) do + table.insert(targetNames, getDisplayLabel(targetUUID)) end obj.logger.i("SYNC: " .. getDisplayLabel(changedUUID) .. " (trigger) -> index " .. tostring(newIndex) .. " | targets: " .. table.concat(targetNames, ", ")) @@ -379,7 +369,7 @@ local function setupWatcher(self) if i > #targets then state.lastActiveSpaces = hs.spaces.activeSpaces() or {} - do + if obj.logger.level >= 4 then -- debug level local parts = {} for uuid, spaceID in pairs(state.lastActiveSpaces) do local idx = getSpaceIndex(uuid, spaceID) or "?" @@ -389,7 +379,7 @@ local function setupWatcher(self) end if state.debounceTimer then state.debounceTimer:stop() end - state.debounceTimer = hs.timer.doAfter(self.debounceSeconds, function() + state.debounceTimer = hs.timer.doAfter(obj.debounceSeconds, function() state.syncInProgress = false state.lastActiveSpaces = hs.spaces.activeSpaces() or {} obj.logger.d("Watcher re-enabled") @@ -397,8 +387,8 @@ local function setupWatcher(self) return end - syncTarget(self, changedUUID, changedSpaceID, targets[i]) - state.pendingSyncTimer = hs.timer.doAfter(self.switchDelay, function() + syncTarget(changedUUID, changedSpaceID, targets[i]) + state.pendingSyncTimer = hs.timer.doAfter(obj.switchDelay, function() state.pendingSyncTimer = nil syncNext(i + 1) end) @@ -427,14 +417,14 @@ local function checkEnvironment() state.osBlocked = false -- Check macOS version - local os = getOSVersion() - if os.major < MIN_OS_MAJOR then - obj.logger.e("macOS " .. MIN_OS_MAJOR .. "+ required (you have " .. os.str .. "). Space sync will not activate.") + local osVer = getOSVersion() + if osVer.major < MIN_OS_MAJOR then + obj.logger.e("macOS " .. MIN_OS_MAJOR .. "+ required (you have " .. osVer.str .. "). Space sync will not activate.") state.osBlocked = true else local testedStr = TESTED_OS.major .. "." .. TESTED_OS.minor .. "." .. TESTED_OS.patch - if os.major ~= TESTED_OS.major or os.minor ~= TESTED_OS.minor or os.patch ~= TESTED_OS.patch then - obj.logger.w("Tested on macOS " .. testedStr .. ", you have " .. os.str .. ". hs.spaces uses private APIs — behavior may differ.") + if osVer.major ~= TESTED_OS.major or osVer.minor ~= TESTED_OS.minor or osVer.patch ~= TESTED_OS.patch then + obj.logger.w("Tested on macOS " .. testedStr .. ", you have " .. osVer.str .. ". hs.spaces uses private APIs — behavior may differ.") end end @@ -487,7 +477,69 @@ end --- Returns: --- * The SpacesSync object function obj:start() - obj.logger.i("Starting (SpacesSync " .. self.version .. ")") + -- Clean up any in-flight state from a previous start() + if state.enabled then + self:stop() + end + + -- Preload extensions to avoid lazy-load latency during sync. + -- require() alone returns Hammerspoon's lazy proxy without loading the + -- Objective-C bridge. Touching one function on each module forces the + -- actual load so it doesn't happen mid-sync. + require("hs.screen"); local _ = hs.screen.allScreens + require("hs.spaces"); _ = hs.spaces.activeSpaces + -- hs.application is loaded as a transitive dependency of hs.spaces.gotoSpace() + require("hs.application"); _ = hs.application.frontmostApplication + require("hs.timer"); _ = hs.timer.secondsSinceEpoch + + obj.logger.i("Starting (SpacesSync " .. obj.version .. ")") + + -- Validate configuration + if type(obj.syncGroups) ~= "table" then + obj.logger.e("syncGroups must be a table, got " .. type(obj.syncGroups)) + return self + end + for gi, group in ipairs(obj.syncGroups) do + if type(group) ~= "table" then + obj.logger.e("syncGroups[" .. gi .. "] must be a table, got " .. type(group)) + return self + end + if #group < 2 then + obj.logger.w("syncGroups[" .. gi .. "] has " .. #group .. " member(s) — need at least 2 to sync") + end + -- Check for overlapping groups + for _, pos in ipairs(group) do + if type(pos) ~= "number" or pos < 1 or pos ~= math.floor(pos) then + obj.logger.e("syncGroups[" .. gi .. "] contains invalid position: " .. tostring(pos) .. " (must be a positive integer)") + return self + end + end + end + -- Detect overlapping groups (same position in multiple groups) + local positionSeen = {} + for gi, group in ipairs(obj.syncGroups) do + for _, pos in ipairs(group) do + if positionSeen[pos] then + obj.logger.w("Position " .. pos .. " appears in group " .. positionSeen[pos] .. " and group " .. gi .. " — only the first group will be used for triggers from this monitor") + else + positionSeen[pos] = gi + end + end + end + if type(obj.switchDelay) ~= "number" or obj.switchDelay < 0 then + obj.logger.e("switchDelay must be a non-negative number, got " .. tostring(obj.switchDelay)) + return self + end + if obj.switchDelay < 0.1 then + obj.logger.w("switchDelay=" .. obj.switchDelay .. "s — macOS may drop rapid gotoSpace() calls (0.3s recommended)") + end + if type(obj.debounceSeconds) ~= "number" or obj.debounceSeconds < 0 then + obj.logger.e("debounceSeconds must be a non-negative number, got " .. tostring(obj.debounceSeconds)) + return self + end + if obj.debounceSeconds < 0.3 then + obj.logger.w("debounceSeconds=" .. obj.debounceSeconds .. "s — watcher may react to its own switches (0.8s recommended)") + end checkEnvironment() @@ -504,19 +556,17 @@ function obj:start() for pos = 1, totalScreens do local uuid = positionToUUID[pos] if uuid then - local screen = hs.screen.find(uuid) - local f = screen:frame() - obj.logger.i(" pos " .. pos .. ": " .. screen:name() .. " (x=" .. f.x .. ", y=" .. f.y .. ")") + obj.logger.i(" pos " .. pos .. ": " .. getDisplayLabel(uuid)) end end -- Log sync groups - for gi, group in ipairs(self.syncGroups) do + for gi, group in ipairs(obj.syncGroups) do local members = {} for _, pos in ipairs(group) do local uuid = positionToUUID[pos] if uuid then - table.insert(members, "pos " .. pos .. " (" .. hs.screen.find(uuid):name() .. ")") + table.insert(members, "pos " .. pos .. " (" .. getDisplayLabel(uuid) .. ")") else table.insert(members, "pos " .. pos .. " (not connected)") end @@ -527,15 +577,15 @@ function obj:start() -- Log independent monitors for pos = 1, totalScreens do local uuid = positionToUUID[pos] - if uuid and not getTargetsFor(self, uuid) then - obj.logger.i("Independent: pos " .. pos .. " (" .. hs.screen.find(uuid):name() .. ")") + if uuid and not getTargetsFor(uuid) then + obj.logger.i("Independent: pos " .. pos .. " (" .. getDisplayLabel(uuid) .. ")") end end state.enabled = true state.syncInProgress = false state.lastActiveSpaces = hs.spaces.activeSpaces() or {} - setupWatcher(self) + setupWatcher() hs.alert.show("SpacesSync: ON") obj.logger.i("Enabled") @@ -615,7 +665,7 @@ end --- `spoon.SpacesSync:bindHotkeys(spoon.SpacesSync.defaultHotkeys)` function obj:bindHotkeys(mapping) local def = { - toggle = hs.fnutils.partial(self.toggle, self), + toggle = function() self:toggle() end, } hs.spoons.bindHotkeysToSpec(def, mapping) return self From 833ee0eab060d171dbf368bcad0e4509970dad64 Mon Sep 17 00:00:00 2001 From: John Randall Date: Thu, 9 Apr 2026 22:30:45 -0400 Subject: [PATCH 3/4] Update SpacesSync to v0.1 Co-Authored-By: Claude Opus 4.6 (1M context) --- Source/SpacesSync.spoon/docs.json | 584 ------------------------------ Source/SpacesSync.spoon/init.lua | 2 +- 2 files changed, 1 insertion(+), 585 deletions(-) diff --git a/Source/SpacesSync.spoon/docs.json b/Source/SpacesSync.spoon/docs.json index 1b6c2352..e69de29b 100644 --- a/Source/SpacesSync.spoon/docs.json +++ b/Source/SpacesSync.spoon/docs.json @@ -1,584 +0,0 @@ -[ - { - "Constant" : [ - - ], - "submodules" : [ - - ], - "Function" : [ - - ], - "Variable" : [ - { - "desc" : "Logger object used within the Spoon. Set the log level to control verbosity.", - "def" : "SpacesSync.logger", - "stripped_doc" : [ - "Logger object used within the Spoon. Set the log level to control verbosity.", - "", - "Default log level: `info`. Set to `debug` for verbose watcher state dumps", - "and per-target dispatch details. Set to `warning` to suppress routine sync", - "messages.", - "", - "Example:", - "```lua", - "spoon.SpacesSync.logger.setLogLevel('debug')", - "```" - ], - "doc" : "Logger object used within the Spoon. Set the log level to control verbosity.\n\nDefault log level: `info`. Set to `debug` for verbose watcher state dumps\nand per-target dispatch details. Set to `warning` to suppress routine sync\nmessages.\n\nExample:\n```lua\nspoon.SpacesSync.logger.setLogLevel('debug')\n```", - "notes" : [ - - ], - "signature" : "SpacesSync.logger", - "type" : "Variable", - "returns" : [ - - ], - "name" : "logger", - "parameters" : [ - - ] - }, - { - "desc" : "List of sync groups. Each group is a list of monitor position numbers.", - "def" : "SpacesSync.syncGroups", - "stripped_doc" : [ - "List of sync groups. Each group is a list of monitor position numbers.", - "Positions are assigned in reading order (left-to-right, top-to-bottom).", - "Monitors not in any group are independent.", - "", - "Default value: `{ {1, 2} }`", - "", - "Examples:", - " * `{ {1, 2} }` — monitors 1 and 2 sync together", - " * `{ {1, 2}, {3, 4} }` — two independent pairs", - " * `{ {1, 2, 3} }` — three monitors sync together" - ], - "doc" : "List of sync groups. Each group is a list of monitor position numbers.\nPositions are assigned in reading order (left-to-right, top-to-bottom).\nMonitors not in any group are independent.\n\nDefault value: `{ {1, 2} }`\n\nExamples:\n * `{ {1, 2} }` — monitors 1 and 2 sync together\n * `{ {1, 2}, {3, 4} }` — two independent pairs\n * `{ {1, 2, 3} }` — three monitors sync together", - "notes" : [ - - ], - "signature" : "SpacesSync.syncGroups", - "type" : "Variable", - "returns" : [ - - ], - "name" : "syncGroups", - "parameters" : [ - - ] - }, - { - "desc" : "Delay in seconds between each `hs.spaces.gotoSpace()` call.", - "def" : "SpacesSync.switchDelay", - "stripped_doc" : [ - "Delay in seconds between each `hs.spaces.gotoSpace()` call.", - "macOS silently drops rapid back-to-back space switches.", - "", - "Default value: `0.3`" - ], - "doc" : "Delay in seconds between each `hs.spaces.gotoSpace()` call.\nmacOS silently drops rapid back-to-back space switches.\n\nDefault value: `0.3`", - "notes" : [ - - ], - "signature" : "SpacesSync.switchDelay", - "type" : "Variable", - "returns" : [ - - ], - "name" : "switchDelay", - "parameters" : [ - - ] - }, - { - "desc" : "Seconds to wait after all switches complete before re-enabling the watcher.", - "def" : "SpacesSync.debounceSeconds", - "stripped_doc" : [ - "Seconds to wait after all switches complete before re-enabling the watcher.", - "Prevents the watcher from reacting to our own programmatic space switches.", - "", - "Default value: `0.8`" - ], - "doc" : "Seconds to wait after all switches complete before re-enabling the watcher.\nPrevents the watcher from reacting to our own programmatic space switches.\n\nDefault value: `0.8`", - "notes" : [ - - ], - "signature" : "SpacesSync.debounceSeconds", - "type" : "Variable", - "returns" : [ - - ], - "name" : "debounceSeconds", - "parameters" : [ - - ] - }, - { - "desc" : "Default hotkey mapping. Use with `:bindHotkeys()` for a quick setup:", - "def" : "SpacesSync.defaultHotkeys", - "stripped_doc" : [ - "Default hotkey mapping. Use with `:bindHotkeys()` for a quick setup:", - "", - "```lua", - "spoon.SpacesSync:bindHotkeys(spoon.SpacesSync.defaultHotkeys)", - "```", - "", - "Default value:", - "```lua", - "{", - " toggle = {{\"ctrl\", \"alt\", \"cmd\"}, \"Y\"},", - "}", - "```" - ], - "doc" : "Default hotkey mapping. Use with `:bindHotkeys()` for a quick setup:\n\n```lua\nspoon.SpacesSync:bindHotkeys(spoon.SpacesSync.defaultHotkeys)\n```\n\nDefault value:\n```lua\n{\n toggle = {{\"ctrl\", \"alt\", \"cmd\"}, \"Y\"},\n}\n```", - "notes" : [ - - ], - "signature" : "SpacesSync.defaultHotkeys", - "type" : "Variable", - "returns" : [ - - ], - "name" : "defaultHotkeys", - "parameters" : [ - - ] - } - ], - "stripped_doc" : [ - - ], - "desc" : "Synchronize macOS Spaces across monitors.", - "Deprecated" : [ - - ], - "type" : "Module", - "Constructor" : [ - - ], - "doc" : "Synchronize macOS Spaces across monitors.\n\nWhen you switch Spaces on one monitor, all other monitors in the same\nsync group follow to the matching Space index.\n\nMonitors are identified by position number (reading order:\nleft-to-right, top-to-bottom). Define sync groups as sets of position\nnumbers.\n\n**Requirements:**\n * macOS Sequoia 15.0+ (uses private `hs.spaces` APIs)\n * Two or more monitors with multiple Spaces configured\n * \"Displays have separate Spaces\" must be ON (System Settings > Desktop & Dock > Mission Control)\n * \"Automatically rearrange Spaces based on most recent use\" should be OFF\n\nDownload: [https:\/\/github.com\/Hammerspoon\/Spoons\/raw\/master\/Spoons\/SpacesSync.spoon.zip](https:\/\/github.com\/Hammerspoon\/Spoons\/raw\/master\/Spoons\/SpacesSync.spoon.zip)", - "Field" : [ - - ], - "items" : [ - { - "desc" : "Seconds to wait after all switches complete before re-enabling the watcher.", - "def" : "SpacesSync.debounceSeconds", - "stripped_doc" : [ - "Seconds to wait after all switches complete before re-enabling the watcher.", - "Prevents the watcher from reacting to our own programmatic space switches.", - "", - "Default value: `0.8`" - ], - "doc" : "Seconds to wait after all switches complete before re-enabling the watcher.\nPrevents the watcher from reacting to our own programmatic space switches.\n\nDefault value: `0.8`", - "notes" : [ - - ], - "signature" : "SpacesSync.debounceSeconds", - "type" : "Variable", - "returns" : [ - - ], - "name" : "debounceSeconds", - "parameters" : [ - - ] - }, - { - "desc" : "Default hotkey mapping. Use with `:bindHotkeys()` for a quick setup:", - "def" : "SpacesSync.defaultHotkeys", - "stripped_doc" : [ - "Default hotkey mapping. Use with `:bindHotkeys()` for a quick setup:", - "", - "```lua", - "spoon.SpacesSync:bindHotkeys(spoon.SpacesSync.defaultHotkeys)", - "```", - "", - "Default value:", - "```lua", - "{", - " toggle = {{\"ctrl\", \"alt\", \"cmd\"}, \"Y\"},", - "}", - "```" - ], - "doc" : "Default hotkey mapping. Use with `:bindHotkeys()` for a quick setup:\n\n```lua\nspoon.SpacesSync:bindHotkeys(spoon.SpacesSync.defaultHotkeys)\n```\n\nDefault value:\n```lua\n{\n toggle = {{\"ctrl\", \"alt\", \"cmd\"}, \"Y\"},\n}\n```", - "notes" : [ - - ], - "signature" : "SpacesSync.defaultHotkeys", - "type" : "Variable", - "returns" : [ - - ], - "name" : "defaultHotkeys", - "parameters" : [ - - ] - }, - { - "desc" : "Logger object used within the Spoon. Set the log level to control verbosity.", - "def" : "SpacesSync.logger", - "stripped_doc" : [ - "Logger object used within the Spoon. Set the log level to control verbosity.", - "", - "Default log level: `info`. Set to `debug` for verbose watcher state dumps", - "and per-target dispatch details. Set to `warning` to suppress routine sync", - "messages.", - "", - "Example:", - "```lua", - "spoon.SpacesSync.logger.setLogLevel('debug')", - "```" - ], - "doc" : "Logger object used within the Spoon. Set the log level to control verbosity.\n\nDefault log level: `info`. Set to `debug` for verbose watcher state dumps\nand per-target dispatch details. Set to `warning` to suppress routine sync\nmessages.\n\nExample:\n```lua\nspoon.SpacesSync.logger.setLogLevel('debug')\n```", - "notes" : [ - - ], - "signature" : "SpacesSync.logger", - "type" : "Variable", - "returns" : [ - - ], - "name" : "logger", - "parameters" : [ - - ] - }, - { - "desc" : "Delay in seconds between each `hs.spaces.gotoSpace()` call.", - "def" : "SpacesSync.switchDelay", - "stripped_doc" : [ - "Delay in seconds between each `hs.spaces.gotoSpace()` call.", - "macOS silently drops rapid back-to-back space switches.", - "", - "Default value: `0.3`" - ], - "doc" : "Delay in seconds between each `hs.spaces.gotoSpace()` call.\nmacOS silently drops rapid back-to-back space switches.\n\nDefault value: `0.3`", - "notes" : [ - - ], - "signature" : "SpacesSync.switchDelay", - "type" : "Variable", - "returns" : [ - - ], - "name" : "switchDelay", - "parameters" : [ - - ] - }, - { - "desc" : "List of sync groups. Each group is a list of monitor position numbers.", - "def" : "SpacesSync.syncGroups", - "stripped_doc" : [ - "List of sync groups. Each group is a list of monitor position numbers.", - "Positions are assigned in reading order (left-to-right, top-to-bottom).", - "Monitors not in any group are independent.", - "", - "Default value: `{ {1, 2} }`", - "", - "Examples:", - " * `{ {1, 2} }` — monitors 1 and 2 sync together", - " * `{ {1, 2}, {3, 4} }` — two independent pairs", - " * `{ {1, 2, 3} }` — three monitors sync together" - ], - "doc" : "List of sync groups. Each group is a list of monitor position numbers.\nPositions are assigned in reading order (left-to-right, top-to-bottom).\nMonitors not in any group are independent.\n\nDefault value: `{ {1, 2} }`\n\nExamples:\n * `{ {1, 2} }` — monitors 1 and 2 sync together\n * `{ {1, 2}, {3, 4} }` — two independent pairs\n * `{ {1, 2, 3} }` — three monitors sync together", - "notes" : [ - - ], - "signature" : "SpacesSync.syncGroups", - "type" : "Variable", - "returns" : [ - - ], - "name" : "syncGroups", - "parameters" : [ - - ] - }, - { - "desc" : "Binds hotkeys for SpacesSync.", - "def" : "SpacesSync:bindHotkeys(mapping)", - "stripped_doc" : [ - "Binds hotkeys for SpacesSync.", - "" - ], - "doc" : "Binds hotkeys for SpacesSync.\n\nParameters:\n * mapping - A table containing hotkey modifier\/key details for the following items:\n * toggle - Toggle Space syncing on\/off\n\nReturns:\n * The SpacesSync object\n\nNotes:\n * For a quick setup with defaults, use:\n `spoon.SpacesSync:bindHotkeys(spoon.SpacesSync.defaultHotkeys)`", - "notes" : [ - " * For a quick setup with defaults, use:", - " `spoon.SpacesSync:bindHotkeys(spoon.SpacesSync.defaultHotkeys)`" - ], - "signature" : "SpacesSync:bindHotkeys(mapping)", - "type" : "Method", - "returns" : [ - " * The SpacesSync object", - "" - ], - "name" : "bindHotkeys", - "parameters" : [ - " * mapping - A table containing hotkey modifier\/key details for the following items:", - " * toggle - Toggle Space syncing on\/off", - "" - ] - }, - { - "desc" : "Initializes the SpacesSync spoon. Called automatically by `hs.loadSpoon()`.", - "def" : "SpacesSync:init()", - "stripped_doc" : [ - "Initializes the SpacesSync spoon. Called automatically by `hs.loadSpoon()`.", - "Does not start syncing — call `:start()` to begin.", - "" - ], - "doc" : "Initializes the SpacesSync spoon. Called automatically by `hs.loadSpoon()`.\nDoes not start syncing — call `:start()` to begin.\n\nParameters:\n * None\n\nReturns:\n * The SpacesSync object", - "notes" : [ - - ], - "signature" : "SpacesSync:init()", - "type" : "Method", - "returns" : [ - " * The SpacesSync object" - ], - "name" : "init", - "parameters" : [ - " * None", - "" - ] - }, - { - "desc" : "Returns whether Space syncing is currently active.", - "def" : "SpacesSync:isEnabled()", - "stripped_doc" : [ - "Returns whether Space syncing is currently active.", - "" - ], - "doc" : "Returns whether Space syncing is currently active.\n\nParameters:\n * None\n\nReturns:\n * A boolean", - "notes" : [ - - ], - "signature" : "SpacesSync:isEnabled()", - "type" : "Method", - "returns" : [ - " * A boolean" - ], - "name" : "isEnabled", - "parameters" : [ - " * None", - "" - ] - }, - { - "desc" : "Starts Space syncing.", - "def" : "SpacesSync:start()", - "stripped_doc" : [ - "Starts Space syncing.", - "Checks macOS version and Mission Control settings, builds the monitor", - "position map, and enables the Space watcher.", - "" - ], - "doc" : "Starts Space syncing.\nChecks macOS version and Mission Control settings, builds the monitor\nposition map, and enables the Space watcher.\n\nParameters:\n * None\n\nReturns:\n * The SpacesSync object", - "notes" : [ - - ], - "signature" : "SpacesSync:start()", - "type" : "Method", - "returns" : [ - " * The SpacesSync object" - ], - "name" : "start", - "parameters" : [ - " * None", - "" - ] - }, - { - "desc" : "Stops Space syncing and cleans up watchers and timers.", - "def" : "SpacesSync:stop()", - "stripped_doc" : [ - "Stops Space syncing and cleans up watchers and timers.", - "" - ], - "doc" : "Stops Space syncing and cleans up watchers and timers.\n\nParameters:\n * None\n\nReturns:\n * The SpacesSync object", - "notes" : [ - - ], - "signature" : "SpacesSync:stop()", - "type" : "Method", - "returns" : [ - " * The SpacesSync object" - ], - "name" : "stop", - "parameters" : [ - " * None", - "" - ] - }, - { - "desc" : "Toggles Space syncing on or off.", - "def" : "SpacesSync:toggle()", - "stripped_doc" : [ - "Toggles Space syncing on or off.", - "" - ], - "doc" : "Toggles Space syncing on or off.\n\nParameters:\n * None\n\nReturns:\n * The SpacesSync object", - "notes" : [ - - ], - "signature" : "SpacesSync:toggle()", - "type" : "Method", - "returns" : [ - " * The SpacesSync object" - ], - "name" : "toggle", - "parameters" : [ - " * None", - "" - ] - } - ], - "Method" : [ - { - "desc" : "Initializes the SpacesSync spoon. Called automatically by `hs.loadSpoon()`.", - "def" : "SpacesSync:init()", - "stripped_doc" : [ - "Initializes the SpacesSync spoon. Called automatically by `hs.loadSpoon()`.", - "Does not start syncing — call `:start()` to begin.", - "" - ], - "doc" : "Initializes the SpacesSync spoon. Called automatically by `hs.loadSpoon()`.\nDoes not start syncing — call `:start()` to begin.\n\nParameters:\n * None\n\nReturns:\n * The SpacesSync object", - "notes" : [ - - ], - "signature" : "SpacesSync:init()", - "type" : "Method", - "returns" : [ - " * The SpacesSync object" - ], - "name" : "init", - "parameters" : [ - " * None", - "" - ] - }, - { - "desc" : "Starts Space syncing.", - "def" : "SpacesSync:start()", - "stripped_doc" : [ - "Starts Space syncing.", - "Checks macOS version and Mission Control settings, builds the monitor", - "position map, and enables the Space watcher.", - "" - ], - "doc" : "Starts Space syncing.\nChecks macOS version and Mission Control settings, builds the monitor\nposition map, and enables the Space watcher.\n\nParameters:\n * None\n\nReturns:\n * The SpacesSync object", - "notes" : [ - - ], - "signature" : "SpacesSync:start()", - "type" : "Method", - "returns" : [ - " * The SpacesSync object" - ], - "name" : "start", - "parameters" : [ - " * None", - "" - ] - }, - { - "desc" : "Stops Space syncing and cleans up watchers and timers.", - "def" : "SpacesSync:stop()", - "stripped_doc" : [ - "Stops Space syncing and cleans up watchers and timers.", - "" - ], - "doc" : "Stops Space syncing and cleans up watchers and timers.\n\nParameters:\n * None\n\nReturns:\n * The SpacesSync object", - "notes" : [ - - ], - "signature" : "SpacesSync:stop()", - "type" : "Method", - "returns" : [ - " * The SpacesSync object" - ], - "name" : "stop", - "parameters" : [ - " * None", - "" - ] - }, - { - "desc" : "Toggles Space syncing on or off.", - "def" : "SpacesSync:toggle()", - "stripped_doc" : [ - "Toggles Space syncing on or off.", - "" - ], - "doc" : "Toggles Space syncing on or off.\n\nParameters:\n * None\n\nReturns:\n * The SpacesSync object", - "notes" : [ - - ], - "signature" : "SpacesSync:toggle()", - "type" : "Method", - "returns" : [ - " * The SpacesSync object" - ], - "name" : "toggle", - "parameters" : [ - " * None", - "" - ] - }, - { - "desc" : "Returns whether Space syncing is currently active.", - "def" : "SpacesSync:isEnabled()", - "stripped_doc" : [ - "Returns whether Space syncing is currently active.", - "" - ], - "doc" : "Returns whether Space syncing is currently active.\n\nParameters:\n * None\n\nReturns:\n * A boolean", - "notes" : [ - - ], - "signature" : "SpacesSync:isEnabled()", - "type" : "Method", - "returns" : [ - " * A boolean" - ], - "name" : "isEnabled", - "parameters" : [ - " * None", - "" - ] - }, - { - "desc" : "Binds hotkeys for SpacesSync.", - "def" : "SpacesSync:bindHotkeys(mapping)", - "stripped_doc" : [ - "Binds hotkeys for SpacesSync.", - "" - ], - "doc" : "Binds hotkeys for SpacesSync.\n\nParameters:\n * mapping - A table containing hotkey modifier\/key details for the following items:\n * toggle - Toggle Space syncing on\/off\n\nReturns:\n * The SpacesSync object\n\nNotes:\n * For a quick setup with defaults, use:\n `spoon.SpacesSync:bindHotkeys(spoon.SpacesSync.defaultHotkeys)`", - "notes" : [ - " * For a quick setup with defaults, use:", - " `spoon.SpacesSync:bindHotkeys(spoon.SpacesSync.defaultHotkeys)`" - ], - "signature" : "SpacesSync:bindHotkeys(mapping)", - "type" : "Method", - "returns" : [ - " * The SpacesSync object", - "" - ], - "name" : "bindHotkeys", - "parameters" : [ - " * mapping - A table containing hotkey modifier\/key details for the following items:", - " * toggle - Toggle Space syncing on\/off", - "" - ] - } - ], - "Command" : [ - - ], - "name" : "SpacesSync" - } -] diff --git a/Source/SpacesSync.spoon/init.lua b/Source/SpacesSync.spoon/init.lua index 3bab766e..54597948 100644 --- a/Source/SpacesSync.spoon/init.lua +++ b/Source/SpacesSync.spoon/init.lua @@ -22,7 +22,7 @@ obj.__index = obj -- Metadata obj.name = "SpacesSync" -obj.version = "1.0" +obj.version = "0.1" obj.author = "John Randall " obj.homepage = "https://github.com/johntrandall/hammerspoon-spaces-sync" obj.license = "MIT - https://opensource.org/licenses/MIT" From 68fc2f3339baebe55a0e10a08680f6c79bb6d0c1 Mon Sep 17 00:00:00 2001 From: John Randall Date: Fri, 10 Apr 2026 01:01:02 -0400 Subject: [PATCH 4/4] Update SpacesSync to v0.2 Co-Authored-By: Claude Opus 4.6 (1M context) --- Source/SpacesSync.spoon/docs.json | 584 ++++++++++++++++++++++++++++++ Source/SpacesSync.spoon/init.lua | 2 +- 2 files changed, 585 insertions(+), 1 deletion(-) diff --git a/Source/SpacesSync.spoon/docs.json b/Source/SpacesSync.spoon/docs.json index e69de29b..922ccb06 100644 --- a/Source/SpacesSync.spoon/docs.json +++ b/Source/SpacesSync.spoon/docs.json @@ -0,0 +1,584 @@ +[ + { + "Constant" : [ + + ], + "submodules" : [ + + ], + "Function" : [ + + ], + "Variable" : [ + { + "doc" : "Logger object used within the Spoon. Set the log level to control verbosity.\n\nDefault log level: `info`. Set to `debug` for verbose watcher state dumps\nand per-target dispatch details. Set to `warning` to suppress routine sync\nmessages.\n\nExample:\n```lua\nspoon.SpacesSync.logger.setLogLevel('debug')\n```", + "parameters" : [ + + ], + "stripped_doc" : [ + "Logger object used within the Spoon. Set the log level to control verbosity.", + "", + "Default log level: `info`. Set to `debug` for verbose watcher state dumps", + "and per-target dispatch details. Set to `warning` to suppress routine sync", + "messages.", + "", + "Example:", + "```lua", + "spoon.SpacesSync.logger.setLogLevel('debug')", + "```" + ], + "name" : "logger", + "notes" : [ + + ], + "signature" : "SpacesSync.logger", + "type" : "Variable", + "returns" : [ + + ], + "def" : "SpacesSync.logger", + "desc" : "Logger object used within the Spoon. Set the log level to control verbosity." + }, + { + "doc" : "List of sync groups. Each group is a list of monitor position numbers.\nPositions are assigned in reading order (left-to-right, top-to-bottom).\nMonitors not in any group are independent.\n\nDefault value: `{ {1, 2} }`\n\nExamples:\n * `{ {1, 2} }` — monitors 1 and 2 sync together\n * `{ {1, 2}, {3, 4} }` — two independent pairs\n * `{ {1, 2, 3} }` — three monitors sync together", + "parameters" : [ + + ], + "stripped_doc" : [ + "List of sync groups. Each group is a list of monitor position numbers.", + "Positions are assigned in reading order (left-to-right, top-to-bottom).", + "Monitors not in any group are independent.", + "", + "Default value: `{ {1, 2} }`", + "", + "Examples:", + " * `{ {1, 2} }` — monitors 1 and 2 sync together", + " * `{ {1, 2}, {3, 4} }` — two independent pairs", + " * `{ {1, 2, 3} }` — three monitors sync together" + ], + "name" : "syncGroups", + "notes" : [ + + ], + "signature" : "SpacesSync.syncGroups", + "type" : "Variable", + "returns" : [ + + ], + "def" : "SpacesSync.syncGroups", + "desc" : "List of sync groups. Each group is a list of monitor position numbers." + }, + { + "doc" : "Delay in seconds between each `hs.spaces.gotoSpace()` call.\nmacOS silently drops rapid back-to-back space switches.\n\nDefault value: `0.3`", + "parameters" : [ + + ], + "stripped_doc" : [ + "Delay in seconds between each `hs.spaces.gotoSpace()` call.", + "macOS silently drops rapid back-to-back space switches.", + "", + "Default value: `0.3`" + ], + "name" : "switchDelay", + "notes" : [ + + ], + "signature" : "SpacesSync.switchDelay", + "type" : "Variable", + "returns" : [ + + ], + "def" : "SpacesSync.switchDelay", + "desc" : "Delay in seconds between each `hs.spaces.gotoSpace()` call." + }, + { + "doc" : "Seconds to wait after all switches complete before re-enabling the watcher.\nPrevents the watcher from reacting to our own programmatic space switches.\n\nDefault value: `0.8`", + "parameters" : [ + + ], + "stripped_doc" : [ + "Seconds to wait after all switches complete before re-enabling the watcher.", + "Prevents the watcher from reacting to our own programmatic space switches.", + "", + "Default value: `0.8`" + ], + "name" : "debounceSeconds", + "notes" : [ + + ], + "signature" : "SpacesSync.debounceSeconds", + "type" : "Variable", + "returns" : [ + + ], + "def" : "SpacesSync.debounceSeconds", + "desc" : "Seconds to wait after all switches complete before re-enabling the watcher." + }, + { + "doc" : "Default hotkey mapping. Use with `:bindHotkeys()` for a quick setup:\n\n```lua\nspoon.SpacesSync:bindHotkeys(spoon.SpacesSync.defaultHotkeys)\n```\n\nDefault value:\n```lua\n{\n toggle = {{\"ctrl\", \"alt\", \"cmd\"}, \"Y\"},\n}\n```", + "parameters" : [ + + ], + "stripped_doc" : [ + "Default hotkey mapping. Use with `:bindHotkeys()` for a quick setup:", + "", + "```lua", + "spoon.SpacesSync:bindHotkeys(spoon.SpacesSync.defaultHotkeys)", + "```", + "", + "Default value:", + "```lua", + "{", + " toggle = {{\"ctrl\", \"alt\", \"cmd\"}, \"Y\"},", + "}", + "```" + ], + "name" : "defaultHotkeys", + "notes" : [ + + ], + "signature" : "SpacesSync.defaultHotkeys", + "type" : "Variable", + "returns" : [ + + ], + "def" : "SpacesSync.defaultHotkeys", + "desc" : "Default hotkey mapping. Use with `:bindHotkeys()` for a quick setup:" + } + ], + "stripped_doc" : [ + + ], + "type" : "Module", + "Deprecated" : [ + + ], + "desc" : "Synchronize macOS Spaces across monitors.", + "Constructor" : [ + + ], + "items" : [ + { + "doc" : "Seconds to wait after all switches complete before re-enabling the watcher.\nPrevents the watcher from reacting to our own programmatic space switches.\n\nDefault value: `0.8`", + "parameters" : [ + + ], + "stripped_doc" : [ + "Seconds to wait after all switches complete before re-enabling the watcher.", + "Prevents the watcher from reacting to our own programmatic space switches.", + "", + "Default value: `0.8`" + ], + "name" : "debounceSeconds", + "notes" : [ + + ], + "signature" : "SpacesSync.debounceSeconds", + "type" : "Variable", + "returns" : [ + + ], + "def" : "SpacesSync.debounceSeconds", + "desc" : "Seconds to wait after all switches complete before re-enabling the watcher." + }, + { + "doc" : "Default hotkey mapping. Use with `:bindHotkeys()` for a quick setup:\n\n```lua\nspoon.SpacesSync:bindHotkeys(spoon.SpacesSync.defaultHotkeys)\n```\n\nDefault value:\n```lua\n{\n toggle = {{\"ctrl\", \"alt\", \"cmd\"}, \"Y\"},\n}\n```", + "parameters" : [ + + ], + "stripped_doc" : [ + "Default hotkey mapping. Use with `:bindHotkeys()` for a quick setup:", + "", + "```lua", + "spoon.SpacesSync:bindHotkeys(spoon.SpacesSync.defaultHotkeys)", + "```", + "", + "Default value:", + "```lua", + "{", + " toggle = {{\"ctrl\", \"alt\", \"cmd\"}, \"Y\"},", + "}", + "```" + ], + "name" : "defaultHotkeys", + "notes" : [ + + ], + "signature" : "SpacesSync.defaultHotkeys", + "type" : "Variable", + "returns" : [ + + ], + "def" : "SpacesSync.defaultHotkeys", + "desc" : "Default hotkey mapping. Use with `:bindHotkeys()` for a quick setup:" + }, + { + "doc" : "Logger object used within the Spoon. Set the log level to control verbosity.\n\nDefault log level: `info`. Set to `debug` for verbose watcher state dumps\nand per-target dispatch details. Set to `warning` to suppress routine sync\nmessages.\n\nExample:\n```lua\nspoon.SpacesSync.logger.setLogLevel('debug')\n```", + "parameters" : [ + + ], + "stripped_doc" : [ + "Logger object used within the Spoon. Set the log level to control verbosity.", + "", + "Default log level: `info`. Set to `debug` for verbose watcher state dumps", + "and per-target dispatch details. Set to `warning` to suppress routine sync", + "messages.", + "", + "Example:", + "```lua", + "spoon.SpacesSync.logger.setLogLevel('debug')", + "```" + ], + "name" : "logger", + "notes" : [ + + ], + "signature" : "SpacesSync.logger", + "type" : "Variable", + "returns" : [ + + ], + "def" : "SpacesSync.logger", + "desc" : "Logger object used within the Spoon. Set the log level to control verbosity." + }, + { + "doc" : "Delay in seconds between each `hs.spaces.gotoSpace()` call.\nmacOS silently drops rapid back-to-back space switches.\n\nDefault value: `0.3`", + "parameters" : [ + + ], + "stripped_doc" : [ + "Delay in seconds between each `hs.spaces.gotoSpace()` call.", + "macOS silently drops rapid back-to-back space switches.", + "", + "Default value: `0.3`" + ], + "name" : "switchDelay", + "notes" : [ + + ], + "signature" : "SpacesSync.switchDelay", + "type" : "Variable", + "returns" : [ + + ], + "def" : "SpacesSync.switchDelay", + "desc" : "Delay in seconds between each `hs.spaces.gotoSpace()` call." + }, + { + "doc" : "List of sync groups. Each group is a list of monitor position numbers.\nPositions are assigned in reading order (left-to-right, top-to-bottom).\nMonitors not in any group are independent.\n\nDefault value: `{ {1, 2} }`\n\nExamples:\n * `{ {1, 2} }` — monitors 1 and 2 sync together\n * `{ {1, 2}, {3, 4} }` — two independent pairs\n * `{ {1, 2, 3} }` — three monitors sync together", + "parameters" : [ + + ], + "stripped_doc" : [ + "List of sync groups. Each group is a list of monitor position numbers.", + "Positions are assigned in reading order (left-to-right, top-to-bottom).", + "Monitors not in any group are independent.", + "", + "Default value: `{ {1, 2} }`", + "", + "Examples:", + " * `{ {1, 2} }` — monitors 1 and 2 sync together", + " * `{ {1, 2}, {3, 4} }` — two independent pairs", + " * `{ {1, 2, 3} }` — three monitors sync together" + ], + "name" : "syncGroups", + "notes" : [ + + ], + "signature" : "SpacesSync.syncGroups", + "type" : "Variable", + "returns" : [ + + ], + "def" : "SpacesSync.syncGroups", + "desc" : "List of sync groups. Each group is a list of monitor position numbers." + }, + { + "doc" : "Binds hotkeys for SpacesSync.\n\nParameters:\n * mapping - A table containing hotkey modifier\/key details for the following items:\n * toggle - Toggle Space syncing on\/off\n\nReturns:\n * The SpacesSync object\n\nNotes:\n * For a quick setup with defaults, use:\n `spoon.SpacesSync:bindHotkeys(spoon.SpacesSync.defaultHotkeys)`", + "parameters" : [ + " * mapping - A table containing hotkey modifier\/key details for the following items:", + " * toggle - Toggle Space syncing on\/off", + "" + ], + "stripped_doc" : [ + "Binds hotkeys for SpacesSync.", + "" + ], + "name" : "bindHotkeys", + "notes" : [ + " * For a quick setup with defaults, use:", + " `spoon.SpacesSync:bindHotkeys(spoon.SpacesSync.defaultHotkeys)`" + ], + "signature" : "SpacesSync:bindHotkeys(mapping)", + "type" : "Method", + "returns" : [ + " * The SpacesSync object", + "" + ], + "def" : "SpacesSync:bindHotkeys(mapping)", + "desc" : "Binds hotkeys for SpacesSync." + }, + { + "doc" : "Initializes the SpacesSync spoon. Called automatically by `hs.loadSpoon()`.\nDoes not start syncing — call `:start()` to begin.\n\nParameters:\n * None\n\nReturns:\n * The SpacesSync object", + "parameters" : [ + " * None", + "" + ], + "stripped_doc" : [ + "Initializes the SpacesSync spoon. Called automatically by `hs.loadSpoon()`.", + "Does not start syncing — call `:start()` to begin.", + "" + ], + "name" : "init", + "notes" : [ + + ], + "signature" : "SpacesSync:init()", + "type" : "Method", + "returns" : [ + " * The SpacesSync object" + ], + "def" : "SpacesSync:init()", + "desc" : "Initializes the SpacesSync spoon. Called automatically by `hs.loadSpoon()`." + }, + { + "doc" : "Returns whether Space syncing is currently active.\n\nParameters:\n * None\n\nReturns:\n * A boolean", + "parameters" : [ + " * None", + "" + ], + "stripped_doc" : [ + "Returns whether Space syncing is currently active.", + "" + ], + "name" : "isEnabled", + "notes" : [ + + ], + "signature" : "SpacesSync:isEnabled()", + "type" : "Method", + "returns" : [ + " * A boolean" + ], + "def" : "SpacesSync:isEnabled()", + "desc" : "Returns whether Space syncing is currently active." + }, + { + "doc" : "Starts Space syncing.\nChecks macOS version and Mission Control settings, builds the monitor\nposition map, and enables the Space watcher.\n\nParameters:\n * None\n\nReturns:\n * The SpacesSync object", + "parameters" : [ + " * None", + "" + ], + "stripped_doc" : [ + "Starts Space syncing.", + "Checks macOS version and Mission Control settings, builds the monitor", + "position map, and enables the Space watcher.", + "" + ], + "name" : "start", + "notes" : [ + + ], + "signature" : "SpacesSync:start()", + "type" : "Method", + "returns" : [ + " * The SpacesSync object" + ], + "def" : "SpacesSync:start()", + "desc" : "Starts Space syncing." + }, + { + "doc" : "Stops Space syncing and cleans up watchers and timers.\n\nParameters:\n * None\n\nReturns:\n * The SpacesSync object", + "parameters" : [ + " * None", + "" + ], + "stripped_doc" : [ + "Stops Space syncing and cleans up watchers and timers.", + "" + ], + "name" : "stop", + "notes" : [ + + ], + "signature" : "SpacesSync:stop()", + "type" : "Method", + "returns" : [ + " * The SpacesSync object" + ], + "def" : "SpacesSync:stop()", + "desc" : "Stops Space syncing and cleans up watchers and timers." + }, + { + "doc" : "Toggles Space syncing on or off.\n\nParameters:\n * None\n\nReturns:\n * The SpacesSync object", + "parameters" : [ + " * None", + "" + ], + "stripped_doc" : [ + "Toggles Space syncing on or off.", + "" + ], + "name" : "toggle", + "notes" : [ + + ], + "signature" : "SpacesSync:toggle()", + "type" : "Method", + "returns" : [ + " * The SpacesSync object" + ], + "def" : "SpacesSync:toggle()", + "desc" : "Toggles Space syncing on or off." + } + ], + "Method" : [ + { + "doc" : "Initializes the SpacesSync spoon. Called automatically by `hs.loadSpoon()`.\nDoes not start syncing — call `:start()` to begin.\n\nParameters:\n * None\n\nReturns:\n * The SpacesSync object", + "parameters" : [ + " * None", + "" + ], + "stripped_doc" : [ + "Initializes the SpacesSync spoon. Called automatically by `hs.loadSpoon()`.", + "Does not start syncing — call `:start()` to begin.", + "" + ], + "name" : "init", + "notes" : [ + + ], + "signature" : "SpacesSync:init()", + "type" : "Method", + "returns" : [ + " * The SpacesSync object" + ], + "def" : "SpacesSync:init()", + "desc" : "Initializes the SpacesSync spoon. Called automatically by `hs.loadSpoon()`." + }, + { + "doc" : "Starts Space syncing.\nChecks macOS version and Mission Control settings, builds the monitor\nposition map, and enables the Space watcher.\n\nParameters:\n * None\n\nReturns:\n * The SpacesSync object", + "parameters" : [ + " * None", + "" + ], + "stripped_doc" : [ + "Starts Space syncing.", + "Checks macOS version and Mission Control settings, builds the monitor", + "position map, and enables the Space watcher.", + "" + ], + "name" : "start", + "notes" : [ + + ], + "signature" : "SpacesSync:start()", + "type" : "Method", + "returns" : [ + " * The SpacesSync object" + ], + "def" : "SpacesSync:start()", + "desc" : "Starts Space syncing." + }, + { + "doc" : "Stops Space syncing and cleans up watchers and timers.\n\nParameters:\n * None\n\nReturns:\n * The SpacesSync object", + "parameters" : [ + " * None", + "" + ], + "stripped_doc" : [ + "Stops Space syncing and cleans up watchers and timers.", + "" + ], + "name" : "stop", + "notes" : [ + + ], + "signature" : "SpacesSync:stop()", + "type" : "Method", + "returns" : [ + " * The SpacesSync object" + ], + "def" : "SpacesSync:stop()", + "desc" : "Stops Space syncing and cleans up watchers and timers." + }, + { + "doc" : "Toggles Space syncing on or off.\n\nParameters:\n * None\n\nReturns:\n * The SpacesSync object", + "parameters" : [ + " * None", + "" + ], + "stripped_doc" : [ + "Toggles Space syncing on or off.", + "" + ], + "name" : "toggle", + "notes" : [ + + ], + "signature" : "SpacesSync:toggle()", + "type" : "Method", + "returns" : [ + " * The SpacesSync object" + ], + "def" : "SpacesSync:toggle()", + "desc" : "Toggles Space syncing on or off." + }, + { + "doc" : "Returns whether Space syncing is currently active.\n\nParameters:\n * None\n\nReturns:\n * A boolean", + "parameters" : [ + " * None", + "" + ], + "stripped_doc" : [ + "Returns whether Space syncing is currently active.", + "" + ], + "name" : "isEnabled", + "notes" : [ + + ], + "signature" : "SpacesSync:isEnabled()", + "type" : "Method", + "returns" : [ + " * A boolean" + ], + "def" : "SpacesSync:isEnabled()", + "desc" : "Returns whether Space syncing is currently active." + }, + { + "doc" : "Binds hotkeys for SpacesSync.\n\nParameters:\n * mapping - A table containing hotkey modifier\/key details for the following items:\n * toggle - Toggle Space syncing on\/off\n\nReturns:\n * The SpacesSync object\n\nNotes:\n * For a quick setup with defaults, use:\n `spoon.SpacesSync:bindHotkeys(spoon.SpacesSync.defaultHotkeys)`", + "parameters" : [ + " * mapping - A table containing hotkey modifier\/key details for the following items:", + " * toggle - Toggle Space syncing on\/off", + "" + ], + "stripped_doc" : [ + "Binds hotkeys for SpacesSync.", + "" + ], + "name" : "bindHotkeys", + "notes" : [ + " * For a quick setup with defaults, use:", + " `spoon.SpacesSync:bindHotkeys(spoon.SpacesSync.defaultHotkeys)`" + ], + "signature" : "SpacesSync:bindHotkeys(mapping)", + "type" : "Method", + "returns" : [ + " * The SpacesSync object", + "" + ], + "def" : "SpacesSync:bindHotkeys(mapping)", + "desc" : "Binds hotkeys for SpacesSync." + } + ], + "Command" : [ + + ], + "Field" : [ + + ], + "doc" : "Synchronize macOS Spaces across monitors.\n\nWhen you switch Spaces on one monitor, all other monitors in the same\nsync group follow to the matching Space index.\n\nMonitors are identified by position number (reading order:\nleft-to-right, top-to-bottom). Define sync groups as sets of position\nnumbers.\n\n**Requirements:**\n * macOS Sequoia 15.0+ (uses private `hs.spaces` APIs)\n * Two or more monitors with multiple Spaces configured\n * \"Displays have separate Spaces\" must be ON (System Settings > Desktop & Dock > Mission Control)\n * \"Automatically rearrange Spaces based on most recent use\" should be OFF\n\nDownload: [https:\/\/github.com\/Hammerspoon\/Spoons\/raw\/master\/Spoons\/SpacesSync.spoon.zip](https:\/\/github.com\/Hammerspoon\/Spoons\/raw\/master\/Spoons\/SpacesSync.spoon.zip)", + "name" : "SpacesSync" + } +] diff --git a/Source/SpacesSync.spoon/init.lua b/Source/SpacesSync.spoon/init.lua index 54597948..cef27804 100644 --- a/Source/SpacesSync.spoon/init.lua +++ b/Source/SpacesSync.spoon/init.lua @@ -22,7 +22,7 @@ obj.__index = obj -- Metadata obj.name = "SpacesSync" -obj.version = "0.1" +obj.version = "0.2" obj.author = "John Randall " obj.homepage = "https://github.com/johntrandall/hammerspoon-spaces-sync" obj.license = "MIT - https://opensource.org/licenses/MIT"