Last active
April 1, 2026 21:03
-
-
Save audionerd/de354626d83e94c3c22eb13e302cb139 to your computer and use it in GitHub Desktop.
Hammerspoon script to add a mini calendar to the macOS menu bar
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
| local PANEL_WIDTH = 300 | |
| local PANEL_HEIGHT = 226 | |
| local PANEL_TOP_MARGIN = 2 | |
| local MENUBAR_AUTOSAVE_NAME = "minical" | |
| local MENUBAR_TITLE = "📅" | |
| local function normalizeMonth(year, month) | |
| while month < 1 do | |
| month = month + 12 | |
| year = year - 1 | |
| end | |
| while month > 12 do | |
| month = month - 12 | |
| year = year + 1 | |
| end | |
| return year, month | |
| end | |
| local function daysInMonth(year, month) | |
| return os.date("*t", os.time({ year = year, month = month + 1, day = 0, hour = 12 })).day | |
| end | |
| local function monthTitle(year, month) | |
| return os.date("%B %Y", os.time({ year = year, month = month, day = 1, hour = 12 })) | |
| end | |
| local function buildCalendarWeeks(year, month) | |
| local today = os.date("*t") | |
| local firstWday = os.date("*t", os.time({ year = year, month = month, day = 1, hour = 12 })).wday | |
| local currentMonthDays = daysInMonth(year, month) | |
| local prevYear, prevMonth = normalizeMonth(year, month - 1) | |
| local prevMonthDays = daysInMonth(prevYear, prevMonth) | |
| local lead = firstWday - 1 | |
| local total = lead + currentMonthDays | |
| local trail = (7 - (total % 7)) % 7 | |
| local count = total + trail | |
| local cells = {} | |
| local startDay = 1 - lead | |
| for offset = 0, count - 1 do | |
| local day = startDay + offset | |
| local cell = { day = day, inMonth = true, isToday = false } | |
| if day < 1 then | |
| cell.day = prevMonthDays + day | |
| cell.inMonth = false | |
| cell.isToday = today.year == prevYear and today.month == prevMonth and today.day == cell.day | |
| elseif day > currentMonthDays then | |
| cell.day = day - currentMonthDays | |
| cell.inMonth = false | |
| local nextYear, nextMonth = normalizeMonth(year, month + 1) | |
| cell.isToday = today.year == nextYear and today.month == nextMonth and today.day == cell.day | |
| else | |
| cell.isToday = today.year == year and today.month == month and today.day == day | |
| end | |
| table.insert(cells, cell) | |
| end | |
| local weeks = {} | |
| for i = 1, #cells, 7 do | |
| local week = {} | |
| for j = i, i + 6 do | |
| table.insert(week, cells[j]) | |
| end | |
| table.insert(weeks, week) | |
| end | |
| return weeks | |
| end | |
| local function renderCalendarHtml(year, month) | |
| local weeks = buildCalendarWeeks(year, month) | |
| local weekRows = {} | |
| for _, week in ipairs(weeks) do | |
| local dayCells = {} | |
| for _, cell in ipairs(week) do | |
| local classes = { "day" } | |
| if not cell.inMonth then | |
| table.insert(classes, "outside") | |
| end | |
| if cell.isToday then | |
| table.insert(classes, "today") | |
| end | |
| table.insert(dayCells, string.format( | |
| "<div class=\"%s\">%2d</div>", | |
| table.concat(classes, " "), | |
| cell.day | |
| )) | |
| end | |
| table.insert(weekRows, string.format("<div class=\"week\">%s</div>", table.concat(dayCells, ""))) | |
| end | |
| return string.format([[<!doctype html> | |
| <html> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <style> | |
| :root { | |
| color-scheme: light dark; | |
| } | |
| html, | |
| body { | |
| margin: 0; | |
| padding: 0; | |
| width: 100%%; | |
| height: 100%%; | |
| overflow: hidden; | |
| font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, "Cascadia Mono", "Segoe UI Mono", "Roboto Mono", monospace; | |
| background: transparent; | |
| } | |
| .panel { | |
| position: absolute; | |
| top: 8px; | |
| left: 0; | |
| right: 0; | |
| bottom: 0; | |
| z-index: 2; | |
| box-sizing: border-box; | |
| padding: 12px; | |
| color: #ececec; | |
| background: rgba(36, 36, 38, 0.92); | |
| border: 1px solid rgba(255, 255, 255, 0.14); | |
| border-radius: 12px; | |
| box-shadow: 0 8px 24px rgba(0, 0, 0, 0.34); | |
| overflow: hidden; | |
| } | |
| .nub { | |
| position: absolute; | |
| top: 2px; | |
| left: 50%%; | |
| width: 10px; | |
| height: 10px; | |
| z-index: 3; | |
| transform: translateX(-50%%) rotate(45deg); | |
| background: rgba(36, 36, 38, 0.92); | |
| border-left: 1px solid rgba(255, 255, 255, 0.14); | |
| border-top: 1px solid rgba(255, 255, 255, 0.14); | |
| } | |
| .nub-bridge { | |
| position: absolute; | |
| top: 8px; | |
| left: 50%%; | |
| width: 16px; | |
| height: 4px; | |
| z-index: 4; | |
| transform: translateX(-50%%); | |
| background: rgba(36, 36, 38, 0.92); | |
| } | |
| .titlebar { | |
| display: grid; | |
| grid-template-columns: 1fr auto 1fr; | |
| align-items: center; | |
| margin-bottom: 8px; | |
| } | |
| .title { | |
| font-size: 14px; | |
| line-height: 20px; | |
| text-align: center; | |
| letter-spacing: 0.02em; | |
| } | |
| .controls-left, | |
| .controls-right { | |
| display: flex; | |
| align-items: center; | |
| gap: 6px; | |
| } | |
| .controls-right { | |
| justify-content: flex-end; | |
| } | |
| .ctrl { | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| min-width: 22px; | |
| height: 20px; | |
| padding: 0 6px; | |
| border-radius: 6px; | |
| color: #d9d9d9; | |
| text-decoration: none; | |
| font-size: 12px; | |
| line-height: 20px; | |
| } | |
| .ctrl:hover { | |
| background: rgba(255, 255, 255, 0.12); | |
| } | |
| .ctrl.arrow { | |
| font-size: 14px; | |
| } | |
| .header, | |
| .week { | |
| display: grid; | |
| grid-template-columns: repeat(7, 1fr); | |
| column-gap: 4px; | |
| } | |
| .header { | |
| margin-bottom: 4px; | |
| color: #b9b9b9; | |
| font-size: 12px; | |
| line-height: 16px; | |
| } | |
| .header div, | |
| .day { | |
| text-align: center; | |
| } | |
| .week { | |
| margin-bottom: 2px; | |
| } | |
| .day { | |
| font-size: 14px; | |
| line-height: 22px; | |
| border-radius: 6px; | |
| } | |
| .outside { | |
| color: #828282; | |
| } | |
| .today { | |
| color: #111111; | |
| background: #f0f0f0; | |
| font-weight: 700; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="nub"></div> | |
| <div class="nub-bridge"></div> | |
| <div class="panel"> | |
| <div class="titlebar"> | |
| <div class="controls-left"> | |
| <a class="ctrl arrow" href="minical://prev">‹</a> | |
| </div> | |
| <div class="title">%s</div> | |
| <div class="controls-right"> | |
| <!-- <a class="ctrl" href="minical://today">Today</a> --> | |
| <a class="ctrl arrow" href="minical://next">›</a> | |
| </div> | |
| </div> | |
| <div class="header"> | |
| <div>Su</div><div>Mo</div><div>Tu</div><div>We</div><div>Th</div><div>Fr</div><div>Sa</div> | |
| </div> | |
| %s | |
| </div> | |
| </body> | |
| </html>]], monthTitle(year, month), table.concat(weekRows, "")) | |
| end | |
| local minical = _G.minical or {} | |
| if minical.destroy then | |
| minical.destroy() | |
| end | |
| local menu = hs.menubar.new(true, MENUBAR_AUTOSAVE_NAME) | |
| local webview | |
| local outsideClickTap | |
| local viewYear | |
| local viewMonth | |
| local function pointInRect(point, rect) | |
| return point.x >= rect.x and point.x <= rect.x + rect.w and point.y >= rect.y and point.y <= rect.y + rect.h | |
| end | |
| local function panelFrame() | |
| local screenFrame = hs.screen.mainScreen():frame() | |
| local menuFrame = menu:frame() | |
| local x = screenFrame.x + screenFrame.w - PANEL_WIDTH - 8 | |
| local y = screenFrame.y + PANEL_TOP_MARGIN | |
| if menuFrame then | |
| x = menuFrame.x + (menuFrame.w / 2) - (PANEL_WIDTH / 2) | |
| y = menuFrame.y + menuFrame.h + PANEL_TOP_MARGIN | |
| end | |
| if x < screenFrame.x + 8 then | |
| x = screenFrame.x + 8 | |
| end | |
| local maxX = screenFrame.x + screenFrame.w - PANEL_WIDTH - 8 | |
| if x > maxX then | |
| x = maxX | |
| end | |
| return hs.geometry.rect(x, y, PANEL_WIDTH, PANEL_HEIGHT) | |
| end | |
| local function stopOutsideClickTap() | |
| if outsideClickTap then | |
| outsideClickTap:stop() | |
| outsideClickTap = nil | |
| end | |
| end | |
| local function hidePanel() | |
| stopOutsideClickTap() | |
| if webview and webview:isVisible() then | |
| webview:hide() | |
| end | |
| end | |
| local function setViewToCurrentMonth() | |
| local now = os.date("*t") | |
| viewYear = now.year | |
| viewMonth = now.month | |
| end | |
| local function shiftViewMonth(delta) | |
| viewYear, viewMonth = normalizeMonth(viewYear, viewMonth + delta) | |
| end | |
| local function refreshPanel() | |
| if not webview then | |
| return | |
| end | |
| webview:html(renderCalendarHtml(viewYear, viewMonth)) | |
| end | |
| local function startOutsideClickTap() | |
| stopOutsideClickTap() | |
| outsideClickTap = hs.eventtap.new({ | |
| hs.eventtap.event.types.leftMouseDown, | |
| hs.eventtap.event.types.rightMouseDown, | |
| hs.eventtap.event.types.otherMouseDown, | |
| }, function() | |
| if not webview or not webview:isVisible() then | |
| return false | |
| end | |
| local point = hs.mouse.absolutePosition() | |
| local wvFrame = webview:frame() | |
| if pointInRect(point, wvFrame) then | |
| return false | |
| end | |
| local menuFrame = menu:frame() | |
| if menuFrame and pointInRect(point, menuFrame) then | |
| return false | |
| end | |
| hidePanel() | |
| return false | |
| end) | |
| outsideClickTap:start() | |
| end | |
| local function ensureWebview() | |
| if webview then | |
| return | |
| end | |
| webview = hs.webview.new(panelFrame()) | |
| :allowTextEntry(false) | |
| :transparent(true) | |
| :policyCallback(function(action, _, details) | |
| if action ~= "navigationAction" or not details or not details.request then | |
| return true | |
| end | |
| local url = details.request.URL or details.request.url | |
| if type(url) ~= "string" then | |
| return true | |
| end | |
| if url == "minical://prev" then | |
| shiftViewMonth(-1) | |
| refreshPanel() | |
| return false | |
| end | |
| if url == "minical://next" then | |
| shiftViewMonth(1) | |
| refreshPanel() | |
| return false | |
| end | |
| if url == "minical://today" then | |
| setViewToCurrentMonth() | |
| refreshPanel() | |
| return false | |
| end | |
| return true | |
| end) | |
| end | |
| local function showPanel() | |
| ensureWebview() | |
| setViewToCurrentMonth() | |
| webview:frame(panelFrame()) | |
| refreshPanel() | |
| webview:show() | |
| webview:bringToFront(true) | |
| startOutsideClickTap() | |
| end | |
| local function togglePanel() | |
| if webview and webview:isVisible() then | |
| hidePanel() | |
| else | |
| showPanel() | |
| end | |
| end | |
| menu:setTitle(MENUBAR_TITLE) | |
| menu:autosaveName(MENUBAR_AUTOSAVE_NAME) | |
| menu:setClickCallback(togglePanel) | |
| function minical.destroy() | |
| stopOutsideClickTap() | |
| if webview then | |
| webview:delete() | |
| webview = nil | |
| end | |
| if menu then | |
| menu:delete() | |
| end | |
| end | |
| minical.menu = menu | |
| minical.toggle = togglePanel | |
| _G.minical = minical | |
| return minical |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment