Created
December 13, 2019 10:05
-
-
Save mgkennard/e60ea3b354963293035a315d4b9d69f0 to your computer and use it in GitHub Desktop.
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 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 |
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment

I just added
returnto the lines 41 and 48:And binded the timer into a global variable in my
init.lua: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.