Skip to content

Instantly share code, notes, and snippets.

@spilist
Created June 5, 2026 21:21
Show Gist options
  • Select an option

  • Save spilist/92530348ccec56fdc66d2e458eaee285 to your computer and use it in GitHub Desktop.

Select an option

Save spilist/92530348ccec56fdc66d2e458eaee285 to your computer and use it in GitHub Desktop.
Hammerspoon posture reminder — a randomized 'sit up straight!' nudge with sound, jittered timing/position, and a menubar countdown
-- posture_reminder.lua — a "sit up straight!" nudge for Hammerspoon
--
-- A periodic reminder that pops a big card in the middle of your screen and
-- plays a sound, telling you to straighten your back. To keep your brain from
-- tuning it out, BOTH the timing and the on-screen position are randomly
-- jittered every time, and the message is picked at random from a list.
--
-- It also adds a 🪑 menubar item with a live countdown, a "remind me now"
-- action, and pause/resume.
--
-- ---------------------------------------------------------------------------
-- Install (requires Hammerspoon — https://www.hammerspoon.org)
-- ---------------------------------------------------------------------------
-- 1. Save this file as ~/.hammerspoon/posture_reminder.lua
-- 2. Add this line to ~/.hammerspoon/init.lua :
--
-- dofile(hs.configdir .. "/posture_reminder.lua")
--
-- 3. Reload Hammerspoon (menubar → Reload Config). A 🪑 icon appears and the
-- first reminder fires within ~7–13 minutes.
--
-- Tune everything in M.config below, then reload. Messages are in Korean by
-- default ("Straighten your back!") — edit M.config.messages to taste.
--
-- License: MIT. Do whatever you want with it.
local M = {}
-- ---------------------------------------------------------------------------
-- config
-- ---------------------------------------------------------------------------
M.config = {
baseInterval = 10 * 60, -- 기본 간격(초). 10분
timeJitter = 0.3, -- 시간 지터 ±30% → 실제 간격 7~13분 사이 랜덤
posJitter = 0.22, -- 위치 지터: 화면 중앙에서 가로/세로 ±22%까지 랜덤
displayFor = 6, -- 알림이 화면에 떠 있는 시간(초)
sound = "Hero", -- hs.sound 시스템 사운드 이름. nil 이면 무음
speak = false, -- true 면 TTS 로도 읽어줌 (speakText 사용)
speakText = "허리 펴세요",
fadeIn = 0.25,
fadeOut = 0.4,
-- 매번 무작위로 하나를 골라 표시 → 같은 문구에 익숙해지지 않게
messages = {
"🪑 허리 펴라!",
"🧘 등 곧게, 어깨 활짝",
"⬆️ 골반 세우고 허리 세우기",
"🙆 자세 리셋 타임",
"👀 모니터 멀어졌나 확인!",
},
}
local cfg = M.config
-- ---------------------------------------------------------------------------
-- state
-- ---------------------------------------------------------------------------
local timer = nil
local menubar = nil
local activeCanvas = nil
local speaker = nil
M.paused = false
M.nextFireAt = nil
math.randomseed(os.time())
-- ---------------------------------------------------------------------------
-- the reminder itself
-- ---------------------------------------------------------------------------
local function showBox(msg)
-- 이전 알림이 남아 있으면 정리
if activeCanvas then activeCanvas:delete(); activeCanvas = nil end
-- 마우스가 있는 화면에 띄워 멀티모니터에서도 보이게
local screen = hs.mouse.getCurrentScreen() or hs.screen.mainScreen()
local f = screen:frame()
local boxW, boxH = 460, 130
-- 화면 중앙 + 랜덤 지터, 그리고 화면 밖으로 나가지 않게 clamp
local cx, cy = f.x + f.w / 2, f.y + f.h / 2
local jx = (math.random() * 2 - 1) * f.w * cfg.posJitter
local jy = (math.random() * 2 - 1) * f.h * cfg.posJitter
local x = math.max(f.x, math.min(cx + jx - boxW / 2, f.x + f.w - boxW))
local y = math.max(f.y, math.min(cy + jy - boxH / 2, f.y + f.h - boxH))
local c = hs.canvas.new({ x = x, y = y, w = boxW, h = boxH })
c:level(hs.canvas.windowLevels.overlay)
c:behaviorAsLabels({ "canJoinAllSpaces", "stationary" })
c[1] = {
type = "rectangle", action = "fill",
roundedRectRadii = { xRadius = 20, yRadius = 20 },
fillColor = { red = 0.05, green = 0.05, blue = 0.07, alpha = 0.9 },
trackMouseUp = true,
}
c[2] = {
type = "rectangle", action = "stroke",
roundedRectRadii = { xRadius = 20, yRadius = 20 },
strokeColor = { white = 1, alpha = 0.15 }, strokeWidth = 1,
}
c[3] = {
type = "text", text = msg,
textColor = { white = 1 },
textSize = 40, textAlignment = "center",
frame = { x = 0, y = (boxH - 52) / 2, w = boxW, h = 60 },
}
local dismissed = false
local function hide()
if dismissed then return end
dismissed = true
if activeCanvas == c then activeCanvas = nil end
c:delete(cfg.fadeOut)
end
c:mouseCallback(hide) -- 클릭하면 바로 닫힘
c:canvasMouseEvents(true)
c:show(cfg.fadeIn)
activeCanvas = c
hs.timer.doAfter(cfg.displayFor, hide)
end
local function fire()
if cfg.sound then
local snd = hs.sound.getByName(cfg.sound)
if snd then snd:play() end
end
if cfg.speak then
speaker = speaker or hs.speech.new()
if speaker then speaker:speak(cfg.speakText) end
end
showBox(cfg.messages[math.random(#cfg.messages)])
end
-- ---------------------------------------------------------------------------
-- scheduling (reschedules with fresh jitter after every fire)
-- ---------------------------------------------------------------------------
local function nextInterval()
local j = (math.random() * 2 - 1) * cfg.timeJitter
return cfg.baseInterval * (1 + j)
end
local function schedule()
if timer then timer:stop(); timer = nil end
if M.paused then return end
local wait = nextInterval()
M.nextFireAt = os.time() + math.floor(wait)
timer = hs.timer.doAfter(wait, function()
fire()
schedule()
end)
end
-- ---------------------------------------------------------------------------
-- menubar (pause / resume / fire now / live countdown)
-- ---------------------------------------------------------------------------
local function fmtCountdown()
if M.paused or not M.nextFireAt then return "일시정지됨" end
local rem = math.max(0, M.nextFireAt - os.time())
return string.format("%d분 %d초 후", math.floor(rem / 60), rem % 60)
end
local function updateMenu()
if not menubar then return end
menubar:setTitle(M.paused and "🪑⏸" or "🪑")
menubar:setMenu(function()
return {
{ title = "다음 알림: " .. fmtCountdown(), disabled = true },
{ title = "-" },
{ title = "지금 알림", fn = function() M.now() end },
{ title = M.paused and "재개" or "일시정지", fn = function() M.toggle() end },
}
end)
end
-- ---------------------------------------------------------------------------
-- public API
-- ---------------------------------------------------------------------------
function M.now()
fire()
end
function M.toggle()
M.paused = not M.paused
if M.paused then
if timer then timer:stop(); timer = nil end
M.nextFireAt = nil
else
schedule()
end
updateMenu()
end
function M.start()
M.paused = false
schedule()
updateMenu()
end
function M.cleanup()
if timer then timer:stop(); timer = nil end
if activeCanvas then activeCanvas:delete(); activeCanvas = nil end
if menubar then menubar:delete(); menubar = nil end
M.nextFireAt = nil
end
-- 리로드 시 이전 인스턴스(타이머/메뉴바) 중복 방지
if _G.__postureReminder and _G.__postureReminder.cleanup then
pcall(_G.__postureReminder.cleanup)
end
_G.__postureReminder = M
menubar = hs.menubar.new()
M.start()
return M
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment