-- ~/.config/nvim/lua/stopwatch.lua -- Simple visible stopwatch for Neovim local M = {} local uv = vim.loop local state = { running = false, start_hr = 0, -- hrtime at start (nanoseconds) elapsed_ns = 0, -- accumulated nanoseconds when stopped name = nil, timer = nil, bufnr = nil, win = nil, opts = nil, } local function ns_to_time(ns) local s = ns / 1e9 local h = math.floor(s / 3600) s = s - h * 3600 local m = math.floor(s / 60) local sec = s - m * 60 local secs = math.floor(sec) local ms = math.floor((sec - secs) * 1000) if h > 0 then return string.format("%02d:%02d:%02d.%03d", h, m, secs, ms) else return string.format("%02d:%02d.%03d", m, secs, ms) end end local function get_elapsed_ns() if state.running then return state.elapsed_ns + (uv.hrtime() - state.start_hr) else return state.elapsed_ns end end local function ensure_win() if state.win and vim.api.nvim_win_is_valid(state.win) then return end if not state.bufnr or not vim.api.nvim_buf_is_valid(state.bufnr) then state.bufnr = vim.api.nvim_create_buf(false, true) vim.api.nvim_buf_set_option(state.bufnr, "bufhidden", "wipe") vim.api.nvim_buf_set_option(state.bufnr, "modifiable", false) vim.api.nvim_buf_set_option(state.bufnr, "filetype", "stopwatch") end local width = state.opts.width or 24 local height = 1 local col = (vim.o.columns - width) - (state.opts.margin_right or 2) local row = state.opts.row or 1 local win_opts = { relative = "editor", anchor = "NW", row = row, col = col, width = width, height = height, style = "minimal", focusable = false, noautocmd = true, } state.win = vim.api.nvim_open_win(state.bufnr, false, win_opts) vim.api.nvim_win_set_option(state.win, "winblend", state.opts.winblend or 5) end local function close_win() if state.win and vim.api.nvim_win_is_valid(state.win) then pcall(vim.api.nvim_win_close, state.win, true) end state.win = nil -- keep buf so it can be reused end local function update_win() if not state.opts.visible then return end ensure_win() if not state.bufnr or not vim.api.nvim_buf_is_valid(state.bufnr) then return end local label = state.name and ("[" .. state.name .. "] ") or "" local status_sym = state.running and "⏱ " or "⏸ " local txt = status_sym .. " " .. label .. ns_to_time(get_elapsed_ns()) vim.api.nvim_buf_set_option(state.bufnr, "modifiable", true) vim.api.nvim_buf_set_lines(state.bufnr, 0, -1, false, {txt}) vim.api.nvim_buf_set_option(state.bufnr, "modifiable", false) end local function start_timer_loop() if state.timer then return end state.timer = uv.new_timer() state.timer:start(0, state.opts.update_ms or 250, vim.schedule_wrap(function() if state.win and not vim.api.nvim_win_is_valid(state.win) then state.win = nil end update_win() end)) end local function stop_timer_loop() if state.timer then pcall(function() state.timer:stop() end) state.timer:close() state.timer = nil end end -- Public API function M.start(name) if state.running then return end state.name = name or state.name state.start_hr = uv.hrtime() - state.elapsed_ns state.running = true if state.opts.visible then ensure_win() end start_timer_loop() update_win() end function M.stop() if not state.running then return end state.elapsed_ns = get_elapsed_ns() state.running = false stop_timer_loop() update_win() if state.opts.log_on_stop then local log_path = state.opts.log_path or (vim.fn.stdpath("data") .. "/stopwatch_log.md") local time_str = os.date("%Y-%m-%d %H:%M:%S") local name = state.name or "-" local duration = ns_to_time(state.elapsed_ns) local line = string.format("- %s | %s | %s\n", time_str, name, duration) local f = io.open(log_path, "a") if f then f:write(line) f:close() end end end function M.toggle(name) if state.running then M.stop() else M.start(name) end end function M.reset() state.running = false state.elapsed_ns = 0 state.start_hr = 0 stop_timer_loop() update_win() end function M.show() state.opts.visible = true ensure_win() start_timer_loop() update_win() end function M.hide() state.opts.visible = false close_win() stop_timer_loop() end function M.log_now() local log_path = state.opts.log_path or (vim.fn.stdpath("data") .. "/stopwatch_log.md") local time_str = os.date("%Y-%m-%d %H:%M:%S") local name = state.name or "-" local duration = ns_to_time(get_elapsed_ns()) local line = string.format("- %s | %s | %s\n", time_str, name, duration) local f = io.open(log_path, "a") if f then f:write(line) f:close() end end function M.status() -- returns a short string for statusline local elapsed = get_elapsed_ns() local prefix = state.running and "⏱" or "⏸" local name = state.name and (" " .. state.name) or "" return prefix .. name .. " " .. ns_to_time(elapsed) end function M.setup(opts) opts = opts or {} state.opts = vim.tbl_extend("keep", opts, { visible = true, update_ms = 250, width = 28, row = 1, margin_right = 2, winblend = 5, log_on_stop = false, log_path = nil, }) -- Create user commands vim.api.nvim_create_user_command("StopwatchStart", function(opts) M.start(opts.args ~= "" and opts.args or nil) end, { nargs = "?" }) vim.api.nvim_create_user_command("StopwatchStop", function() M.stop() end, {}) vim.api.nvim_create_user_command("StopwatchToggle", function(opts) M.toggle(opts.args ~= "" and opts.args or nil) end, { nargs = "?" }) vim.api.nvim_create_user_command("StopwatchReset", function() M.reset() end, {}) vim.api.nvim_create_user_command("StopwatchShow", function() M.show() end, {}) vim.api.nvim_create_user_command("StopwatchHide", function() M.hide() end, {}) vim.api.nvim_create_user_command("StopwatchLog", function() M.log_now() end, {}) -- optionally show initially if state.opts.visible then ensure_win() update_win() end end return M