- 01.Starting Point
- 02.First Attempt: hs.chooser (Spotlight-style picker)
- 03.Exploring Double-Tap Detection
- 04.User Testing Results
- 05.First Hold Implementation — Wrong
- 06.Adding Visual Feedback
- 07.Adding Achievement Toast
- 08.Where We Landed
- 09.Commits
- 10.Takeaways
Screenshot Gamification with Hold Detection
Working Hammerspoon screenshot system that routes screenshots to project folders based on macOS Spaces. User realized they needed two categories:
Starting Point
Working Hammerspoon screenshot system that routes screenshots to project folders based on macOS Spaces. User realized they needed two categories:
- Debug — quick shots for immediate debugging, can be downscaled
- Progress — full resolution shots for blog posts, content creation
Goal: distinguish between them with minimal friction.
First Attempt: hs.chooser (Spotlight-style picker)
Added a chooser that appears after capture:
local function showScreenshotChooser(tmpFile)
local chooser = hs.chooser.new(function(choice)
if choice then
processScreenshot(tmpFile, choice.category)
else
os.remove(tmpFile)
end
end)
chooser:choices({
{text = "Debug", subText = "Quick debugging, temporary", category = "debug"},
{text = "Progress", subText = "For blog posts & content", category = "progress"}
})
chooser:width(300)
chooser:rows(2)
chooser:selectedRow(1) -- Pre-select Debug
chooser:show()
endThis worked. User could hit Enter for debug (default) or arrow-down + Enter for progress.
Learned: hs.chooser is powerful:
selectedRow(1)pre-selects first item- Custom fields on choice items pass through to callback
width(),rows()control appearance
But user wanted less friction — no UI at all.
Exploring Double-Tap Detection
User asked: "Does Hammerspoon support double key taps, like cmd+w, cmd+w?"
Not built-in, but implementable. Discussed three strategies:
- Catch Cmd+Shift only — Bad, hijacks modifier combo for all apps
- Tap again while holding modifiers — Tap S again while still holding Cmd+Shift
- Hold duration — Quick tap vs hold
Set up a test harness to try both 2 and 3 without screenshot complexity:
-- Strategy 2: Tap again while holding modifiers (Cmd+Shift+T for testing)
local strategy2Watcher = hs.eventtap.new({hs.eventtap.event.types.keyDown}, function(event)
if not strategy2.active then return false end
local keyCode = event:getKeyCode()
local flags = event:getFlags()
-- T key = keycode 17
if keyCode == 17 and flags.cmd and flags.shift then
strategy2.extraTaps = strategy2.extraTaps + 1
hs.alert("Extra tap! Count: " .. strategy2.extraTaps, 0.3)
return true -- Consume the event
end
return false
end)-- Strategy 3: Hold duration (Cmd+Shift+Y for testing)
hs.hotkey.bind({"cmd", "shift"}, "Y",
function() -- Press
strategy3.pressTime = hs.timer.secondsSinceEpoch()
end,
function() -- Release
local held = hs.timer.secondsSinceEpoch() - strategy3.pressTime
local category = held > 0.5 and "PROGRESS" or "DEBUG"
hs.alert("Result: " .. category)
end
)Key discovery: hs.hotkey.bind takes separate press and release callbacks:
hs.hotkey.bind(mods, key, pressedFn, releasedFn)User Testing Results
Both strategies worked. Tradeoffs:
| Strategy 2 (tap-again) | Strategy 3 (hold) |
|---|---|
| Decide during capture | Decide before capture |
| Must hold Cmd+Shift entire time | Can release modifiers |
| More complex (eventtap) | Simpler implementation |
User's input: "I always know before — the intent starts my mind toward the hotkeys"
Decision: Strategy 3 (hold duration). Simpler, matches mental model.
First Hold Implementation — Wrong
Initial attempt detected timing after capture:
-- WRONG: This detects double-tap AFTER screenshot completes
if pendingScreenshot then
pendingScreenshot.timer:stop()
processScreenshot(pendingScreenshot.tmpFile, "progress")
...
endProblem: User has to take two screenshots within 0.4s, which is impossible when drawing a selection takes seconds.
Fix: Detect on hotkey press, before capture starts:
local selectionPressTime = 0
hs.hotkey.bind({"cmd", "shift"}, "S",
function()
selectionPressTime = hs.timer.secondsSinceEpoch()
end,
function()
local held = hs.timer.secondsSinceEpoch() - selectionPressTime
local category = held >= holdThreshold and "progress" or "debug"
takeScreenshot("-i", category)
end
)Adding Visual Feedback
User couldn't tell which mode they were in while holding. Added a hold indicator with hs.canvas:
local function showHoldIndicator()
local screen = hs.screen.mainScreen()
local screenFrame = screen:frame()
holdIndicator = hs.canvas.new({x = x, y = y, w = 140, h = 36})
holdIndicator:appendElements({
{id = "bg", type = "rectangle", ...},
{id = "progress", type = "rectangle", frame = {x = 10, y = 30, w = 0, h = 3}, ...},
{id = "mode", type = "text", text = "📷 Debug", ...},
})
holdIndicator:show()
-- Animate progress bar
holdTimer = hs.timer.doEvery(0.02, function()
local elapsed = hs.timer.secondsSinceEpoch() - startTime
local progress = math.min(elapsed / holdThreshold, 1)
-- Update progress bar width
holdIndicator["progress"].frame = {x = 10, y = 30, w = maxWidth * progress, h = 3}
if progress >= 1 then
-- Switch to progress mode
holdIndicator["mode"].text = "⭐ Progress"
holdIndicator["mode"].textColor = {red = 1, green = 0.84, blue = 0, alpha = 1}
hs.sound.getByName("Pop"):play()
return true -- Stop timer
end
end)
endKey patterns:
- Canvas elements with
idcan be updated:canvas["elementId"].property = value hs.timer.doEvery(0.02, fn)for smooth 50fps animation- Return
truefrom timer callback to stop it
Adding Achievement Toast
For progress screenshots, show a game-style achievement:
local function showAchievementToast(title, subtitle)
local toast = hs.canvas.new({x = x, y = y, w = 320, h = 70})
toast:appendElements({
-- Shadow
{type = "rectangle", fillColor = {alpha = 0.3}, ...},
-- Background with gold border
{type = "rectangle", strokeColor = {red = 1, green = 0.84, blue = 0}, ...},
-- Star icon
{type = "text", text = "⭐", textSize = 32, ...},
-- Title in gold
{type = "text", text = title, textColor = {red = 1, green = 0.84, blue = 0}, ...},
-- Subtitle
{type = "text", text = subtitle, ...},
})
-- Fade in
toast:alpha(0)
toast:show()
hs.timer.doEvery(0.02, function()
steps = steps - 1
toast:alpha((10 - steps) / 10)
if steps <= 0 then
-- Hold, then fade out
hs.timer.doAfter(2, function()
-- Fade out animation...
toast:delete()
end)
return true
end
end)
hs.sound.getByName("Glass"):play()
endBuilt-in sounds: Glass, Pop, Ping, Purr, Sosumi, Blow, Bottle, Frog, Funk, Hero, Morse, Submarine, Tink
Where We Landed
Final UX flow:
- Press
Cmd+Shift+S→ indicator appears:📷 Debug - Progress bar fills over 0.4s
- Threshold crossed → "Pop" sound, switches to gold
⭐ Progress - Release → indicator disappears, screenshot capture begins
- If progress → achievement toast with "Glass" sound
File structure:
screenshots/debug/debug-YYYYMMDD-HHMMSS.png— downscaled to 1000pxscreenshots/progress/progress-YYYYMMDD-HHMMSS.png— full resolution
Commits
7b6f93d Add screenshot category chooser (Debug vs Progress)
0b90ccd Gamify progress screenshots with hold detection
Takeaways
hs.hotkey.bind press/release pattern — The two-callback form is perfect for hold detection:
hs.hotkey.bind(mods, key, pressedFn, releasedFn)hs.canvas for custom UI — Much more flexible than hs.alert. Elements with IDs can be updated in-place for animations.
hs.eventtap — Can intercept raw keyboard events, useful for detecting keypresses during other operations (like tapping again while holding modifiers).
0.4s threshold — Felt right for "intentional hold" vs "quick tap". Long enough to be deliberate, short enough to not feel slow.
Gamification works — Small dopamine hit for progress captures. Makes documentation feel rewarding.