Skip to content

Instantly share code, notes, and snippets.

@mgkennard
Created December 13, 2019 10:05
Show Gist options
  • Select an option

  • Save mgkennard/e60ea3b354963293035a315d4b9d69f0 to your computer and use it in GitHub Desktop.

Select an option

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
@iT-Boyer
Copy link

iT-Boyer commented Apr 1, 2021

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?

local function startUpdatingClockingMenu()
   hs.timer.doEvery(10, updateClockingMenu)
end

@mgkennard
Copy link
Author

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.

@iT-Boyer
Copy link

iT-Boyer commented Apr 7, 2021

Continue to pay attention, after optimization, hope to be notified

@isamert
Copy link

isamert commented Feb 7, 2022

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.

@keeprock
Copy link

keeprock commented Oct 9, 2025

I made it work for me.
image

image

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