local M = {} local defaults = { auto_enable = true, persist = true, data_path = nil, save_delay_ms = 1000, statusline_prefix = "LOC", } local state = { config = nil, loaded = false, loaded_path = nil, enabled = false, augroup = nil, snapshots = {}, save_pending = false, dirty = false, days = {}, metrics_render = 0, } local metrics_color_stops = { { at = 0.00, color = "#f0b6c1" }, { at = 0.35, color = "#eba1ae" }, { at = 0.65, color = "#df617a" }, { at = 1.00, color = "#b90f36" }, } local metrics_zero_style = { bg = "#252628", value_fg = "#e5e39a", date_fg = "#8f9096", } local metrics_low_text_style = { value_fg = "#2b2529", date_fg = "#58565b", } local metrics_high_text_style = { value_fg = "#ffd6df", date_fg = "#f0aebb", } local weekday_names = { "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat" } local chars_per_loc = 35 local metrics_cell_width = 7 local metrics_cell_height = 3 local metrics_column_gap = 2 local metrics_row_gap = 1 local function data_path() return (state.config and state.config.data_path) or table.concat({ vim.fn.stdpath("data"), "loc.nvim", "stats.json" }, "/") end local json_encode = (vim.json and vim.json.encode) or vim.fn.json_encode local json_decode = (vim.json and vim.json.decode) or vim.fn.json_decode local function today_key() return vim.fn.strftime("%Y-%m-%d") end local function day_stats(date) state.days[date] = state.days[date] or { added = 0, deleted = 0 } return state.days[date] end local function char_count(value) if value == "" then return 0 end local ok, count = pcall(vim.fn.strchars, value) return ok and count or #value end local function is_continuation_byte(byte) return byte and byte >= 0x80 and byte <= 0xbf end local function has_char_boundary_at(value, byte_index) return byte_index <= 1 or byte_index > #value or not is_continuation_byte(value:byte(byte_index)) end local function has_char_boundary_after(value, byte_index) return has_char_boundary_at(value, byte_index + 1) end local function common_prefix_bytes(old, new) local max_len = math.min(#old, #new) local prefix = 0 while prefix < max_len and old:byte(prefix + 1) == new:byte(prefix + 1) do prefix = prefix + 1 end while prefix > 0 and (not has_char_boundary_after(old, prefix) or not has_char_boundary_after(new, prefix)) do prefix = prefix - 1 end return prefix end local function common_suffix_bytes(old, new, prefix) local old_len = #old local new_len = #new local suffix = 0 while old_len - suffix > prefix and new_len - suffix > prefix and old:byte(old_len - suffix) == new:byte(new_len - suffix) do suffix = suffix + 1 end while suffix > 0 do local old_start = old_len - suffix + 1 local new_start = new_len - suffix + 1 if has_char_boundary_at(old, old_start) and has_char_boundary_at(new, new_start) then break end suffix = suffix - 1 end return suffix end local function measure_change(old, new) if old == new then return 0, 0 end local prefix = common_prefix_bytes(old, new) local suffix = common_suffix_bytes(old, new, prefix) local old_changed = old:sub(prefix + 1, #old - suffix) local new_changed = new:sub(prefix + 1, #new - suffix) return char_count(new_changed), char_count(old_changed) end local function snapshot(bufnr) if not vim.api.nvim_buf_is_valid(bufnr) then return nil end local ok, lines = pcall(vim.api.nvim_buf_get_lines, bufnr, 0, -1, true) if not ok then return nil end return table.concat(lines, "\n") end local function apply_change(added, deleted) if added == 0 and deleted == 0 then return end local day = day_stats(today_key()) day.added = day.added + added day.deleted = day.deleted + deleted state.dirty = true vim.schedule(function() vim.cmd.redrawstatus() end) end local function save_now() if not state.config or not state.config.persist or not state.dirty then return end local path = data_path() local payload = json_encode(state.days) local ok = pcall(function() vim.fn.mkdir(vim.fn.fnamemodify(path, ":h"), "p") vim.fn.writefile({ payload }, path) end) if ok then state.dirty = false end end local function schedule_save() if not state.config or not state.config.persist or state.save_pending then return end state.save_pending = true vim.defer_fn(function() state.save_pending = false save_now() end, state.config.save_delay_ms) end local function update_snapshot(bufnr) local previous = state.snapshots[bufnr] local current = snapshot(bufnr) if not current then state.snapshots[bufnr] = nil return end if previous then local added, deleted = measure_change(previous, current) apply_change(added, deleted) schedule_save() end state.snapshots[bufnr] = current end local function clamp(value, min, max) return math.max(min, math.min(max, value)) end local function chars_to_loc(chars) return (chars < 0 and -1 or 1) * math.floor((math.abs(chars) / chars_per_loc) + 0.5) end local function hex_to_rgb(color) return { r = tonumber(color:sub(2, 3), 16), g = tonumber(color:sub(4, 5), 16), b = tonumber(color:sub(6, 7), 16), } end local function rgb_to_hex(rgb) return string.format("#%02x%02x%02x", rgb.r, rgb.g, rgb.b) end local function lerp_number(start, finish, t) return math.floor(start + ((finish - start) * t) + 0.5) end local function lerp_color(start, finish, t) local from = hex_to_rgb(start) local to = hex_to_rgb(finish) return rgb_to_hex({ r = lerp_number(from.r, to.r, t), g = lerp_number(from.g, to.g, t), b = lerp_number(from.b, to.b, t), }) end local function metrics_color(t) t = clamp(t or 0, 0, 1) for index = 1, #metrics_color_stops - 1 do local left = metrics_color_stops[index] local right = metrics_color_stops[index + 1] if t <= right.at then local span = right.at - left.at local local_t = span == 0 and 0 or (t - left.at) / span return lerp_color(left.color, right.color, local_t) end end return metrics_color_stops[#metrics_color_stops].color end local function metrics_tile_style(net, max_abs_net) local abs_net = math.abs(net or 0) if abs_net == 0 or max_abs_net == 0 then return vim.tbl_extend("force", {}, metrics_zero_style) end local t = clamp(abs_net / max_abs_net, 0, 1) local text_style = t >= 0.75 and metrics_high_text_style or metrics_low_text_style return { bg = metrics_color(t), value_fg = text_style.value_fg, date_fg = text_style.date_fg, } end local function highlight_color(name, key) local ok, highlight = pcall(vim.api.nvim_get_hl, 0, { name = name, link = false }) if not ok or type(highlight) ~= "table" or type(highlight[key]) ~= "number" then return nil end return highlight[key] end local function resolve_metrics_background() return { bg = highlight_color("StatusLine", "bg") or highlight_color("Normal", "bg"), fg = highlight_color("StatusLine", "fg") or highlight_color("Normal", "fg"), } end local function metrics_dates(now) now = now or vim.fn.localtime() local dates = {} local today_weekday = tonumber(vim.fn.strftime("%w", now)) local current_week_start = now - (today_weekday * 86400) local range_start = current_week_start - (21 * 86400) for offset = 0, 27 do local epoch = range_start + (offset * 86400) dates[#dates + 1] = { date = vim.fn.strftime("%Y-%m-%d", epoch), epoch = epoch, weekday = tonumber(vim.fn.strftime("%w", epoch)), } end return dates end local function metrics_display_date(epoch) return vim.fn.strftime("%m/%d", epoch) end local function stored_day_stats(date) local day = state.days[date] if type(day) ~= "table" then return { added = 0, deleted = 0, net = 0 } end local added = tonumber(day.added) or 0 local deleted = tonumber(day.deleted) or 0 return { added = added, deleted = deleted, net = added - deleted, } end local function display_day_stats(stats) local loc_added = chars_to_loc(stats.added) local loc_deleted = chars_to_loc(stats.deleted) local loc_net = chars_to_loc(stats.net) return loc_added, loc_deleted, loc_net end local function collect_metrics_entries(now) local dates = metrics_dates(now) local entries = {} local max_abs_net = 0 local total_net = 0 for index, item in ipairs(dates) do local stats = stored_day_stats(item.date) local loc_added, loc_deleted, loc_net = display_day_stats(stats) local loc_abs_net = math.abs(loc_net) local entry = { index = index, date = item.date, display_date = metrics_display_date(item.epoch), weekday = item.weekday, column = math.floor((index - 1) / 7) + 1, added = stats.added, deleted = stats.deleted, net = stats.net, abs_net = math.abs(stats.net), loc_added = loc_added, loc_deleted = loc_deleted, loc_net = loc_net, loc_abs_net = loc_abs_net, } max_abs_net = math.max(max_abs_net, loc_abs_net) total_net = total_net + loc_net entries[#entries + 1] = entry end return entries, entries[#entries].column, max_abs_net, total_net end local function max_display_width(lines) local width = 0 for _, line in ipairs(lines) do width = math.max(width, vim.fn.strdisplaywidth(line)) end return width end local function centered_text(value, width) local display_width = vim.fn.strdisplaywidth(value) if display_width >= width then return value end local left = math.floor((width - display_width) / 2) return string.rep(" ", left) .. value .. string.rep(" ", width - display_width - left) end local function clamped_metric_value(value) value = clamp(value, -9999, 9999) return (value > 0 and "+" or "") .. value end local function build_metrics_lines(column_count, total_net) local horizontal_padding = 4 local vertical_padding = 1 local lines = { "LOC metrics", "", } local cells = {} for week = 1, column_count do local row_lines = {} local row_cells = {} for _ = 1, metrics_cell_height do row_lines[#row_lines + 1] = "" end for weekday = 0, 6 do local start_col = #row_lines[1] for row = 1, metrics_cell_height do row_lines[row] = row_lines[row] .. string.rep(" ", metrics_cell_width) end row_cells[weekday] = { line = #lines, value_line = #lines + 1, date_line = #lines + 2, start_col = start_col, end_col = #row_lines[1], } if weekday < 6 then local gap = string.rep(" ", metrics_column_gap) for row = 1, metrics_cell_height do row_lines[row] = row_lines[row] .. gap end end end cells[week] = row_cells for _, line in ipairs(row_lines) do lines[#lines + 1] = line end if week < column_count then for _ = 1, metrics_row_gap do lines[#lines + 1] = "" end end end lines[#lines + 1] = "" lines[#lines + 1] = string.format("4-week net: %+d", total_net) local padded_lines = {} for _ = 1, vertical_padding do padded_lines[#padded_lines + 1] = "" end for _, line in ipairs(lines) do padded_lines[#padded_lines + 1] = string.rep(" ", horizontal_padding) .. line .. string.rep(" ", horizontal_padding) end for _ = 1, vertical_padding do padded_lines[#padded_lines + 1] = "" end for _, row_cells in pairs(cells) do for _, cell in pairs(row_cells) do cell.line = cell.line + vertical_padding cell.value_line = cell.value_line + vertical_padding cell.date_line = cell.date_line + vertical_padding cell.start_col = cell.start_col + horizontal_padding cell.end_col = cell.end_col + horizontal_padding end end return padded_lines, cells end local function set_metrics_keymaps(bufnr, winid) local function close() if vim.api.nvim_win_is_valid(winid) then vim.api.nvim_win_close(winid, true) end end local opts = { buffer = bufnr, nowait = true, silent = true } vim.keymap.set("n", "q", close, opts) vim.keymap.set("n", "", close, opts) -- Disable scrolling and movement local nop = function() end local keys = { "", "", "", "", "", "", "", "", "k", "j", "h", "l", "", "", "", "", "gg", "G" } for _, key in ipairs(keys) do vim.keymap.set("n", key, nop, opts) end end local function open_metrics_window(lines) local width = max_display_width(lines) local height = #lines local columns = vim.o.columns local rows = vim.o.lines width = math.min(width, math.max(1, columns - 4)) height = math.min(height, math.max(1, rows - 4)) local bufnr = vim.api.nvim_create_buf(false, true) vim.bo[bufnr].buftype = "nofile" vim.bo[bufnr].bufhidden = "wipe" vim.bo[bufnr].swapfile = false vim.bo[bufnr].filetype = "locmetrics" vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) vim.bo[bufnr].modifiable = false local winid = vim.api.nvim_open_win(bufnr, true, { relative = "editor", width = width, height = height, row = math.max(0, math.floor((rows - height) / 2) - 1), col = math.max(0, math.floor((columns - width) / 2)), style = "minimal", }) vim.wo[winid].wrap = false vim.wo[winid].cursorline = false vim.wo[winid].number = false vim.wo[winid].relativenumber = false vim.wo[winid].scrolloff = 0 vim.wo[winid].sidescrolloff = 0 set_metrics_keymaps(bufnr, winid) return bufnr, winid end local function set_metrics_window_highlights(winid) local normal_group = "LocMetricsNormal" .. state.metrics_render vim.api.nvim_set_hl(0, normal_group, resolve_metrics_background()) vim.wo[winid].winhl = "Normal:" .. normal_group .. ",NormalFloat:" .. normal_group end local function apply_metrics_highlights(bufnr, cells, entries, max_abs_net) local namespace = vim.api.nvim_create_namespace("loc_metrics") local title_group = "LocMetricsTitle" .. state.metrics_render vim.api.nvim_set_hl(0, title_group, { italic = true }) vim.api.nvim_buf_add_highlight(bufnr, namespace, title_group, 1, 4, 15) for _, entry in ipairs(entries) do local style = metrics_tile_style(entry.loc_net, max_abs_net) local group = string.format("LocMetricsCell%d_%02d", state.metrics_render, entry.index) local date_group = string.format("LocMetricsDate%d_%02d", state.metrics_render, entry.index) local cell = cells[entry.column][entry.weekday] local date = centered_text(entry.display_date, metrics_cell_width) vim.api.nvim_set_hl(0, group, { bg = style.bg, fg = style.value_fg }) vim.api.nvim_set_hl(0, date_group, { bg = style.bg, fg = style.date_fg, italic = true }) if entry.loc_net == 0 then vim.api.nvim_buf_set_text(bufnr, cell.value_line, cell.start_col, cell.value_line, cell.end_col, { date }) else local value = centered_text(clamped_metric_value(entry.loc_net), metrics_cell_width) vim.api.nvim_buf_set_text(bufnr, cell.value_line, cell.start_col, cell.value_line, cell.end_col, { value }) vim.api.nvim_buf_set_text(bufnr, cell.date_line, cell.start_col, cell.date_line, cell.end_col, { date }) end for row = 0, metrics_cell_height - 1 do vim.api.nvim_buf_add_highlight(bufnr, namespace, group, cell.line + row, cell.start_col, cell.end_col) end local date_line = entry.loc_net == 0 and cell.value_line or cell.date_line vim.api.nvim_buf_add_highlight(bufnr, namespace, date_group, date_line, cell.start_col, cell.end_col) end end local function load_stats() local path = data_path() if state.loaded and state.loaded_path == path then return end if not state.config.persist then state.days = {} state.loaded = true state.loaded_path = path return end if vim.fn.filereadable(path) ~= 1 then state.days = {} state.loaded = true state.loaded_path = path return end local ok, parsed = pcall(function() return json_decode(table.concat(vim.fn.readfile(path), "\n")) end) state.days = {} state.dirty = false if ok and type(parsed) == "table" then for date, stats in pairs(parsed) do if type(date) == "string" and type(stats) == "table" then state.days[date] = { added = tonumber(stats.added) or 0, deleted = tonumber(stats.deleted) or 0, } end end end state.loaded = true state.loaded_path = path end local function ensure_setup() if state.config then return end M.setup({ auto_enable = false }) end function M.setup(opts) opts = opts or {} state.config = vim.tbl_deep_extend("force", defaults, opts) if opts.data_path == nil then state.config.data_path = nil end load_stats() if state.config.auto_enable then M.enable() end return M end function M.enable() ensure_setup() if state.enabled then return end state.enabled = true state.augroup = vim.api.nvim_create_augroup("loc_nvim_tracker", { clear = true }) vim.api.nvim_create_autocmd("InsertEnter", { group = state.augroup, callback = function(args) state.snapshots[args.buf] = snapshot(args.buf) end, }) vim.api.nvim_create_autocmd("TextChangedI", { group = state.augroup, callback = function(args) update_snapshot(args.buf) end, }) vim.api.nvim_create_autocmd("InsertLeave", { group = state.augroup, callback = function(args) update_snapshot(args.buf) state.snapshots[args.buf] = nil save_now() end, }) end function M.disable() if not state.enabled then return end if state.augroup then pcall(vim.api.nvim_del_augroup_by_id, state.augroup) end state.enabled = false state.augroup = nil state.snapshots = {} save_now() end function M.reset() ensure_setup() local day = day_stats(today_key()) day.added = 0 day.deleted = 0 state.dirty = true save_now() vim.cmd.redrawstatus() end function M.stats() ensure_setup() local day = day_stats(today_key()) return { added = day.added, deleted = day.deleted, net = day.added - day.deleted, } end function M.display_stats() local stats = M.stats() local added, deleted, net = display_day_stats(stats) return { added = added, deleted = deleted, net = net, } end function M.statusline() local stats = M.display_stats() local prefix = state.config.statusline_prefix return string.format("%s %+d", prefix, stats.net) end function M.metrics() ensure_setup() local entries, column_count, max_abs_net, total_net = collect_metrics_entries() local lines, cells = build_metrics_lines(column_count, total_net) local bufnr, winid = open_metrics_window(lines) state.metrics_render = state.metrics_render + 1 set_metrics_window_highlights(winid) vim.bo[bufnr].modifiable = true apply_metrics_highlights(bufnr, cells, entries, max_abs_net) vim.bo[bufnr].modifiable = false return { bufnr = bufnr, winid = winid, entries = entries, total_net = total_net, max_abs_net = max_abs_net, } end function M.save() ensure_setup() save_now() end M._measure_change = measure_change M._metrics_color = metrics_color M._metrics_dates = metrics_dates M._metrics_entries = collect_metrics_entries M._metrics_background = resolve_metrics_background M._metrics_centered_text = centered_text M._metrics_value = clamped_metric_value M._metrics_tile_style = metrics_tile_style M._metrics_display_date = metrics_display_date M._chars_to_loc = chars_to_loc return M