Skip to content

Instantly share code, notes, and snippets.

@ole
Created May 28, 2025 10:38
Show Gist options
  • Save ole/433e1a17eb6d6286187ea15b45bf92c4 to your computer and use it in GitHub Desktop.
Save ole/433e1a17eb6d6286187ea15b45bf92c4 to your computer and use it in GitHub Desktop.
OleScreenConfigNotifier: a Hammerspoon spoon for observing changes in the screen/display/monitor configuration, such as (dis-)connecting displays or resolution changes.
--- === ScreenConfigNotifier ===
---
--- Displays a local notification when the screen configuration changes.
---
--- With some modifications, this can be used as the basis for triggering
--- other actions when the screen config changes.
--- Usage:
--- 1. Save this file as `OleScreenConfigNotifier.spoon/init.lua` in your Hammerspoon config folder.
--- 2. In your root `init.lua`, add these lines:
---
--- hs.loadSpoon("OleScreenConfigNotifier")
--- spoon.OleScreenConfigNotifier:start()
local obj = {}
obj.__index = obj
-- Metadata
obj.name = "OleScreenConfigNotifier"
obj.version = "0.1"
obj.author = "Ole Begemann"
-- Internal variables
obj.watcher = nil
obj.currentScreenConfig = nil
--- ScreenConfigNotifier:start()
--- Method
--- Starts watching for screen configuration changes
---
--- Parameters:
--- * None
---
--- Returns:
--- * The ScreenConfigNotifier object
function obj:start()
if self.watcher then
self.watcher:stop()
end
-- Create a watcher that responds to both layout and active screen changes
self.watcher = hs.screen.watcher.newWithActiveScreen(function(isActiveScreenChange)
self:screenChanged(isActiveScreenChange)
end)
self.watcher:start()
-- Show initial notification on launch
-- This will implicitly store the initial screen config, which is why we don't
-- need an explicit `self.currentScreenConfig = self:getScreenInfo()` call here.
self:screenChanged(false)
return self
end
--- ScreenConfigNotifier:stop()
--- Method
--- Stops watching for screen configuration changes
---
--- Parameters:
--- * None
---
--- Returns:
--- * The ScreenConfigNotifier object
function obj:stop()
if self.watcher then
self.watcher:stop()
self.watcher = nil
end
return self
end
-- Callback that gets called when screen configuration changes
--
-- When isActiveScreenChange is false, this means it's a layout change
-- When isActiveScreenChange is true, it's about the active screen changing
function obj:screenChanged(isActiveScreenChange)
local newScreenConfig = self:getScreenInfo()
-- Check if the configuration has actually changed
if self:isIdenticalConfig(self.currentScreenConfig, newScreenConfig) then
-- No change, don't show notification
-- But we still want to store the new config for the next comparison
self.currentScreenConfig = newScreenConfig
return
end
-- Update stored configuration
self.currentScreenConfig = newScreenConfig
local changeType = isActiveScreenChange and "Active screen changed" or "Screen layout changed"
local info = self:formatScreenInfo(newScreenConfig)
print(info)
-- Create a notification with screen information
local notification = hs.notify.new({
title = changeType,
informativeText = info,
autoWithdraw = true,
hasActionButton = false
})
notification:send()
end
--- ScreenConfigNotifier:getScreenInfo()
--- Method
--- Collect information about all screens as structured data
---
--- Parameters:
--- * None
---
--- Returns:
--- * An array of tables, each containing information about a screen:
--- * index - The screen's index number
--- * name - The screen's name or "Unknown" if not available
--- * uuid - The screen's UUID or "Unknown" if not available
--- * resolution - A table with:
--- * width - The screen's width in pixels
--- * height - The screen's height in pixels
--- * scale - The screen's scaling factor
function obj:getScreenInfo()
local screens = hs.screen.allScreens()
local screenData = {}
for i, screen in ipairs(screens) do
local mode = screen:currentMode()
screenData[i] = {
index = i,
name = screen:name() or "Unknown",
uuid = screen:getUUID() or "Unknown",
resolution = {
width = mode.w,
height = mode.h
},
scale = mode.scale
}
end
return screenData
end
-- Format screen information as a string
function obj:formatScreenInfo(screens)
local info = ""
string.format("Found %d screen(s):\n", #screens)
for _, screen in ipairs(screens) do
local resolution = string.format("%d×%d", screen.resolution.width, screen.resolution.height)
info = info .. string.format(
"Screen %d/%d: %s\n%s @ %.1f×\nUUID: %s\n",
screen.index, #screens, screen.name, resolution, screen.scale, screen.uuid
)
end
return info
end
-- Compare two screen configurations to see if they're identical
function obj:isIdenticalConfig(config1, config2)
if not config1 and not config2 then
return true
elseif not config1 or not config2 then
return false
end
-- Check if the number of screens is the same
if #config1 ~= #config2 then
return false
end
-- Compare screens in order (position matters)
for i, screen1 in ipairs(config1) do
local screen2 = config2[i]
-- Check if UUIDs match
if screen1.uuid ~= screen2.uuid then
return false -- Different screen at this position
end
-- Check properties
if screen1.resolution.width ~= screen2.resolution.width or
screen1.resolution.height ~= screen2.resolution.height or
screen1.scale ~= screen2.scale then
return false -- Resolution or scale has changed
end
end
return true -- Configurations are identical
end
return obj
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment