-- ~/.config/nvim/lua/stopwatch.lua -- Visible stopwatch for Neovim with centered big display and clickable buttons local M = {} local uv = vim.loop local state = { running = false, start_hr = 0, elapsed_ns = 0, name = nil, timer = nil, bufnr = nil, win = nil, opts = nil, big_mode = false, button_spans = 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 BIG_DIGITS = { ["0"] = {" ███ ","█ █","█ █","█ █"," ███ "}, ["1"] = {" █ "," ██ "," █ "," █ "," ███ "}, ["2"] = {" ███ ","█ █"," ██ "," ██ ","█████"}, ["3"] = {" ███ "," █"," ██ "," █"," ███ "}, ["4"] = {"█ █ ","█ █ ","█████"," █ "," █ "}, ["5"] = {"█████","█ ","████ "," █","████ "}, ["6"] = {" ███ ","█ ","████ ","█ █"," ███ "}, ["7"] = {"█████"," █"," █ "," █ "," █ "}, ["8"] = {" ███ ","█ █"," ███ ","█ █"," ███ "}, ["9"] = {" ███ ","█ █"," ████"," █"," ███ "}, [":"] = {" "," ░ "," "," ░ "," "}, [" "] = {" "," "," "," "," "}, ["-"] = {" "," ","█████"," "," "}, ["."] = {" "," "," "," "," ░ "}, } local function big_time_lines(timestr) local rows = {"","","","",""} for i = 1, #timestr do local ch = timestr:sub(i,i) local pat = BIG_DIGITS[ch] or BIG_DIGITS[" "] for r = 1,5 do rows[r] = rows[r] .. pat[r] .. " " end end return rows 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 28 local height = 1 if state.opts.big_display or state.big_mode then local chars = state.opts.big_chars or 8 local char_width = 6 width = chars * char_width height = 8 height = height + 1 -- plus button line else height = 1 + 1 -- one line time + button line end 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 = math.max(0, col), width = math.min(width, vim.o.columns - 1), height = height, style = "minimal", focusable = true, -- <-- must be focusable for clicks to work reliably noautocmd = true, } 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 = vim.api.nvim_open_win(state.bufnr, false, win_opts) vim.api.nvim_win_set_option(state.win, "winblend", state.opts.winblend or 5) vim.api.nvim_buf_set_option(state.bufnr, "buftype", "nofile") vim.api.nvim_buf_set_option(state.bufnr, "wrap", false) -- buffer-local mouse mapping: LeftMouse triggers click handler pcall(function() vim.api.nvim_buf_set_keymap(state.bufnr, "n", "", "lua require('stopwatch')._on_click()", {nowait=true, noremap=true, silent=true}) end) 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 end local function build_button_line() local btns = { "> Toggle", "O Reset", "X Hide" } local sep = " " local line = table.concat(btns, sep) local spans = {} local cur = 1 for i, b in ipairs(btns) do local s = line:find(b, cur, true) if s then local e = s + #b - 1 table.insert(spans, {name = b, s = s, e = e}) cur = e + #sep + 1 end end return line, spans 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 lines = {} local label = state.name and ("[" .. state.name .. "] ") or "" if state.opts.big_display or state.big_mode then -- compute time string local total_s = math.floor(get_elapsed_ns() / 1e9) local h = math.floor(total_s / 3600) local m = math.floor((total_s % 3600) / 60) local s = total_s % 60 local timestr if h > 0 then timestr = string.format("%02d:%02d:%02d", h, m, s) else timestr = string.format("%02d:%02d", m, s) end local b_lines = big_time_lines(timestr) -- center horizontally inside window by adding left padding local win_w = vim.api.nvim_win_get_width(state.win) local char_width = 6 -- matches BIG_DIGITS width+gap local displayed_w = #timestr * char_width local leftpad = math.max(0, math.floor((win_w - displayed_w) / 2)) local pad = string.rep(" ", leftpad) table.insert(lines, "") -- top blank line for _, l in ipairs(b_lines) do table.insert(lines, pad .. l) end table.insert(lines, "") -- blank between time and buttons local btn_line, spans = build_button_line() btn_line = pad .. btn_line -- adjust spans by leftpad for _, sp in ipairs(spans) do sp.s = sp.s + leftpad sp.e = sp.e + leftpad end table.insert(lines, btn_line) table.insert(lines, "") -- bottom blank state.button_spans = spans else local status_sym = state.running and "⏱ " or "⏸ " local txt = status_sym .. label .. ns_to_time(get_elapsed_ns()) local win_w = vim.api.nvim_win_get_width(state.win) local strw = vim.fn.strwidth(txt) local leftpad = math.max(0, math.floor((win_w - strw) / 2)) local pad = string.rep(" ", leftpad) table.insert(lines, "") -- top blank table.insert(lines, pad .. txt) table.insert(lines, "") -- blank between time and buttons local btn_line, spans = build_button_line() btn_line = pad .. btn_line for _, sp in ipairs(spans) do sp.s = sp.s + leftpad sp.e = sp.e + leftpad end table.insert(lines, btn_line) table.insert(lines, "") -- bottom blank state.button_spans = spans end vim.api.nvim_buf_set_option(state.bufnr, "modifiable", true) vim.api.nvim_buf_set_lines(state.bufnr, 0, -1, false, lines) 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 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 -- Pause (anhalten, aber nicht als "Stop"/Log) function M.pause() if not state.running then return end state.elapsed_ns = get_elapsed_ns() state.running = false stop_timer_loop() update_win() end -- Resume (fortsetzen) function M.resume() if state.running then return end state.start_hr = uv.hrtime() - state.elapsed_ns state.running = true start_timer_loop() update_win() end -- Toggle zwischen Pause und Resume (für Button/Command) function M.toggle(name) if state.running then M.pause() else -- optional: wenn name übergeben wurde, setze neuen Namen if name and name ~= "" then state.name = name end M.resume() 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() 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.toggle_big() state.big_mode = not state.big_mode update_win() end -- Mouse click handler function M._on_click() -- only proceed when our floating window is focused local cur = vim.api.nvim_get_current_win() if cur ~= state.win then return end local pos = vim.fn.getmousepos() if not pos then return end local click_line = pos.line local col = pos.column -- button line is the second-to-last line because we add a bottom blank line local total = vim.api.nvim_buf_line_count(state.bufnr) local btn_line_index = total - 1 if click_line ~= btn_line_index then return end if not state.button_spans then return end for _, sp in ipairs(state.button_spans) do if col >= sp.s and col <= sp.e then local name = sp.name if name:find("Toggle",1,true) then M.toggle() elseif name:find("Hide",1,true) then M.hide() elseif name:find("Reset",1,true) then M.reset() end -- visual feedback pcall(function() local ns = vim.api.nvim_create_namespace("stopwatch_click") vim.api.nvim_buf_add_highlight(state.bufnr, ns, "Visual", btn_line_index-1, sp.s-1, sp.e) vim.defer_fn(function() vim.api.nvim_buf_clear_namespace(state.bufnr, ns, 0, -1) end, 120) end) break end end 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, big_display = false, big_chars = 8, }) state.big_mode = state.opts.big_display or false 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, {}) vim.api.nvim_create_user_command("StopwatchToggleBig", function() M.toggle_big() end, {}) if state.opts.visible then ensure_win() update_win() end end return M