-
-
Save mgkennard/e60ea3b354963293035a315d4b9d69f0 to your computer and use it in GitHub Desktop.
| local clockingLog = hs.logger.new("clocking") | |
| local clockingMenu = hs.menubar.new() | |
| local currentTask = nil | |
| local function trim(s) | |
| return (s:gsub("^%s*(.-)%s*$", "%1")) | |
| end | |
| local function eval(sexp, callback) | |
| hs.task.new( | |
| "/usr/local/bin/emacsclient", | |
| function(exitCode, stdOut, stdErr) | |
| if exitCode == 0 then | |
| callback(trim(stdOut)) | |
| end | |
| end, | |
| { "--eval", sexp } | |
| ):start() | |
| end | |
| local function updateClockingMenu() | |
| eval( | |
| "(org-clock-is-active)", | |
| function(value) | |
| if value == "nil" then | |
| clockingMenu:setTitle("No Task") | |
| else | |
| eval( | |
| "(org-clock-get-clock-string)", | |
| function(value) | |
| clockingMenu:setTitle(string.match(value, '"(.+)"')) | |
| end | |
| ) | |
| end | |
| end | |
| ) | |
| end | |
| local function startUpdatingClockingMenu() | |
| hs.timer.doEvery(10, updateClockingMenu) | |
| end | |
| local mod = {} | |
| function mod.init() | |
| updateClockingMenu() | |
| startUpdatingClockingMenu() | |
| end | |
| return mod |
I need to revisit this. For me the refresh seems to have broken completely recently. I did notice that the refresh broke when macOS came back from being suspended. I probably need to take a look at some of the recent updates to Hammerspoon and see if they've changed anything that this script uses.
Continue to pay attention, after optimization, hope to be notified
I just added return to the lines 41 and 48:
41c41
< hs.timer.doEvery(5, updateClockingMenu)
---
> return hs.timer.doEvery(5, updateClockingMenu)
48c48
< startUpdatingClockingMenu()
---
> return startUpdatingClockingMenu()
And binded the timer into a global variable in my init.lua:
local clocking = require "clocking"
dateTimeGarbageCollectorPreventer = clocking.init()Now it works without any problems. Here is the issue that I found the solution: Hammerspoon/hammerspoon#1942 TLDR; timer gets garbage collected for some reason, when we bind it to a some global value, it doesn't get garbage collected.
It will track org-clock-in and org-clock-out with a menu title. Also, you will to install a Hammerspoon CLI. In hammerspoon console do:
require("hs.ipc")
hs.ipc.cliInstall()
Check in console with which hs
Here are emacs doom and hammerspoon code below:
;;; Hammerspoon status hooks for org-clock
(defun my/hs-org-clock-status ()
"Return 'HH:MM Title' if clocking, else empty string (no text properties)."
(require 'org)
(require 'org-clock)
(if (org-clocking-p)
(let* ((mins (org-clock-get-clocked-time))
(h (/ mins 60))
(m (% mins 60))
;; org-clock-current-task is often PROPERTIZED → strip it:
(raw (or org-clock-current-task
(ignore-errors (org-get-heading t t t t))
"")) ;; fallback if no heading
(title (string-trim
(replace-regexp-in-string
"[\n\r]+" " "
(substring-no-properties raw)))))
(format "%02d:%02d %s" h m title))
""))
(defun my/hs-org-clock-out ()
"Clock out if clocking. Returns a simple status string."
(require 'org-clock)
(if (org-clocking-p)
(progn
(org-clock-out nil t)
"clocked-out")
"no-clock"))
(defun my/hs-org-clock-out ()
"Clock out if clocking. Returns a small status string."
(require 'org-clock)
(if (org-clocking-p)
(progn (org-clock-out nil t) "clocked-out")
"no-clock"))
(defun my/hs-org-clock-goto ()
"Jump to the currently clocked task. Returns status."
(require 'org-clock)
(if (org-clocking-p)
(progn
(org-clock-goto)
;; bring Emacs to front when in GUI
(when (display-graphic-p)
(ignore-errors (raise-frame))
(ignore-errors (select-frame-set-input-focus (selected-frame))))
"goto")
"no-clock"))
(defun my/hs--cli (expr)
(let* ((hs (or (executable-find "hs")
"/opt/homebrew/bin/hs"
"/usr/local/bin/hs")))
(when (and hs (file-executable-p hs))
;; non-blocking; no window
(start-process "hs-ping" nil hs "-c" expr))))
(defun my/hs-on-clock-change (&rest _)
(require 'org-clock)
(my/hs--cli (format "OrgClockMenubar_push('%s')"
(if (org-clocking-p) "in" "out"))))
(add-hook 'org-clock-in-hook #'my/hs-on-clock-change)
(add-hook 'org-clock-out-hook #'my/hs-on-clock-change)
(add-hook 'org-clock-cancel-hook #'my/hs-on-clock-change)Here is lua code
local log = hs.logger.new("orgclock", "info")
local menu = hs.menubar.new()
local emacsTask = nil
local lastActive = false
local emacsclientPath = nil
local triedLaunch = false
_G.OrgClockMenubar = _G.OrgClockMenubar or {}
menu:setTitle("⏱")
-- small utils
local function trim(s)
return (s or ""):gsub("^%s*(.-)%s*$", "%1")
end
local function unquote(s)
return s and (s:gsub('^"(.*)"$', "%1")) or s
end
local function exists(p)
return p and hs.fs.attributes(p) ~= nil
end
local function truncate(s, max)
if #s <= max then
return s
end
return s:sub(1, max - 1) .. "…"
end
-- UTF-8 helpers (Lua 5.3+ has the utf8 lib; Hammerspoon includes it)
local function utf8safeLen(s)
local len = utf8.len(s)
if len then
return len
end -- already valid UTF-8
-- sanitize invalid byte sequences so we never crash later
return utf8.len(s or "") or #(s or "")
end
local function utf8sub(s, maxChars)
-- return first maxChars UTF-8 codepoints of s
if maxChars <= 0 then
return ""
end
local i = utf8.offset(s, maxChars + 1)
if i then
return s:sub(1, i - 1)
end
-- i is nil when maxChars >= length; return whole string
return s
end
-- Truncate by characters, never break a multibyte codepoint
local function truncateUtf8(s, maxChars)
s = s or ""
if utf8safeLen(s) <= maxChars then
return s
end
return utf8sub(s, maxChars) .. "…"
end
-- Optional: prefer cutting on a word boundary if one exists near the end
local function truncateUtf8WordBoundary(s, maxChars)
s = s or ""
if utf8safeLen(s) <= maxChars then
return s
end
local head = utf8sub(s, maxChars) -- safe head
-- try to backtrack to last separator to avoid chopping words
-- separators: space, tab, dash, slash, underscore, dot, comma, colon, semicolon
local lastSep = head:find("[ \t%-%./_,;:][^ \t%-%./_,;:]*$") -- index of last sep chunk
if lastSep and lastSep > math.floor(maxChars * 0.6) then
head = head:sub(1, lastSep) -- keep up to/including the sep
end
head = head:gsub("%s+$", "") -- strip trailing whitespace
return head .. "…"
end
-- Find emacsclient in several common places
local function findEmacsclient()
local candidates = {
"/opt/homebrew/bin/emacsclient", -- Apple Silicon Homebrew
"/usr/local/bin/emacsclient", -- Intel Homebrew
"/Applications/Emacs.app/Contents/MacOS/bin/emacsclient", -- Emacs.app
}
for _, p in ipairs(candidates) do
if exists(p) then
return p
end
end
-- last resort: ask the shell
local out = hs.execute("/usr/bin/env which emacsclient")
out = trim(out or "")
if out ~= "" and exists(out) then
return out
end
return nil
end
emacsclientPath = findEmacsclient()
if not emacsclientPath then
log.e("Could not find emacsclient in PATH or known locations.")
end
-- generic eval wrapper with single-flight protection
local function eval(sexp, callback)
if not emacsclientPath then
return
end
if emacsTask and emacsTask:isRunning() then
log.i("Skip emacsclient: previous call still running")
return
end
emacsTask = hs.task.new(emacsclientPath, function(exitCode, stdout, stderr)
emacsTask = nil
stdout, stderr = stdout or "", stderr or ""
if exitCode == 0 then
local value = trim(stdout)
callback(value)
else
-- Common failure: server not running
if stderr:lower():find("server") or stderr:lower():find("socket") then
log.w("emacsclient: server not running. stderr: " .. stderr)
if not triedLaunch then
triedLaunch = true
hs.execute("open -g -a Emacs") -- start Emacs silently (no focus)
hs.timer.doAfter(2, function() -- give server a moment, then retry once
eval(sexp, callback)
end)
end
else
log.e("emacsclient error (" .. tostring(exitCode) .. "): " .. stderr)
end
end
end, { "--eval", sexp })
emacsTask:start()
end
local function notifyClockStopped()
hs.notify
.new({
title = "Org Clock",
informativeText = "⏱ Clock stopped",
autoWithdraw = true,
})
:send()
end
local function clockOut()
eval("(my/hs-org-clock-out)", function(_) end)
end
local function gotoTimer()
eval("(my/hs-org-clock-goto)", function(_)
-- ensure Emacs is focused even if it was closed/minimized
hs.execute("open -a Emacs")
end)
end
local function update()
--log.i("tick") -- <— see ticks in Hammerspoon Console
eval(
"(my/hs-org-clock-status)", -- or your current sexp
function(value)
local s = truncateUtf8WordBoundary(unquote(trim(value)), 30)
local pomodoroActive = (s ~= "")
-- Dont need for now.
-- if not pomodoroActive and lastPomodoroActive then
-- notifyClockStopped()
-- end
lastPomodoroActive = pomodoroActive
local active = (s ~= "")
if s == "" then
menu:setTitle("⏱")
menu:setMenu({
{ title = "Go to timer", fn = gotoTimer, disabled = true },
{ title = "Stop timer", fn = clockOut, disabled = true },
{
title = "Open Emacs",
fn = function()
hs.execute("open -a Emacs")
end,
},
})
else
menu:setTitle(s)
menu:setMenu({
{ title = "Go to timer", fn = gotoTimer },
{ title = "Stop timer", fn = clockOut },
{
title = "Open Emacs",
fn = function()
hs.execute("open -a Emacs")
end,
},
})
end
end
)
end
local function safeUpdate()
local ok, err = xpcall(update, debug.traceback)
if not ok then
log.e("update() error: " .. tostring(err))
end
end
-- ✅ Keep a strong reference so the timer isn't GC'd
OrgClockMenubar.timer = hs.timer.doEvery(2, safeUpdate)
--menu._timer = OrgClockMenubar.timer
safeUpdate()
menu:setClickCallback(function()
hs.execute("open -a Emacs")
end)
function OrgClockMenubar_push(status)
if status == "out" then
renderIdle() -- instant flip
end
hs.timer.doAfter(0.05, safeUpdate) -- confirm with emacsclient
end
local function renderIdle()
menu:setTitle("⏱")
menu:setMenu({
{ title = "Go to timer", fn = gotoTimer, disabled = true },
{ title = "Stop timer", fn = clockOut, disabled = true },
{
title = "Open Emacs",
fn = function()
hs.execute("open -a Emacs")
end,
},
})
end
-- Bind the custom URL: hammerspoon://orgclock?status=in|out
hs.urlevent.bind("orgclock", function(eventName, params)
local status = params and params.status or ""
if status == "out" then
lastActive = false
renderIdle() -- instant UI feedback
end
-- In both cases, verify the real state from Emacs
hs.timer.doAfter(0.05, safeUpdate)
end)
return menu
Hello, I really like the work you have done for this, but when I use it, the timing refresh is always interrupted unexpectedly. I don’t know the reason. Can you help me solve it?