diff --git a/.gitignore b/.gitignore index a56face..c89ba31 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/hammerspoon/.hammerspoon/Makefile b/hammerspoon/.hammerspoon/Makefile index d2e35f3..bd7ac71 100644 --- a/hammerspoon/.hammerspoon/Makefile +++ b/hammerspoon/.hammerspoon/Makefile @@ -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()" diff --git a/hammerspoon/.hammerspoon/init.lua b/hammerspoon/.hammerspoon/init.lua index 5f39080..6b7db84 100644 --- a/hammerspoon/.hammerspoon/init.lua +++ b/hammerspoon/.hammerspoon/init.lua @@ -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") @@ -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) diff --git a/hammerspoon/.hammerspoon/modules/caffeine.lua b/hammerspoon/.hammerspoon/modules/caffeine.lua index fbe1c31..b7fcff5 100644 --- a/hammerspoon/.hammerspoon/modules/caffeine.lua +++ b/hammerspoon/.hammerspoon/modules/caffeine.lua @@ -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) diff --git a/hammerspoon/.hammerspoon/modules/glass_toast.lua b/hammerspoon/.hammerspoon/modules/glass_toast.lua new file mode 100644 index 0000000..24b4978 --- /dev/null +++ b/hammerspoon/.hammerspoon/modules/glass_toast.lua @@ -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 diff --git a/hammerspoon/.hammerspoon/native/GlassToast.swift b/hammerspoon/.hammerspoon/native/GlassToast.swift new file mode 100644 index 0000000..1897102 --- /dev/null +++ b/hammerspoon/.hammerspoon/native/GlassToast.swift @@ -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() diff --git a/hammerspoon/.hammerspoon/native/README.md b/hammerspoon/.hammerspoon/native/README.md new file mode 100644 index 0000000..9d44c63 --- /dev/null +++ b/hammerspoon/.hammerspoon/native/README.md @@ -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. diff --git a/hammerspoon/.hammerspoon/tests/test_glass_toast.lua b/hammerspoon/.hammerspoon/tests/test_glass_toast.lua new file mode 100644 index 0000000..1492fe0 --- /dev/null +++ b/hammerspoon/.hammerspoon/tests/test_glass_toast.lua @@ -0,0 +1,92 @@ +-- Isolated wrapper tests; no windows, processes, or caffeine changes. +local test = {} + +function test.run() + local exists, starts, creates = true, true, true + local tasks = {} + local fallbacks = 0 + local fake_hs = { + configdir = "/test", + fs = { + attributes = function() + return exists and {} or nil + end, + }, + logger = { + new = function() + return { w = function() end } + end, + }, + task = { + new = function(path, callback, args) + if not creates then + return nil + end + local task = { path = path, callback = callback, args = args, running = false } + function task:start() + self.running = starts + return starts and self or false + end + function task:isRunning() + return self.running + end + function task:terminate() + self.running = false + self.terminated = true + self.callback(15, "", "terminated") + end + table.insert(tasks, task) + return task + end, + }, + } + local env = setmetatable({ hs = fake_hs }, { __index = _G }) + local toast = assert(loadfile(hs.configdir .. "/modules/glass_toast.lua", "t", env))() + local function fallback() + fallbacks = fallbacks + 1 + end + + exists = false + toast:show("reload", "Hammerspoon Reloaded", fallback) + assert(fallbacks == 1 and #tasks == 0, "missing binary should fall back") + exists = true + toast:show("enabled", "Enabled", fallback) + assert(tasks[1].running and tasks[1].args[1] == "enabled", "should start native helper") + assert( + tasks[1].path == "/test/native/.build/glass-toast", + "should resolve config-relative binary" + ) + toast:show("disabled", "Disabled", fallback) + assert(tasks[1].terminated and tasks[2].args[1] == "disabled", "should replace previous toast") + assert(fallbacks == 1, "termination of replaced toast must not fall back") + tasks[1].callback(1, "", "stale failure") + assert(fallbacks == 1, "late callback from replaced toast must be ignored") + tasks[2].running = false + tasks[2].callback(1, "", "failure") + assert(fallbacks == 2, "helper failure should fall back") + starts = false + toast:show("enabled", "Enabled", fallback) + assert(fallbacks == 3, "start failure should fall back") + tasks[#tasks].callback(1, "", "late start failure") + assert(fallbacks == 3, "start failure must not fall back twice") + creates = false + toast:show("enabled", "Enabled", fallback) + assert(fallbacks == 4, "task creation failure should fall back") + creates, starts = true, true + toast:show("reload", "Hammerspoon Reloaded", fallback) + assert(tasks[#tasks].args[1] == "reload", "should pass reload kind to helper") + assert(tasks[#tasks].args[2] == "Hammerspoon Reloaded", "should pass reload title to helper") + tasks[#tasks].callback(0, "", "") + assert(fallbacks == 4, "normal exit must not fall back") + toast:show("enabled", "Enabled", fallback) + local previous = tasks[#tasks] + exists = false + toast:show("reload", "Hammerspoon Reloaded", fallback) + assert(previous.terminated, "missing binary fallback should cancel the previous toast") + assert(fallbacks == 5, "missing binary should fall back exactly once after cancellation") + previous.callback(1, "", "late failure") + assert(fallbacks == 5, "cancelled helper must not fall back again") + print("PASS: glass toast fallback, replacement, stale callback, reload, and normal exit tests") +end + +return test