COORD: 44.21.90
OFFSET: +12.5°
SYS.READY
BUFFER: 99%
FOCAL_PT
BACK TO DEVLOG
HAMMERSPOON

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:

2026-01-04 // RAW LEARNING CAPTURE
PROJECTHAMMERSPOON

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()
end

This 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:

  1. Catch Cmd+Shift only — Bad, hijacks modifier combo for all apps
  2. Tap again while holding modifiers — Tap S again while still holding Cmd+Shift
  3. 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 captureDecide before capture
Must hold Cmd+Shift entire timeCan 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")
    ...
end

Problem: 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)
end

Key patterns:

  • Canvas elements with id can be updated: canvas["elementId"].property = value
  • hs.timer.doEvery(0.02, fn) for smooth 50fps animation
  • Return true from 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()
end

Built-in sounds: Glass, Pop, Ping, Purr, Sosumi, Blow, Bottle, Frog, Funk, Hero, Morse, Submarine, Tink

Where We Landed

Final UX flow:

  1. Press Cmd+Shift+S → indicator appears: 📷 Debug
  2. Progress bar fills over 0.4s
  3. Threshold crossed → "Pop" sound, switches to gold ⭐ Progress
  4. Release → indicator disappears, screenshot capture begins
  5. If progress → achievement toast with "Glass" sound

File structure:

  • screenshots/debug/debug-YYYYMMDD-HHMMSS.png — downscaled to 1000px
  • screenshots/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.

LOG.ENTRY_END
ref:hammerspoon
RAW