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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ qutebrowser/.qutebrowser/bookmarks/urls
qutebrowser/.qutebrowser/quickmarks

hammerspoon/.hammerspoon/Spoons
hammerspoon/.hammerspoon/native/.build/

gpg/.gnupg/*
!gpg/.gnupg/gpg-agent.conf
Expand Down
17 changes: 17 additions & 0 deletions hammerspoon/.hammerspoon/Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,21 @@
.DEFAULT_GOAL := test
.PHONY: test format native preview-glass

NATIVE_BINARY := native/.build/glass-toast

native: $(NATIVE_BINARY)

$(NATIVE_BINARY): native/GlassToast.swift
mkdir -p native/.build
xcrun swiftc -O -target "$$(uname -m)-apple-macosx13.0" -framework AppKit $< -o $@.tmp
mv -f $@.tmp $@

# Visual preview only; does not change caffeine state.
preview-glass: native
$(NATIVE_BINARY) enabled "Caffeine Enabled" 4

test:
hs -c "dofile(hs.configdir .. '/tests/test_glass_toast.lua').run()"
hs -c "dofile(hs.configdir .. '/tests/test_basic_alerts.lua').run()"
hs -c "dofile(hs.configdir .. '/tests/test_stacking.lua').run()"
hs -c "dofile(hs.configdir .. '/tests/test_manual_dismissal.lua').run()"
Expand Down
5 changes: 4 additions & 1 deletion hammerspoon/.hammerspoon/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ require("modules.plugins")
-- require("modules.alerts")
require("modules.caffeine")

local glass_toast = require("modules.glass_toast")
local helpers = require("modules.helpers")
local wm = require("modules.wm")

Expand Down Expand Up @@ -84,4 +85,6 @@ end)
-- The CLI itself is installed separately, so do not recheck/reinstall it on reload.
require("hs.ipc")

helpers:show("Hammerspoon has been loaded.", nil, nil, nil)
glass_toast:show("reload", "Hammerspoon Reloaded", function()
helpers:show("Hammerspoon Reloaded")
end)
14 changes: 7 additions & 7 deletions hammerspoon/.hammerspoon/modules/caffeine.lua
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
local glass_toast = require("modules.glass_toast")
local helpers = require("modules.helpers")

hs.hotkey.bind(HYPER, "0", function()
hs.caffeinate.toggle("displayIdle")
if hs.caffeinate.get("displayIdle") then
-- helpers:show("Caffeine Enabled", nil, helpers.styles.success, helpers.assets.bolt)
helpers:show("Caffeine Enabled", nil, helpers.styles.success)
else
-- helpers:show("Caffeine Disabled", nil, helpers.styles.error, helpers.assets.bolt_slash)
helpers:show("Caffeine Disabled", nil, helpers.styles.error)
end
local enabled = hs.caffeinate.get("displayIdle")
local title = enabled and "Caffeine Enabled" or "Caffeine Disabled"
local style = enabled and helpers.styles.success or helpers.styles.error
glass_toast:show(enabled and "enabled" or "disabled", title, function()
helpers:show(title, nil, style)
end)
end)
39 changes: 39 additions & 0 deletions hammerspoon/.hammerspoon/modules/glass_toast.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
local obj = {}
local task
local log = hs.logger.new("glass_toast", "warning")

-- Build explicitly with `make native`; never compile on the hotkey path.
-- kind: "enabled", "disabled", or "reload".
function obj:show(kind, title, fallback)
-- Cancel before checking the binary so a fallback also replaces the old toast.
local previous = task
task = nil
if previous and previous:isRunning() then
previous:terminate()
end

local binary = hs.configdir .. "/native/.build/glass-toast"
if not hs.fs.attributes(binary) then
fallback()
return
end

local next_task
next_task = hs.task.new(binary, function(code, _, stderr)
if task ~= next_task then
return
end
task = nil
if code ~= 0 then
log.w("Native toast failed: " .. tostring(stderr))
fallback()
end
end, { kind, title, "2" })
task = next_task
if not next_task or not next_task:start() then
task = nil
fallback()
end
end

return obj
150 changes: 150 additions & 0 deletions hammerspoon/.hammerspoon/native/GlassToast.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import AppKit

private func fail(_ message: String) -> Never {
FileHandle.standardError.write(Data("glass-toast: \(message)\n".utf8))
exit(EXIT_FAILURE)
}

// A short-lived accessory process: no Dock icon, focus change, or notification history.
final class ToastPanel: NSPanel {
override var canBecomeKey: Bool { false }
override var canBecomeMain: Bool { false }
}

final class AppDelegate: NSObject, NSApplicationDelegate {
private var panel: ToastPanel?

func applicationDidFinishLaunching(_ notification: Notification) {
let args = Array(CommandLine.arguments.dropFirst())
guard args.count <= 3 else {
fail("usage: glass-toast [enabled|disabled|reload] [title] [seconds]")
}
let kind = args.first ?? "enabled"
let symbol: String
let defaultTitle: String
let message: String
let tint: NSColor
switch kind {
case "reload":
symbol = "arrow.clockwise"
defaultTitle = "Hammerspoon Reloaded"
message = "Your configuration is ready."
tint = .systemBlue
case "disabled":
symbol = "moon.zzz.fill"
defaultTitle = "Caffeine Disabled"
message = "Normal display sleep restored."
tint = .secondaryLabelColor
case "enabled":
symbol = "cup.and.saucer.fill"
defaultTitle = "Caffeine Enabled"
message = "Your display will stay awake."
tint = .systemGreen
default:
fail("unknown kind: \(kind)")
}
let title = args.count > 1 ? args[1] : defaultTitle
let duration = args.count > 2 ? Double(args[2]) ?? .nan : 2
guard duration.isFinite, (0.2...30).contains(duration) else {
fail("duration must be between 0.2 and 30 seconds")
}
guard let screen = NSScreen.main else {
fail("no screen available")
}

let size = NSSize(width: 360, height: 76)
let frame = screen.visibleFrame
let rect = NSRect(
x: frame.midX - size.width / 2,
y: frame.midY - size.height / 2,
width: size.width,
height: size.height
)
let panel = ToastPanel(
contentRect: rect,
styleMask: [.borderless, .nonactivatingPanel],
backing: .buffered,
defer: false
)
panel.isOpaque = false
panel.backgroundColor = .clear
panel.hasShadow = true
panel.level = .statusBar
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .ignoresCycle]
panel.ignoresMouseEvents = true
panel.hidesOnDeactivate = false
panel.isReleasedWhenClosed = false

let content = NSView(frame: NSRect(origin: .zero, size: size))
if #available(macOS 26.0, *) {
let glass = NSGlassEffectView(frame: content.bounds)
glass.style = .regular
glass.cornerRadius = 22
glass.contentView = content
panel.contentView = glass
} else {
let blur = NSVisualEffectView(frame: content.bounds)
blur.material = .hudWindow
blur.blendingMode = .behindWindow
blur.state = .active
blur.wantsLayer = true
blur.layer?.cornerRadius = 22
blur.layer?.masksToBounds = true
blur.addSubview(content)
panel.contentView = blur
}

let icon = NSImageView()
icon.image = NSImage(systemSymbolName: symbol, accessibilityDescription: nil)?
.withSymbolConfiguration(NSImage.SymbolConfiguration(pointSize: 24, weight: .medium))
icon.contentTintColor = tint
icon.translatesAutoresizingMaskIntoConstraints = false

let heading = NSTextField(labelWithString: title)
heading.font = .systemFont(ofSize: 14, weight: .semibold)
heading.textColor = .labelColor
let detail = NSTextField(labelWithString: message)
detail.font = .systemFont(ofSize: 12)
detail.textColor = .secondaryLabelColor
let labels = NSStackView(views: [heading, detail])
labels.orientation = .vertical
labels.alignment = .leading
labels.spacing = 4
labels.translatesAutoresizingMaskIntoConstraints = false
content.addSubview(icon)
content.addSubview(labels)
NSLayoutConstraint.activate([
icon.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 22),
icon.centerYAnchor.constraint(equalTo: content.centerYAnchor),
icon.widthAnchor.constraint(equalToConstant: 30),
icon.heightAnchor.constraint(equalToConstant: 30),
labels.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 14),
labels.centerYAnchor.constraint(equalTo: content.centerYAnchor),
labels.trailingAnchor.constraint(lessThanOrEqualTo: content.trailingAnchor, constant: -22),
])

self.panel = panel
panel.alphaValue = 0
panel.orderFrontRegardless()
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.15
panel.animator().alphaValue = 1
}
DispatchQueue.main.asyncAfter(deadline: .now() + duration) {
NSAnimationContext.runAnimationGroup(
{ context in
context.duration = 0.18
panel.animator().alphaValue = 0
},
completionHandler: {
NSApp.terminate(nil)
})
}
}
}

let app = NSApplication.shared
app.setActivationPolicy(.accessory)
let delegate = AppDelegate()
app.delegate = delegate
app.run()
60 changes: 60 additions & 0 deletions hammerspoon/.hammerspoon/native/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Native glass toast prototype

A centered, click-through, non-activating AppKit panel for caffeine and reload feedback.
Uses `NSGlassEffectView` on macOS 26+ and `NSVisualEffectView` (`hudWindow`)
on macOS 13–25. No notification permission, Dock icon, or Notification Center
history. Accessibility settings such as Reduce Transparency can change the effect.

## Build and preview

Requires Apple Command Line Tools with a macOS 26+ SDK (for the glass API).
From `hammerspoon/.hammerspoon`:

```sh
make native
make preview-glass
```

The preview does **not** change display-sleep prevention. To preview the other state:

```sh
native/.build/glass-toast disabled "Caffeine Disabled" 4
```

To preview startup/reload feedback:

```sh
native/.build/glass-toast reload "Hammerspoon Reloaded" 4
```

Arguments are kind (`enabled`, `disabled`, or `reload`), optional title, and optional hold
seconds (0.2–30; default 2). Invalid arguments exit nonzero with a diagnostic
so Hammerspoon can fall back to canvas. The panel centers on AppKit's main
screen, fades in and out, and exits. Hammerspoon replaces the previous process on rapid toggles.
This short-lived process is intentionally a prototype, not a persistent service.

## Hammerspoon integration

Stow the `hammerspoon` package so `~/.hammerspoon/native` points here, build,
then reload Hammerspoon. Cmd+Ctrl+0 uses the native toast for caffeine;
startup/reload uses it with an `arrow.clockwise` SF Symbol and blue tint.
Other alerts still use the existing canvas renderer. The Lua wrapper falls back
to canvas if the binary is missing or fails to run; it never compiles on a hotkey.

The generated executable lives in ignored `native/.build/`. Rebuild after source
changes; builds replace the executable atomically, and a binary is not distributed
or committed. To disable the prototype, remove `native/.build/glass-toast`;
caffeine and reload will use the canvas again.

## Validation

```sh
hs -c 'dofile(hs.configdir .. "/tests/test_glass_toast.lua").run()'
xcrun swift-format lint --strict native/GlassToast.swift
```

The wrapper tests use mock tasks and do not show UI or change caffeine state.
They cover native launch, replacement, fallback, stale callbacks, and reload
arguments. Preview each kind to check rendering and focus behavior on macOS.
The older-macOS visual-effect fallback requires separate visual testing on an
older host.
Loading
Loading