Hammerspoon / hammerspoon

Staggeringly powerful macOS desktop automation with Lua

Home Page:http://www.hammerspoon.org

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

How do I show an alert on all fullscreen spaces?

NightMachinery opened this issue · comments

I have this code for showing alerts. I want the alerts to be on all my fullscreen spaces (I have many fullscreen apps). I have a single monitor. It currently only shows the alert on the current space.

local hyperStyle = {
    -- [[https://github.com/Hammerspoon/hammerspoon/blob/master/extensions/alert/alert.lua#L17][hammerspoon/extensions/alert/alert.lua at master · Hammerspoon/hammerspoon]]
    -- strokeWidth  = 2,
    -- strokeColor = { white = 1, alpha = 1 },
    -- fillColor   = { white = 0, alpha = 0.75 },
    -- textColor = { white = 1, alpha = 1 },
    -- textFont  = ".AppleSystemUIFont",
    -- textSize  = 27,
    -- radius = 27,
    atScreenEdge = 1,
    fadeInDuration = 0.001,
    fadeOutDuration = 0.001,
    -- padding = nil,
    fillColor = { white = 1, alpha = 2 / 3 },
    radius = 24,
    strokeColor = { red = 19 / 255, green = 182 / 255, blue = 133 / 255, alpha = 1},
    strokeWidth = 16,
    textColor = { white = 0.125 },
    textSize = 48,
}

function hyper_modality:entered()
    hyper_modality.entered_p = true

    -- I have not yet added the redis updaters for purple_modality.
    redisActivateMode("hyper_modality")

    if hs.eventtap.isSecureInputEnabled() then
        hs.alert("⚠️ Secure Input is on. Our Hyper Mode commands might not work.")
        -- [[https://github.com/Hammerspoon/hammerspoon/issues/3555][Hammerspoon hangs spradically when entering hyper mode and displaying a modal window · Issue #3555 · Hammerspoon/hammerspoon]]
    end

    hyperAlerts = {}
    for i, screen in pairs(hs.screen.allScreens()) do
        msg = "🌟"
        -- msg = "Hyper Mode 🌟"
        -- msg = "Hyper Mode ✈"

        alert = hs.alert(msg, hyperStyle, screen, "")
        hyperAlerts[i] = alert
    end
end

function hyper_modality:exited()
    hyper_modality.entered_p = false
    hyper_modality.exit_on_release_p = false

    for i, alert in pairs(hyperAlerts) do
        hs.alert.closeSpecific(alert, 0.25)
    end

    redisDeactivateMode("hyper_modality")
end

TL;DR: There's a hacky way of doing it, but it doesn't work quite right and I haven't figured out how to fix it yet, so you may be better off not using hs.alert for this.


Hammerspoon doesn't seem to have any official way to do that, but if you're okay with directly messing with hs.alert's implementation details behind its back, it is possible to set the 'behavior' of the underlying hs.drawing objects to make them appear in all spaces. (The various behavior flags are supposed to be documented here - or here for hs.canvas - but the specific values aren't currently showing up in the HTML documentation right now. Your best bet is to go to the source code for hs.canvas and read the comments there.)

Attempt #1: Just setting the drawings' behavior
function setAlertBehavior(alertUUID, behavior)
	local method = type(behavior) == 'number' and 'setBehavior' or 'setBehaviorByLabels'
	for _, alert in ipairs(hs.alert._visibleAlerts) do
		if alert.UUID == alertUUID then
			for _, drawing in ipairs(alert.drawings) do
				drawing[method](drawing, behavior)
			end
			break
		end
	end
end

-- Example usage:

local alert = hs.alert('This is a test', 20)
setAlertBehavior(alert, {'canJoinAllSpaces', 'transient', 'fullScreenAuxiliary'}) -- alert will appear in all spaces
-- OR
setAlertBehavior(alert, {'canJoinAllSpaces', 'stationary', 'fullScreenAuxiliary'}) -- alert will appear in all spaces AND Mission Control

You'll need to uncheck 'Show dock icon' in Hammerspoon's preferences for it to be allowed to draw over fullscreen windows.


Unfortunately, even with fullScreenAuxiliary, it still doesn't seem to work quite right. Because hs.alert is based on the old, deprecated hs.drawing module, the background/frame and the text are two separate windows, and they seem to reverse their order for some reason when switching between fullscreen windows and normal Spaces, resulting in the text being behind the (translucent) background. The order stays the same when switching from a normal Space to another normal Space, or from a fullscreen window to another fullscreen window - it's only when I switch from one type to the other that the alert's parts get reversed (or de-reversed).

I tried using an hs.spaces.watcher to bringToFront the drawings in the correct order, but for some reason they still end up reversed.

Attempt #2: Set behavior of drawings, then re-stack when switching Spaces
local allSpacesAlerts = {}
local function fixAllSpacesAlerts()
	for _, alert in pairs(allSpacesAlerts) do
		for _, drawing in ipairs(alert.drawings) do
			drawing:bringToFront()
		end
	end
end
local allSpacesAlertsSpacesWatcher = hs.spaces.watcher.new(fixAllSpacesAlerts)
function showAlertInAllSpaces(alertUUID, showInMissionControl)
	local behaviors = {'canJoinAllSpaces', showInMissionControl and 'stationary' or 'transient', 'fullScreenAuxiliary'}
	for _, alert in ipairs(hs.alert._visibleAlerts) do
		if alert.UUID == alertUUID then
			for _, drawing in ipairs(alert.drawings) do
				drawing:setBehaviorByLabels(behaviors)
			end
			allSpacesAlerts[alertUUID] = alert
			allSpacesAlertsSpacesWatcher:start()
			break
		end
	end
end
-- patch hs.alert to clean up the allSpacesAlerts table when alerts go away
do
	local getupvalue = debug.getupvalue
	local closeSpecific = hs.alert.closeSpecific
	local n = 0
	local name, value
	repeat
		n = n + 1
		name, value = getupvalue(closeSpecific, n)
	until name == 'purgeAlert' or not name
	if name then
		local origPurgeAlert = value
		local function newPurgeAlert(alertUUID, ...)
			allSpacesAlerts[alertUUID] = nil
			if not next(allSpacesAlerts) then
				allSpacesAlertsSpacesWatcher:stop()
			end
			return origPurgeAlert(alertUUID, ...)
		end
		debug.setupvalue(closeSpecific, n, newPurgeAlert)
	else
		error "Couldn't patch hs.alert's purgeAlert function!"
	end
end

-- Example usage:

local alertUUID = hs.alert('This is a test', 10)
showAlertInAllSpaces(alertUUID, true)

I'm not really sure what to try at this point, other than abandoning hs.alert for this purpose and displaying the Hyper mode indicator some other way, e.g. using an hs.canvas.

Attempt #3: Using hs.canvas instead of hs.alert
local hyperStyle = {
    fillColor = { white = 1, alpha = 2 / 3 },
    radius = 24,
    strokeColor = { red = 19 / 255, green = 182 / 255, blue = 133 / 255, alpha = 1},
    strokeWidth = 8,
    textColor = { white = 0.125 },
    textSize = 48,
    text = "🌟",
}

local hyperModeIndicator = hs.canvas.new{x=0, y=0, w=0, h=0}:insertElement{
    id = 'background',
    type = 'rectangle',
    action = 'strokeAndFill',
    fillColor = hyperStyle.fillColor,
    roundedRectRadii = { xRadius = hyperStyle.radius, yRadius = hyperStyle.radius },
    strokeColor = hyperStyle.strokeColor,
    strokeWidth = hyperStyle.strokeWidth,
    padding = hyperStyle.strokeWidth/2,
}:insertElement{
    id = 'textBox',
    type = 'text',
    text = hyperStyle.text,
    textAlignment = 'center',
    textColor = hyperStyle.textColor,
    textSize = hyperStyle.textSize,
}
local hyperTextBoxSize = hyperModeIndicator:minimumTextSize(2, hyperStyle.text)
local screenFrame = hs.screen.primaryScreen():fullFrame()
local hyperFrame = {}
hyperFrame.w = hyperTextBoxSize.w + hyperStyle.strokeWidth*2 + hyperStyle.textSize -- default alert padding is 1/2 of font size, but we need it on both sides
hyperFrame.h = hyperTextBoxSize.h + hyperStyle.strokeWidth*2 + hyperStyle.textSize -- ditto
hyperFrame.x = (screenFrame.w - hyperFrame.w)/2
hyperFrame.y = screenFrame.y -- top edge
-- Hammerspoon can automatically center the text horizontally, but not vertically, so:
hyperModeIndicator.textBox.frame = {
    x = (hyperFrame.w - hyperTextBoxSize.w)/2,
    y = (hyperFrame.h - hyperTextBoxSize.h)/2,
    w = hyperTextBoxSize.w,
    h = hyperTextBoxSize.h,
}
hyperModeIndicator:frame(hyperFrame)
hyperModeIndicator:behavior{'canJoinAllSpaces', 'transient', 'fullScreenAuxiliary'} -- canvas will appear in all spaces
-- OR
hyperModeIndicator:behavior{'canJoinAllSpaces', 'stationary', 'fullScreenAuxiliary'} -- canvas will appear in all spaces AND Mission Control

function hyper_modality:entered()
    hyper_modality.entered_p = true

    -- I have not yet added the redis updaters for purple_modality.
    redisActivateMode("hyper_modality")

    if hs.eventtap.isSecureInputEnabled() then
        hs.alert("⚠️ Secure Input is on. Our Hyper Mode commands might not work.")
        -- [[https://github.com/Hammerspoon/hammerspoon/issues/3555][Hammerspoon hangs spradically when entering hyper mode and displaying a modal window · Issue #3555 · Hammerspoon/hammerspoon]]
    end

    hyperModeIndicator:show()
end

function hyper_modality:exited()
    hyper_modality.entered_p = false
    hyper_modality.exit_on_release_p = false

    hyperModeIndicator:hide()

    redisDeactivateMode("hyper_modality")
end

There may be some subtle differences in how that indicator looks, and I had to cut strokeWidth in half to make it look the same, but it should be pretty close to what you had before. Note that since it's not an hs.alert, any actual hs.alerts with atScreenEdge = 1 won't know about it, and will draw over the top of it.

@Rhys-T Thanks for the detailed writeup. I tried using the new canvas based approach, and it works, but it still only shows up on the current space?

Whoops! Forgot to actually set the behavior on the canvas. Add one of these lines right after hyperModeIndicator:frame(hyperFrame):

hyperModeIndicator:behavior{'canJoinAllSpaces', 'transient', 'fullScreenAuxiliary'} -- canvas will appear in all spaces
-- OR
hyperModeIndicator:behavior{'canJoinAllSpaces', 'stationary', 'fullScreenAuxiliary'} -- canvas will appear in all spaces AND Mission Control

I'll edit that back into my original comment. Sorry for the confusion.