Created
June 5, 2026 21:21
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| -- 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