feat: add :LocMetrics heatmap and refactor core for LOC reduction
This commit is contained in:
@@ -3,7 +3,7 @@
|
|||||||
> Entirely GPT 5.5 generated
|
> Entirely GPT 5.5 generated
|
||||||
|
|
||||||
A tiny Neovim plugin that tracks characters added and deleted while you are in
|
A tiny Neovim plugin that tracks characters added and deleted while you are in
|
||||||
insert mode.
|
insert mode, then displays estimated lines of code by dividing characters by 35.
|
||||||
|
|
||||||
It keeps daily totals:
|
It keeps daily totals:
|
||||||
|
|
||||||
@@ -63,6 +63,7 @@ The plugin defines these commands when it is on your runtimepath:
|
|||||||
- `:LocDisable` stops tracking.
|
- `:LocDisable` stops tracking.
|
||||||
- `:LocReset` clears today's counters.
|
- `:LocReset` clears today's counters.
|
||||||
- `:LocStats` prints today's counters.
|
- `:LocStats` prints today's counters.
|
||||||
|
- `:LocMetrics` opens a floating 4-week net LOC heatmap.
|
||||||
|
|
||||||
Calling `require("loc").setup()` enables tracking by default. Use this if you
|
Calling `require("loc").setup()` enables tracking by default. Use this if you
|
||||||
want to configure the plugin but start it manually:
|
want to configure the plugin but start it manually:
|
||||||
@@ -114,6 +115,7 @@ require("loc").reset()
|
|||||||
require("loc").save()
|
require("loc").save()
|
||||||
require("loc").statusline()
|
require("loc").statusline()
|
||||||
require("loc").stats()
|
require("loc").stats()
|
||||||
|
require("loc").metrics()
|
||||||
```
|
```
|
||||||
|
|
||||||
`stats()` returns today's counters:
|
`stats()` returns today's counters:
|
||||||
|
|||||||
+468
-49
@@ -18,78 +18,72 @@ local state = {
|
|||||||
save_pending = false,
|
save_pending = false,
|
||||||
dirty = false,
|
dirty = false,
|
||||||
days = {},
|
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()
|
local function data_path()
|
||||||
if state.config and state.config.data_path then
|
return (state.config and state.config.data_path)
|
||||||
return state.config.data_path
|
or table.concat({ vim.fn.stdpath("data"), "loc.nvim", "stats.json" }, "/")
|
||||||
end
|
end
|
||||||
|
|
||||||
return table.concat({ vim.fn.stdpath("data"), "loc.nvim", "stats.json" }, "/")
|
local json_encode = (vim.json and vim.json.encode) or vim.fn.json_encode
|
||||||
end
|
local json_decode = (vim.json and vim.json.decode) or vim.fn.json_decode
|
||||||
|
|
||||||
local function json_encode(value)
|
|
||||||
if vim.json and vim.json.encode then
|
|
||||||
return vim.json.encode(value)
|
|
||||||
end
|
|
||||||
|
|
||||||
return vim.fn.json_encode(value)
|
|
||||||
end
|
|
||||||
|
|
||||||
local function json_decode(value)
|
|
||||||
if vim.json and vim.json.decode then
|
|
||||||
return vim.json.decode(value)
|
|
||||||
end
|
|
||||||
|
|
||||||
return vim.fn.json_decode(value)
|
|
||||||
end
|
|
||||||
|
|
||||||
local function today_key()
|
local function today_key()
|
||||||
return vim.fn.strftime("%Y-%m-%d")
|
return vim.fn.strftime("%Y-%m-%d")
|
||||||
end
|
end
|
||||||
|
|
||||||
local function day_stats(date)
|
local function day_stats(date)
|
||||||
local day = state.days[date]
|
state.days[date] = state.days[date] or { added = 0, deleted = 0 }
|
||||||
|
return state.days[date]
|
||||||
if not day then
|
|
||||||
day = { added = 0, deleted = 0 }
|
|
||||||
state.days[date] = day
|
|
||||||
end
|
|
||||||
|
|
||||||
return day
|
|
||||||
end
|
end
|
||||||
|
|
||||||
local function char_count(value)
|
local function char_count(value)
|
||||||
if value == "" then
|
if value == "" then return 0 end
|
||||||
return 0
|
|
||||||
end
|
|
||||||
|
|
||||||
local ok, count = pcall(vim.fn.strchars, value)
|
local ok, count = pcall(vim.fn.strchars, value)
|
||||||
if ok then
|
return ok and count or #value
|
||||||
return count
|
|
||||||
end
|
|
||||||
|
|
||||||
return #value
|
|
||||||
end
|
end
|
||||||
|
|
||||||
local function is_continuation_byte(byte)
|
local function is_continuation_byte(byte)
|
||||||
return byte and byte >= 0x80 and byte <= 0xbf
|
return byte and byte >= 0x80 and byte <= 0xbf
|
||||||
end
|
end
|
||||||
|
|
||||||
local function has_char_boundary_after(value, byte_index)
|
|
||||||
if byte_index <= 0 or byte_index >= #value then
|
|
||||||
return true
|
|
||||||
end
|
|
||||||
|
|
||||||
return not is_continuation_byte(value:byte(byte_index + 1))
|
|
||||||
end
|
|
||||||
|
|
||||||
local function has_char_boundary_at(value, byte_index)
|
local function has_char_boundary_at(value, byte_index)
|
||||||
if byte_index <= 1 or byte_index > #value then
|
return byte_index <= 1 or byte_index > #value or not is_continuation_byte(value:byte(byte_index))
|
||||||
return true
|
|
||||||
end
|
end
|
||||||
|
|
||||||
return not is_continuation_byte(value:byte(byte_index))
|
local function has_char_boundary_after(value, byte_index)
|
||||||
|
return has_char_boundary_at(value, byte_index + 1)
|
||||||
end
|
end
|
||||||
|
|
||||||
local function common_prefix_bytes(old, new)
|
local function common_prefix_bytes(old, new)
|
||||||
@@ -224,6 +218,388 @@ local function update_snapshot(bufnr)
|
|||||||
state.snapshots[bufnr] = current
|
state.snapshots[bufnr] = current
|
||||||
end
|
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", "<Esc>", close, opts)
|
||||||
|
|
||||||
|
-- Disable scrolling and movement
|
||||||
|
local nop = function() end
|
||||||
|
local keys = {
|
||||||
|
"<PageUp>", "<PageDown>", "<ScrollWheelUp>", "<ScrollWheelDown>",
|
||||||
|
"<Up>", "<Down>", "<Left>", "<Right>", "k", "j", "h", "l",
|
||||||
|
"<C-f>", "<C-b>", "<C-d>", "<C-u>", "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 function load_stats()
|
||||||
local path = data_path()
|
local path = data_path()
|
||||||
|
|
||||||
@@ -365,18 +741,61 @@ function M.stats()
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
function M.statusline()
|
function M.display_stats()
|
||||||
local stats = M.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
|
local prefix = state.config.statusline_prefix
|
||||||
|
|
||||||
return string.format("%s %+d", prefix, stats.net)
|
return string.format("%s %+d", prefix, stats.net)
|
||||||
end
|
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()
|
function M.save()
|
||||||
ensure_setup()
|
ensure_setup()
|
||||||
save_now()
|
save_now()
|
||||||
end
|
end
|
||||||
|
|
||||||
M._measure_change = measure_change
|
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
|
return M
|
||||||
|
|||||||
+6
-2
@@ -17,7 +17,7 @@ vim.api.nvim_create_user_command("LocReset", function()
|
|||||||
end, { desc = "Reset loc.nvim character counters" })
|
end, { desc = "Reset loc.nvim character counters" })
|
||||||
|
|
||||||
vim.api.nvim_create_user_command("LocStats", function()
|
vim.api.nvim_create_user_command("LocStats", function()
|
||||||
local stats = require("loc").stats()
|
local stats = require("loc").display_stats()
|
||||||
local message = string.format(
|
local message = string.format(
|
||||||
"LOC added=%d deleted=%d net=%+d",
|
"LOC added=%d deleted=%d net=%+d",
|
||||||
stats.added,
|
stats.added,
|
||||||
@@ -26,4 +26,8 @@ vim.api.nvim_create_user_command("LocStats", function()
|
|||||||
)
|
)
|
||||||
|
|
||||||
vim.notify(message, vim.log.levels.INFO)
|
vim.notify(message, vim.log.levels.INFO)
|
||||||
end, { desc = "Show loc.nvim character counters" })
|
end, { desc = "Show loc.nvim estimated LOC counters" })
|
||||||
|
|
||||||
|
vim.api.nvim_create_user_command("LocMetrics", function()
|
||||||
|
require("loc").metrics()
|
||||||
|
end, { desc = "Show loc.nvim 30-day LOC metrics heatmap" })
|
||||||
|
|||||||
+102
-1
@@ -46,6 +46,14 @@ assert_change("hello", "hello world", 6, 0)
|
|||||||
assert_change("héllo", "hallo", 1, 1)
|
assert_change("héllo", "hallo", 1, 1)
|
||||||
assert_change("one\ntwo", "one\nthree", 4, 2)
|
assert_change("one\ntwo", "one\nthree", 4, 2)
|
||||||
|
|
||||||
|
assert_equal(loc._chars_to_loc(0), 0, "chars to loc zero")
|
||||||
|
assert_equal(loc._chars_to_loc(17), 0, "chars to loc below half")
|
||||||
|
assert_equal(loc._chars_to_loc(18), 1, "chars to loc rounds half up")
|
||||||
|
assert_equal(loc._chars_to_loc(35), 1, "chars to loc one line")
|
||||||
|
assert_equal(loc._chars_to_loc(52), 1, "chars to loc below one and a half")
|
||||||
|
assert_equal(loc._chars_to_loc(53), 2, "chars to loc above one and a half")
|
||||||
|
assert_equal(loc._chars_to_loc(-70), -2, "chars to loc negative")
|
||||||
|
|
||||||
local today = vim.fn.strftime("%Y-%m-%d")
|
local today = vim.fn.strftime("%Y-%m-%d")
|
||||||
local old_day = "2000-01-01"
|
local old_day = "2000-01-01"
|
||||||
|
|
||||||
@@ -61,7 +69,7 @@ local loaded_stats = loc.stats()
|
|||||||
assert_equal(loaded_stats.added, 7, "loaded daily added")
|
assert_equal(loaded_stats.added, 7, "loaded daily added")
|
||||||
assert_equal(loaded_stats.deleted, 2, "loaded daily deleted")
|
assert_equal(loaded_stats.deleted, 2, "loaded daily deleted")
|
||||||
assert_equal(loaded_stats.net, 5, "loaded daily net")
|
assert_equal(loaded_stats.net, 5, "loaded daily net")
|
||||||
assert_equal(loc.statusline(), "LOC +5", "daily statusline")
|
assert_equal(loc.statusline(), "LOC +0", "daily statusline uses estimated loc")
|
||||||
|
|
||||||
loc.reset()
|
loc.reset()
|
||||||
|
|
||||||
@@ -90,6 +98,7 @@ vim.api.nvim_exec_autocmds("TextChangedI", { buffer = bufnr })
|
|||||||
local changed_stats = loc.stats()
|
local changed_stats = loc.stats()
|
||||||
assert_equal(changed_stats.added, 2, "tracked edit added")
|
assert_equal(changed_stats.added, 2, "tracked edit added")
|
||||||
assert_equal(changed_stats.deleted, 0, "tracked edit deleted")
|
assert_equal(changed_stats.deleted, 0, "tracked edit deleted")
|
||||||
|
assert_equal(loc.display_stats().added, 0, "tracked edit display added")
|
||||||
|
|
||||||
loc.save()
|
loc.save()
|
||||||
loc.disable()
|
loc.disable()
|
||||||
@@ -100,4 +109,96 @@ local changed = read_json(change_path)
|
|||||||
assert_equal(changed[today].added, 2, "tracked edit saved today added")
|
assert_equal(changed[today].added, 2, "tracked edit saved today added")
|
||||||
assert_equal(changed[today].deleted, 0, "tracked edit saved today deleted")
|
assert_equal(changed[today].deleted, 0, "tracked edit saved today deleted")
|
||||||
|
|
||||||
|
local metrics_epoch = vim.fn.strptime("%Y-%m-%d", "2026-05-10")
|
||||||
|
local metrics_dates = loc._metrics_dates(metrics_epoch)
|
||||||
|
|
||||||
|
assert_equal(#metrics_dates, 28, "metrics date count")
|
||||||
|
assert_equal(metrics_dates[1].date, "2026-04-19", "metrics first date")
|
||||||
|
assert_equal(metrics_dates[28].date, "2026-05-16", "metrics last date")
|
||||||
|
assert_equal(loc._metrics_color(0), "#f0b6c1", "metrics active color start")
|
||||||
|
assert_equal(loc._metrics_color(1), "#b90f36", "metrics active color end")
|
||||||
|
|
||||||
|
local zero_style = loc._metrics_tile_style(0, 9999)
|
||||||
|
assert_equal(zero_style.bg, "#252628", "metrics zero tile background")
|
||||||
|
assert_equal(zero_style.value_fg, "#e5e39a", "metrics zero value foreground")
|
||||||
|
assert_equal(zero_style.date_fg, "#8f9096", "metrics zero date foreground")
|
||||||
|
|
||||||
|
local low_style = loc._metrics_tile_style(2000, 9999)
|
||||||
|
assert_equal(low_style.bg, "#edaab6", "metrics low tile background")
|
||||||
|
assert_equal(low_style.value_fg, "#2b2529", "metrics low value foreground")
|
||||||
|
assert_equal(low_style.date_fg, "#58565b", "metrics low date foreground")
|
||||||
|
|
||||||
|
local high_style = loc._metrics_tile_style(9999, 9999)
|
||||||
|
assert_equal(high_style.bg, "#b90f36", "metrics high tile background")
|
||||||
|
assert_equal(high_style.value_fg, "#ffd6df", "metrics high value foreground")
|
||||||
|
assert_equal(high_style.date_fg, "#f0aebb", "metrics high date foreground")
|
||||||
|
|
||||||
|
assert_equal(loc._metrics_value(12000), "+9999", "metrics positive value clamps")
|
||||||
|
assert_equal(loc._metrics_value(-12000), "-9999", "metrics negative value clamps")
|
||||||
|
assert_equal(loc._metrics_value(0), "0", "metrics zero value")
|
||||||
|
assert_equal(loc._metrics_centered_text("+7", 7), " +7 ", "metrics centered value")
|
||||||
|
assert_equal(loc._metrics_display_date(metrics_dates[1].epoch), "04/19", "metrics display date")
|
||||||
|
|
||||||
|
vim.api.nvim_set_hl(0, "Normal", { fg = 0x112233, bg = 0x445566 })
|
||||||
|
vim.api.nvim_set_hl(0, "NormalFloat", { fg = 0xaabbcc, bg = 0xddeeff })
|
||||||
|
vim.api.nvim_set_hl(0, "StatusLine", { fg = 0x778899, bg = 0x0a0b0c })
|
||||||
|
|
||||||
|
local metrics_background = loc._metrics_background()
|
||||||
|
assert_equal(metrics_background.fg, 0x778899, "metrics background fg from StatusLine")
|
||||||
|
assert_equal(metrics_background.bg, 0x0a0b0c, "metrics background bg from StatusLine")
|
||||||
|
|
||||||
|
vim.api.nvim_set_hl(0, "Normal", { fg = 0x010203, bg = 0x040506 })
|
||||||
|
vim.api.nvim_set_hl(0, "StatusLine", {})
|
||||||
|
|
||||||
|
metrics_background = loc._metrics_background()
|
||||||
|
assert_equal(metrics_background.fg, 0x010203, "metrics background fg falls back to Normal")
|
||||||
|
assert_equal(metrics_background.bg, 0x040506, "metrics background bg falls back to Normal")
|
||||||
|
|
||||||
|
local metrics_path = vim.fn.tempname()
|
||||||
|
write_json(metrics_path, {
|
||||||
|
[metrics_dates[1].date] = { added = 245, deleted = 0 },
|
||||||
|
[metrics_dates[10].date] = { added = 0, deleted = 105 },
|
||||||
|
["2026-05-10"] = { added = 350, deleted = 0 },
|
||||||
|
})
|
||||||
|
|
||||||
|
loc.setup({ auto_enable = false, data_path = metrics_path, save_delay_ms = 1 })
|
||||||
|
|
||||||
|
local entries, column_count, max_abs_net, total_net = loc._metrics_entries(metrics_epoch)
|
||||||
|
|
||||||
|
assert_equal(#entries, 28, "metrics entry count")
|
||||||
|
assert_equal(column_count, 4, "metrics column count")
|
||||||
|
assert_equal(entries[1].net, 245, "metrics first entry raw net")
|
||||||
|
assert_equal(entries[1].loc_net, 7, "metrics first entry loc net")
|
||||||
|
assert_equal(entries[1].display_date, "04/19", "metrics first entry display date")
|
||||||
|
assert_equal(entries[2].net, 0, "metrics missing entry net")
|
||||||
|
assert_equal(entries[10].net, -105, "metrics negative raw entry net")
|
||||||
|
assert_equal(entries[10].loc_net, -3, "metrics negative loc entry net")
|
||||||
|
assert_equal(entries[22].date, "2026-05-10", "metrics current week starts with today")
|
||||||
|
assert_equal(entries[22].column, 4, "metrics today is in current week column")
|
||||||
|
assert_equal(entries[22].weekday, 0, "metrics today is Sunday row")
|
||||||
|
assert_equal(entries[28].date, "2026-05-16", "metrics includes future week end")
|
||||||
|
assert_equal(entries[28].loc_net, 0, "metrics future date loc net is zero")
|
||||||
|
assert_equal(max_abs_net, 10, "metrics max abs net")
|
||||||
|
assert_equal(total_net, 14, "metrics total net")
|
||||||
|
|
||||||
|
local render = loc.metrics()
|
||||||
|
assert_equal(vim.api.nvim_win_is_valid(render.winid), true, "metrics window is valid")
|
||||||
|
assert_equal(vim.api.nvim_buf_is_valid(render.bufnr), true, "metrics buffer is valid")
|
||||||
|
|
||||||
|
local metrics_lines = vim.api.nvim_buf_get_lines(render.bufnr, 0, -1, false)
|
||||||
|
local rendered = table.concat(metrics_lines, "\n")
|
||||||
|
|
||||||
|
assert_equal(metrics_lines[2]:match("LOC metrics") ~= nil, true, "metrics title")
|
||||||
|
assert_equal(metrics_lines[#metrics_lines - 1]:match("4%-week net: %+14") ~= nil, true, "metrics summary")
|
||||||
|
assert_equal(#metrics_lines, 21, "metrics renders transposed three-line week rows with row gaps and outer padding")
|
||||||
|
assert_equal(rendered:match("%+7") ~= nil, true, "metrics renders net value")
|
||||||
|
assert_equal(rendered:match("04/19") ~= nil, true, "metrics renders date label")
|
||||||
|
assert_equal(rendered:match("04/20") ~= nil, true, "metrics renders zero tile date label")
|
||||||
|
assert_equal(rendered:match("05/16") ~= nil, true, "metrics renders future date label")
|
||||||
|
assert_equal(rendered:match("Sun") == nil, true, "metrics omits weekday labels")
|
||||||
|
assert_equal(vim.wo[render.winid].winhl:match("LocMetricsNormal") ~= nil, true, "metrics window uses custom normal highlight")
|
||||||
|
assert_equal(vim.wo[render.winid].winhl:match("FloatBorder") == nil, true, "metrics window does not set a border highlight")
|
||||||
|
|
||||||
|
vim.api.nvim_win_close(render.winid, true)
|
||||||
|
|
||||||
print("loc.nvim tests passed")
|
print("loc.nvim tests passed")
|
||||||
|
|||||||
Reference in New Issue
Block a user