diff --git a/README.md b/README.md index 71ee3ed..17af6c0 100644 --- a/README.md +++ b/README.md @@ -37,8 +37,14 @@ documents without distorting either one. and resolve-on-write staging. - Recursive folder comparison with efficient external digest checks and child file diffs that return to the folder view. -- One-column overview gutters that show changed regions proportionally across - each document. +- A minimap for your ordinary editor windows (`:DiffBanditMinimap`): git hunks + against the index, diagnostics, search matches, the visible range, and, with + image connectors, the code's shape in syntax colours. +- Overview gutters (minimaps) that show changed regions proportionally across + each document, with the visible range, visual selection, and search matches. + Click or drag in them to jump, and scroll the pane with the wheel. With image + connectors they are drawn at pixel resolution, so every hunk keeps its own + mark, and can show the code's shape in syntax colours. - Binary file support through a read-only hex comparison view. - Theme-friendly semantic colors derived from the active colorscheme, with optional overrides. @@ -195,6 +201,30 @@ diagnostics. The left side of two-way diffs and the local/remote sides of merge views keep their filetype and syntax highlighting, while warnings and completion remain focused on the editable target/result pane. +## Editor Minimap + +The diff view's overview gutters are also available as a minimap in ordinary +editor windows. `:DiffBanditMinimap` toggles it for the current window +(`:DiffBanditMinimap open|close`, or `require("diffbandit").minimap(action)`), +and `minimap.auto = true` opens it for every eligible window. + +- It is a float over the window's right edge, so it never changes your window + layout. It covers the last few text columns, and hides when the window is + narrower than `minimap.min_window_width`. +- It shows hunks of the buffer against the git index, computed by DiffBandit + (no gitsigns needed). Hunks update shortly after edits and when the index + changes (write, buffer enter, focus return). The hunk under the cursor is + outlined. +- It also shows diagnostics (warnings and errors by default), search matches, + the visual selection, the visible range and the cursor line. +- With image connectors it draws the code's shape in syntax colours, 12 cells + wide. Otherwise it is a one-column cell strip. +- Click or drag in it to jump, and scroll the window with the wheel over it. + It adds no mappings to your buffers: clicks are handled when they enter the + minimap, which hands focus back on release. +- Help, quickfix and other special buffers, excluded filetypes, floats, and + DiffBandit's own tabs are skipped. + ## Git Modes `:DiffBanditGit` accepts common diff scopes: @@ -533,10 +563,21 @@ require("diffbandit").setup({ image_colorspace = "auto", -- "srgb" | "display-p3" (auto: P3 for Ghostty on macOS) }, split_blend = 0.3, - overview = { + overview = { -- the diff view's overview gutters enabled = true, width = 1, cursor = true, + viewport = true, -- shade the rows covering the visible range + renderer = "auto", -- "auto" (image when the connector is) | "cells" + min_reduction = nil, -- lines per row below which short files stop stretching (nil = auto) + search = true, -- show 'hlsearch' matches + selection = true, -- show the visual selection + mouse = true, -- click/drag to jump, wheel to scroll the pane + code_shape = true, -- image renderer: draw the code's shape in syntax colours (false: hunk strip) + code_width = 12, -- overview width (cells) with code_shape + focus_follow = true, -- code shape: only the focused pane's strip is full width + compact_width = 4, -- the other pane's strip: the same shape, squeezed + code_debounce_ms = 400, -- rebuild the code shape once edits pause }, status = { enabled = true, @@ -565,6 +606,34 @@ require("diffbandit").setup({ }) ``` +The editor minimap (`:DiffBanditMinimap`) has its own section with the same +rendering options as `ui.overview`: + +```lua +require("diffbandit").setup({ + minimap = { + auto = false, -- open for every eligible window + renderer = "auto", -- "auto" (image when supported) | "cells" + width = 1, -- cell-strip width + code_shape = true, -- image: draw the code's shape in syntax colours + code_width = 12, + min_reduction = nil, + viewport = true, + cursor = true, + search = true, + selection = true, + mouse = true, + git = true, -- hunks against the git index + git_debounce_ms = 300, + diagnostics = { enabled = true, min_severity = "warn" }, + min_window_width = 60, + exclude_filetypes = { "help", "qf", "netrw", "gitcommit", "gitrebase" }, + zindex = 20, + }, +}) +``` + + Theme colors are derived from the active colorscheme's diff highlight groups. Use `ui.theme.colors` for semantic color overrides, or `ui.theme.highlights` to override specific `DiffBandit*` highlight groups. @@ -614,6 +683,39 @@ cell renderer is used, unchanged. set `vim.g.diffbandit_connector_renderer` or `DIFFBANDIT_CONNECTOR_RENDERER`. Inside tmux, auto mode uses cells. - If the terminal rejects an image later, every open view falls back to cells. +- The overview gutters become images too (`ui.overview.renderer = "auto"`). + In the cell overview a long file squeezes several hunks into one cell row. + The image gives each hunk its own mark at its exact position, about 20x + the vertical resolution. One-line hunks stay at least 2px tall, and the + current chunk is outlined. The viewport band and cursor row are cell + backgrounds showing through the image, so scrolling never re-sends it. + Set `ui.overview.renderer = "cells"` to keep the cell overview. +- By default (`ui.overview.code_shape = true`) the image overview is a code + minimap (`code_width` cells, ~120 columns at 1px per character, 2px on + HiDPI); set it to `false` for the one-column hunk strip. Each + line's text is drawn in its treesitter colours, falling back to legacy + syntax for files up to 3000 lines and to the Normal colour otherwise. Hunks + are bands behind the text and search matches are translucent bands. Short + files are not stretched: by default characters are twice as tall as wide + (`ui.overview.min_reduction` sets lines per row explicitly). While you edit, + the previous shape stays until typing pauses for `code_debounce_ms`. +- Only the focused pane's code minimap is full width (`code_width`); the other + pane's is `compact_width` cells, the same shape squeezed horizontally. The + editable right side starts focused. When focus moves to the other pane the + two strips swap sizes and the layout shifts over. The panes keep equal + widths, and the shape is not rebuilt. Set `ui.overview.focus_follow = false` + to keep both strips full width. + +#### Overview interaction + +Both renderers shade the visible range, show the visual selection as a row +band, and mark search matches while `'hlsearch'` is active (a right-half block +in cells, a tick in the image). Click or drag in an overview to centre that +spot in its pane, and use the mouse wheel over it to scroll the pane. Clicks +resolve to the cell row under the mouse because Neovim reports no pixel +positions. Set `ui.overview.min_reduction` (lines per row) to stop short files +from stretching to fill the strip. The overview never scrolls: long files are +always shown whole. `DIFFBANDIT_PERF=1` adds `kitty curves/raster/transmit` stage timings to the render log. `tools/bench_kitty.lua` compares repaint cost against the cell @@ -652,6 +754,7 @@ diffbandit.merge("path/to/conflicted-file") diffbandit.commit_panel({}) diffbandit.git_panel({}) -- working-tree changes, panel open on the first file diffbandit.launch({ "main...HEAD" }) -- what bin/diffbandit runs (git diff-style args) +diffbandit.minimap("toggle") -- editor minimap: "toggle" | "open" | "close" -- Predicates. `is_running()` is the current tab (2-way, merge, or folder). -- Tabpage ids and bufnrs are both integers, so overloads take a table: diff --git a/doc/diffbandit.txt b/doc/diffbandit.txt index 570a590..e3219ae 100644 --- a/doc/diffbandit.txt +++ b/doc/diffbandit.txt @@ -39,7 +39,9 @@ Core features: - Optional commit panel with file staging and commit message entry. - Merge conflict group with a local/result/remote resolver. - Recursive folder comparison using external digest commands. -- One-column overview gutters at the outer document edges. +- Overview gutters (minimaps) at the outer document edges: visible range, + visual selection and search matches; click/drag to jump, wheel to scroll. + Pixel resolution and an optional code-shape minimap with image connectors. - Read-only binary hex comparison view. - Theme-friendly colors derived from the active colorscheme. @@ -74,6 +76,14 @@ COMMANDS *diffbandit-commands* :DiffBanditBuffers {left-bufnr} {right-bufnr} Open a DiffBandit view comparing two loaded buffers. + *:DiffBanditMinimap* +:DiffBanditMinimap [toggle|open|close] + Toggle (default), open or close the minimap of the current window: a + float over its right edge with git hunks against the index, + diagnostics, search matches, the visual selection and the visible + range; the code's shape in syntax colours with image connectors. + Click or drag to jump, wheel to scroll. See |diffbandit-minimap|. + *:DiffBanditFolderDiff* :DiffBanditFolderDiff {left-folder} {right-folder} Open a read-only recursive folder comparison. @@ -498,6 +508,27 @@ Common configuration: enabled = true, width = 1, cursor = true, + -- Shade the rows covering the visible range. + viewport = true, + -- "auto": draw the overview as a pixel-resolution image whenever + -- the connector uses the Kitty image renderer; "cells": never. + renderer = "auto", + -- Lines per row below which short files stop stretching. + -- nil = auto (fill; code shape: characters twice as tall as wide). + min_reduction = nil, + search = true, -- 'hlsearch' matches + selection = true, -- visual selection + mouse = true, -- click/drag to jump, wheel to scroll the pane + -- Image renderer only: draw each line's text in syntax colours + -- (false: the one-column hunk strip). + code_shape = true, + code_width = 12, + -- Code shape: only the focused pane's strip is code_width wide; the + -- other is compact_width (the same shape, squeezed). Focus moving + -- to a pane swaps them. false: both full width. + focus_follow = true, + compact_width = 4, + code_debounce_ms = 400, }, status = { enabled = true, @@ -538,6 +569,32 @@ and DiffChange highlight groups. Use ui.theme.colors for semantic color overrides, or ui.theme.highlights to override specific DiffBandit* groups. ============================================================================== +EDITOR MINIMAP *diffbandit-minimap* + +The minimap of ordinary editor windows shares the diff view's overview +renderer. Configure it under `minimap`: +> + minimap = { + auto = false, -- open for every eligible window + renderer = "auto", -- "auto" (image when supported) | "cells" + width = 1, -- cell-strip width + code_shape = true, -- image: code shape in syntax colours + code_width = 12, + min_reduction = nil, + viewport = true, cursor = true, search = true, selection = true, + mouse = true, + git = true, -- hunks against the git index + git_debounce_ms = 300, + diagnostics = { enabled = true, min_severity = "warn" }, + min_window_width = 60, + exclude_filetypes = { "help", "qf", "netrw", "gitcommit", "gitrebase" }, + zindex = 20, + } +< +It is a float over the window's right edge (the layout never changes; the +last text columns are covered) and adds no mappings to your buffers. Special +buffers, excluded filetypes, floats and DiffBandit's own tabs are skipped. + LUA API *diffbandit-lua-api* > @@ -554,6 +611,7 @@ LUA API *diffbandit-lua-api* diffbandit.git_commit("HEAD") diffbandit.git_panel({}) diffbandit.launch({ "main...HEAD" }) + diffbandit.minimap("toggle") -- "toggle" | "open" | "close" diffbandit.git_compare("main", "feature") diffbandit.git_compare_branches({}) diffbandit.git_checkout("feature") diff --git a/doc/tags b/doc/tags index bcd5089..713768b 100644 --- a/doc/tags +++ b/doc/tags @@ -14,6 +14,7 @@ :DiffBanditGitMenu diffbandit.txt /*:DiffBanditGitMenu* :DiffBanditLaunch diffbandit.txt /*:DiffBanditLaunch* :DiffBanditMerge diffbandit.txt /*:DiffBanditMerge* +:DiffBanditMinimap diffbandit.txt /*:DiffBanditMinimap* :DiffBanditStageHunk diffbandit.txt /*:DiffBanditStageHunk* :DiffBanditToggleStageHunk diffbandit.txt /*:DiffBanditToggleStageHunk* :DiffBanditUndo diffbandit.txt /*:DiffBanditUndo* @@ -41,6 +42,7 @@ diffbandit-launcher diffbandit.txt /*diffbandit-launcher* diffbandit-lua-api diffbandit.txt /*diffbandit-lua-api* diffbandit-mappings diffbandit.txt /*diffbandit-mappings* diffbandit-merge diffbandit.txt /*diffbandit-merge* +diffbandit-minimap diffbandit.txt /*diffbandit-minimap* diffbandit-overview diffbandit.txt /*diffbandit-overview* diffbandit-q diffbandit.txt /*diffbandit-q* diffbandit-testing diffbandit.txt /*diffbandit-testing* diff --git a/lua/diffbandit/config.lua b/lua/diffbandit/config.lua index 11055d2..6bed4f1 100644 --- a/lua/diffbandit/config.lua +++ b/lua/diffbandit/config.lua @@ -71,6 +71,33 @@ local defaults = { enabled = true, width = 1, cursor = true, + -- Shade the rows covering the visible range of the pane. + viewport = true, + -- "auto" draws the overview as a pixel-resolution image whenever the + -- connector uses the Kitty image renderer (every hunk gets its own + -- mark instead of sharing a cell row); "cells" always uses cells. + renderer = "auto", + -- Lines per overview row below which a short file stops stretching + -- and takes only the top of the strip. nil = auto: always fill for + -- the hunk overview; for code shape, characters twice as tall as wide. + min_reduction = nil, + -- Show search matches ('hlsearch' active) and the visual selection. + search = true, + selection = true, + -- Click/drag in the overview to jump, mouse wheel to scroll the pane. + mouse = true, + -- Image renderer only: draw each line's text shape in syntax colours + -- (treesitter, else legacy syntax), code_width cells wide. Set false + -- for the one-column hunk strip (the cell renderer always uses it). + code_shape = true, + code_width = 12, + -- Code shape: only the focused pane's strip is code_width wide; the + -- other pane's is compact_width (the same shape, squeezed). Focus + -- moving to a pane swaps them. false: both full width. + focus_follow = true, + compact_width = 4, + -- Edits rebuild the code shape once typing pauses this long. + code_debounce_ms = 400, -- CursorMoved overview repaint (marks already cached; cursor glyph moves). debounce_ms = 32, }, @@ -210,6 +237,37 @@ local defaults = { close = "q", }, }, + -- Minimap for ordinary editor windows (:DiffBanditMinimap). A float on + -- the window's right edge; same rendering as the diff view's overview + -- (ui.overview), with git hunks against the index and diagnostics. + minimap = { + -- Open for every eligible window automatically (else per command). + auto = false, + -- "auto": a pixel image when the terminal supports Kitty graphics (as + -- resolved for ui.connector_renderer); "cells": a cell strip. + renderer = "auto", + -- Cell-strip width; the image draws the code's shape code_width wide. + width = 1, + code_shape = true, + code_width = 12, + min_reduction = nil, + viewport = true, + cursor = true, + search = true, + selection = true, + mouse = true, + debounce_ms = 32, + code_debounce_ms = 400, + -- Hunks against the git index, refreshed this long after edits. + git = true, + git_debounce_ms = 300, + -- Diagnostics at least this severe ("error" | "warn" | "info" | "hint"). + diagnostics = { enabled = true, min_severity = "warn" }, + -- Windows narrower than this get no minimap. + min_window_width = 60, + exclude_filetypes = { "help", "qf", "netrw", "gitcommit", "gitrebase" }, + zindex = 20, + }, } function M.defaults() diff --git a/lua/diffbandit/git/init.lua b/lua/diffbandit/git/init.lua index 1fe93d6..c16a99c 100644 --- a/lua/diffbandit/git/init.lua +++ b/lua/diffbandit/git/init.lua @@ -911,6 +911,18 @@ function M.read_index(root, path) return read_blob(root, ":" .. path) end +--- Async index blob: callback(text | nil, err) on the main loop. nil text +--- means the path is not in the index (untracked) or git failed. +function M.read_index_async(root, path, callback) + return git_async(root, { "show", "--no-ext-diff", ":" .. path }, function(ok, err, output) + if ok then + callback(output or "", nil) + else + callback(nil, err) + end + end) +end + function M.read_head(root, path, base) return read_blob(root, (base or "HEAD") .. ":" .. path) end diff --git a/lua/diffbandit/highlights.lua b/lua/diffbandit/highlights.lua index 2c4abe4..d0d958e 100644 --- a/lua/diffbandit/highlights.lua +++ b/lua/diffbandit/highlights.lua @@ -17,6 +17,10 @@ local base_defaults = { DiffBanditAccentText = { link = "Identifier" }, DiffBanditOverviewContext = { link = "Normal" }, DiffBanditOverviewCursor = { link = "CursorLineNr" }, + DiffBanditOverviewViewport = { link = "CursorLine" }, + DiffBanditOverviewCursorBand = { link = "Visual" }, + DiffBanditOverviewSearch = { link = "Search" }, + DiffBanditOverviewSelection = { link = "Visual" }, -- Neutral cursorline so it doesn't wash out range backgrounds DiffBanditCursorLine = { bg = "NONE" }, } @@ -341,6 +345,22 @@ local function apply_diff_variants(config) sp = normal_fg, bold = true, }) + -- Visible-range band, and the image overview's cursor row (a background: + -- image placeholder cells cannot carry an underline). + local viewport_bg = blend_color(normal_bg, normal_fg, 0.12) + apply_group("DiffBanditOverviewViewport", { bg = viewport_bg, fg = viewport_bg }) + apply_group("DiffBanditOverviewCursorBand", { bg = blend_color(normal_bg, normal_fg, 0.4) }) + -- Search matches (fg: a right-half block in cells, a tick in the image) + -- and the visual selection (a row background). + local search_color = color_to_number(adopt_diff_colors("Search").bg) or blend_color(normal_bg, 0xE5C07B, 0.85) + apply_group("DiffBanditOverviewSearch", { fg = search_color }) + local visual_bg = color_to_number(adopt_diff_colors("Visual").bg) or blend_color(normal_bg, normal_fg, 0.25) + apply_group("DiffBanditOverviewSelection", { bg = visual_bg, fg = visual_bg }) + local diag_fallback = { Error = 0xE06C75, Warn = 0xE5C07B, Info = 0x61AFEF, Hint = 0x56B6C2 } + for name, fallback in pairs(diag_fallback) do + local fg = color_to_number(adopt_diff_colors("Diagnostic" .. name).fg) or fallback + apply_group("DiffBanditOverviewDiag" .. name, { fg = fg }) + end -- Variants for line numbers with diff backgrounds (for visual text overlay) local line_number_fg = get_foreground_color("LineNr", "#808080") diff --git a/lua/diffbandit/init.lua b/lua/diffbandit/init.lua index 52a8ac3..bac27a3 100644 --- a/lua/diffbandit/init.lua +++ b/lua/diffbandit/init.lua @@ -50,9 +50,28 @@ function M.setup(opts) highlights.apply(config) configure_theme_refresh(config) state.highlights_ready = true + -- The editor minimap loads only when wanted: minimap.auto, or once it is + -- in use (setup again re-reads the config). + if (config.minimap or {}).auto or package.loaded["diffbandit.minimap.editor"] then + require("diffbandit.minimap.editor").setup() + end return config end +--- Minimap for an ordinary editor window: action = "toggle" | "open" | +--- "close" (default toggle), win = window id (default current). +function M.minimap(action, win) + local editor = require("diffbandit.minimap.editor") + ensure_highlights(state.get_config()) + action = action or "toggle" + if action == "open" then + return editor.open(win) + elseif action == "close" then + return editor.close(win) + end + return editor.toggle(win) +end + local function start_session(left_source, right_source, opts) local config = state.get_config() ensure_highlights(config) diff --git a/lua/diffbandit/kitty/canvas.lua b/lua/diffbandit/kitty/canvas.lua index 0378d4c..a81d800 100644 --- a/lua/diffbandit/kitty/canvas.lua +++ b/lua/diffbandit/kitty/canvas.lua @@ -67,6 +67,16 @@ end --- Ribbon palette from the theme-derived connector groups (respects user --- highlight overrides): band fill = DiffBanditConnector.bg, active --- border = DiffBanditConnectorLine.fg. +--- A highlight colour ({r,g,b} or nil) in the image colour space. +function M.image_rgb(config, name, field, space) + local rgb = hl_rgb(name, field) + space = space or color.image_colorspace(config) + if rgb and space == "display-p3" then + return color.srgb_to_display_p3(rgb) + end + return rgb +end + function M.palette(config) local palette = {} local space = color.image_colorspace(config) @@ -192,6 +202,8 @@ local function transmit(host, st, bytes, width, height, layout) st.transmits = st.transmits + 1 return detect.send(table.concat(chunks)) end +-- layout = { cols, rows }: the placement size in cells. +M.transmit = transmit -- --- canvas float --------------------------------------------------------- -- diff --git a/lua/diffbandit/kitty/init.lua b/lua/diffbandit/kitty/init.lua index 9bce909..38b0749 100644 --- a/lua/diffbandit/kitty/init.lua +++ b/lua/diffbandit/kitty/init.lua @@ -8,6 +8,7 @@ local canvas = require("diffbandit.kitty.canvas") local connector_width = require("diffbandit.connector.width") local detect = require("diffbandit.kitty.detect") +local kitty_overview = require("diffbandit.kitty.overview") local M = {} @@ -109,8 +110,10 @@ function M.attach(host, opts) return host.connector_renderer end ---- Drop the terminal images a host owns (single canvas or merge pair slots). +--- Drop the terminal images a host owns (single canvas or merge pair slots, +--- overview strips). function M.release(host) + kitty_overview.destroy(host) canvas.destroy(host) for slot, st in pairs(host.kitty_states or {}) do canvas.destroy_state(st) @@ -120,6 +123,7 @@ end function M.invalidate(host) canvas.invalidate(host) + kitty_overview.invalidate(host) for _, st in pairs(host.kitty_states or {}) do st.last_key = nil end diff --git a/lua/diffbandit/kitty/overview.lua b/lua/diffbandit/kitty/overview.lua new file mode 100644 index 0000000..9e9d8ec --- /dev/null +++ b/lua/diffbandit/kitty/overview.lua @@ -0,0 +1,196 @@ +-- Image overview: the overview strip beside each pane drawn as one Kitty +-- image per side, at pixel resolution (see kitty/overview_raster.lua). +-- +-- The overview sidecar buffer is already static (line r is screen row r and +-- it never scrolls), so its own text is the placeholder grid: no float is +-- needed. The image is re-rasterized and re-sent only when the marks, the +-- strip size, the cell size or the palette change. The viewport band and +-- the cursor row are cell backgrounds (per-row highlight groups that keep +-- the image id as foreground) showing through the transparent pixels, so +-- scrolling and cursor moves only swap highlights. +local canvas = require("diffbandit.kitty.canvas") +local detect = require("diffbandit.kitty.detect") +local protocol = require("diffbandit.kitty.protocol") +local overview_raster = require("diffbandit.kitty.overview_raster") + +local M = {} + +local function hl_bg(name) + local ok, hl = pcall(vim.api.nvim_get_hl, 0, { name = name, link = false }) + return ok and hl and hl.bg or nil +end + +-- Placeholder groups: fg encodes the image id, bg is the cell background +-- shown through transparent pixels (context, viewport band, cursor row). +local function ensure_groups(st) + local groups = { + base = { name = st.hl, bg = hl_bg("DiffBanditOverviewContext") }, + viewport = { name = st.hl .. "Viewport", bg = hl_bg("DiffBanditOverviewViewport") }, + cursor = { name = st.hl .. "Cursor", bg = hl_bg("DiffBanditOverviewCursorBand") }, + selection = { name = st.hl .. "Selection", bg = hl_bg("DiffBanditOverviewSelection") }, + } + for _, g in pairs(groups) do + local ok, hl = pcall(vim.api.nvim_get_hl, 0, { name = g.name, link = false }) + if not (ok and hl and hl.fg == st.id and hl.bg == g.bg) then + vim.api.nvim_set_hl(0, g.name, { fg = protocol.id_to_hex(st.id), bg = g.bg }) + end + end + return groups +end + +local function palette(config) + local base, key = canvas.palette(config) + local out = { add = base.add, delete = base.delete, change = base.change } + out.outline = canvas.image_rgb(config, "DiffBanditOverviewCursor", "fg") + or canvas.image_rgb(config, "Normal", "fg") + out.search = canvas.image_rgb(config, "DiffBanditOverviewSearch", "fg") + local parts = { key, table.concat(out.outline or { "-" }, ","), table.concat(out.search or { "-" }, ",") } + out.diagnostics = {} + for sev, name in ipairs({ "Error", "Warn", "Info", "Hint" }) do + out.diagnostics[sev] = canvas.image_rgb(config, "DiffBanditOverviewDiag" .. name, "fg") + parts[#parts + 1] = table.concat(out.diagnostics[sev] or { "-" }, ",") + end + return out, table.concat(parts, ";") +end + +local function set_placeholder_lines(st, buf, cols, rows) + local key = cols .. "x" .. rows + if st.lines_key == key and st.lines_buf == buf then + return true + end + local lines = {} + for r = 0, rows - 1 do + local run = protocol.placeholder_run(r, 0, cols) + if not run then + return false + end + lines[#lines + 1] = run + end + local modifiable = vim.bo[buf].modifiable + vim.bo[buf].modifiable = true + vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) + vim.bo[buf].modifiable = modifiable + st.lines_key, st.lines_buf = key, buf + return true +end + +--- Whether a strip of cols x rows cells can be addressed by placeholders. +function M.fits(cols, rows) + return cols >= 1 and rows >= 1 and cols <= protocol.MAX_INDEX + 1 and rows <= protocol.MAX_INDEX + 1 +end + +--- Paint one side. opts = { buf, ns, marks, line_count, map_lines?, rows, +--- cols, overlays (util/overview row_overlays: band, selection, +--- cursor_row, 1-based rows), search_lines?, diagnostics?, code? = { runs, key } }. +--- Returns false when the strip cannot be drawn as an image (the caller +--- then draws cells), true otherwise. +function M.paint(host, side, opts) + local buf = opts.buf + if not (buf and vim.api.nvim_buf_is_valid(buf)) or not M.fits(opts.cols, opts.rows) then + M.destroy_side(host, side) + return false + end + host.kitty_overview_states = host.kitty_overview_states or {} + local st = host.kitty_overview_states[side] + if not st then + st = canvas.new_state() + st.host = host + host.kitty_overview_states[side] = st + end + local groups = ensure_groups(st) + if not set_placeholder_lines(st, buf, opts.cols, opts.rows) then + M.destroy_side(host, side) + return false + end + + local cell = detect.cell_size(host.config) + local pal, pal_key = palette(host.config) + -- Cursor moves and scrolling repaint only highlights: reuse the spans and + -- their key while the marks table (providers cache it) and line count are + -- the same, and the search key while the search result table is. + local memo = st.overview_memo or {} + st.overview_memo = memo + if memo.marks ~= opts.marks or memo.line_count ~= opts.line_count then + memo.marks, memo.line_count = opts.marks, opts.line_count + memo.spans = overview_raster.spans(opts.marks) + memo.spans_key = overview_raster.spans_key(memo.spans, opts.line_count) + end + local spans = memo.spans + local map_lines = opts.map_lines or opts.line_count + local search_lines = opts.search_lines or {} + if memo.search ~= search_lines then + memo.search = search_lines + memo.search_key = table.concat(search_lines, ",") + end + local diag_parts = {} + for _, d in ipairs(opts.diagnostics or {}) do + diag_parts[#diag_parts + 1] = d.line .. ":" .. d.severity + end + local key = table.concat({ + table.concat(diag_parts, ","), + opts.cols, opts.rows, cell.w, cell.h, pal_key, memo.spans_key, + tostring(map_lines), memo.search_key, opts.code and opts.code.key or "-", + }, "|") + if key ~= st.last_key then + local spec = { + line_count = opts.line_count, + map_lines = map_lines, + cols = opts.cols, + rows = opts.rows, + cell_w = cell.w, + cell_h = cell.h, + palette = pal, + search_lines = search_lines, + diagnostics = opts.diagnostics, + code = opts.code, + } + local bytes, width, height = overview_raster.render(spans, spec) + canvas.transmit(host, st, bytes, width, height, { cols = opts.cols, rows = opts.rows }) + st.last_key = key + st.last_spans = spans + end + + vim.api.nvim_buf_clear_namespace(buf, opts.ns, 0, -1) + local ov = opts.overlays or {} + local function within(range, row) + return range ~= nil and row >= range.first and row <= range.last + end + for row = 1, opts.rows do + local g = groups.base + if ov.cursor_row == row then + g = groups.cursor + elseif within(ov.selection, row) then + g = groups.selection + elseif within(ov.band, row) then + g = groups.viewport + end + vim.api.nvim_buf_add_highlight(buf, opts.ns, g.name, row - 1, 0, -1) + end + return true +end + +--- Forget transmitted images so the next paint re-sends them. +function M.invalidate(host) + for _, st in pairs(host.kitty_overview_states or {}) do + st.last_key = nil + end +end + +function M.destroy_side(host, side) + local states = host.kitty_overview_states + local st = states and states[side] + if st then + canvas.destroy_state(st) + states[side] = nil + end +end + +--- Delete both sides' images (renderer flip to cells, session close). +function M.destroy(host) + for side in pairs(host.kitty_overview_states or {}) do + M.destroy_side(host, side) + end + host.kitty_overview_states = nil +end + +return M diff --git a/lua/diffbandit/kitty/overview_raster.lua b/lua/diffbandit/kitty/overview_raster.lua new file mode 100644 index 0000000..84b65e8 --- /dev/null +++ b/lua/diffbandit/kitty/overview_raster.lua @@ -0,0 +1,351 @@ +-- Pixel overview strip for the image overview (pure: no Neovim API). +-- +-- The cell overview projects every changed line onto one of `rows` screen +-- rows, so hunks that share a row collapse into one block. The image gives +-- every hunk its own mark at its exact proportional position: with an +-- 18-24px line, the strip has roughly 20x the vertical resolution. +-- +-- Marks are horizontal bars, so every pixel row is uniform apart from its +-- left/right inset columns. Each row is therefore two RGBA values ("edge" +-- for the inset columns, "inner" for the rest), expanded into bytes at the +-- end. The inset leaves a transparent margin around marks so the cell +-- background (viewport band, cursor row) shows on both sides of a mark. The +-- current chunk's outline is drawn in that margin. +local M = {} + +local floor, max, min = math.floor, math.max, math.min + +-- Paint order: later kinds win where marks overlap (same as the cell +-- overview's same-row priority). +local KIND_PRIORITY = { delete = 1, add = 2, change = 3 } + +--- Group per-line marks (overview.build_marks output) into contiguous spans +--- of the same kind and current-chunk flag. Returns spans sorted by line: +--- { first, last, kind, current }. +function M.spans(marks) + local sorted = {} + for _, mark in ipairs(marks or {}) do + if mark.line and KIND_PRIORITY[mark.kind] then + sorted[#sorted + 1] = mark + end + end + table.sort(sorted, function(a, b) + if a.line ~= b.line then + return a.line < b.line + end + return (KIND_PRIORITY[a.kind] or 0) < (KIND_PRIORITY[b.kind] or 0) + end) + local spans = {} + local cur + for _, mark in ipairs(sorted) do + local current = mark.current == true + if cur and cur.kind == mark.kind and cur.current == current and mark.line <= cur.last + 1 then + cur.last = max(cur.last, mark.line) + else + cur = { first = mark.line, last = mark.line, kind = mark.kind, current = current } + spans[#spans + 1] = cur + end + end + return spans +end + +--- Stable key for the raster inputs that depend on the marks. +function M.spans_key(spans, line_count) + local parts = { tostring(line_count) } + for _, s in ipairs(spans) do + parts[#parts + 1] = s.first .. "-" .. s.last .. (s.kind:sub(1, 1)) .. (s.current and "*" or "") + end + return table.concat(parts, ",") +end + +-- Straight-alpha source-over of opaque `rgb` at coverage `a` onto px. +local function blend(px, rgb, a) + if a <= 0 then + return + end + if a >= 1 then + px[1], px[2], px[3], px[4] = rgb[1], rgb[2], rgb[3], 1 + return + end + local da = px[4] + local inv = da * (1 - a) + local oa = a + inv + px[1] = (rgb[1] * a + px[1] * inv) / oa + px[2] = (rgb[2] * a + px[2] * inv) / oa + px[3] = (rgb[3] * a + px[3] * inv) / oa + px[4] = oa +end + +-- Paint the vertical extent [y0, y1) (fractional px) into one column class, +-- with exact coverage on the partial top/bottom rows. +local function paint(column, h, y0, y1, rgb, alpha) + if not rgb or y1 <= y0 then + return + end + alpha = alpha or 1 + for py = max(0, floor(y0)), min(h - 1, math.ceil(y1) - 1) do + local cov = min(y1, py + 1) - max(y0, py) + if cov > 0 then + blend(column[py], rgb, min(1, cov) * alpha) + end + end +end + +-- Widen [y0, y1) to at least `thick` px, kept inside [0, h). A widened mark +-- starts on the whole pixel holding its first line, so it stays crisp +-- instead of straddling two half-covered pixel rows. +local function at_least(y0, y1, thick, h) + -- Marks are rectangles: snap both edges to whole pixels so they stay crisp + -- (a fractional edge would add a half-intensity row that reads as a seam). + y0, y1 = floor(y0 + 0.5), floor(y1 + 0.5) + if y1 - y0 >= thick then + return y0, y1 + end + y1 = y0 + thick + if y0 < 0 then + y0, y1 = 0, thick + elseif y1 > h then + y0, y1 = h - thick, h + end + return max(0, y0), min(h, y1) +end + +local function byte(v) + return floor(v + 0.5) +end + +local function encode(px) + if px[4] <= 0 then + return "\0\0\0\0" + end + return string.char(byte(px[1]), byte(px[2]), byte(px[3]), byte(px[4] * 255)) +end + +--- Pixel layout for a strip: width/height, inset, minimum mark height and +--- outline thickness (px). Exposed for tests. +function M.metrics(spec) + local cw, ch = spec.cell_w, spec.cell_h + local w, h = spec.cols * cw, spec.rows * ch + local inset = spec.inset + if inset == nil then + inset = w >= 5 and max(1, floor(cw / 8)) or 0 + end + inset = max(0, min(inset, floor((w - 1) / 2))) + local char_px = spec.char_px or M.char_px(cw) + -- A code layer built for more columns than fit (the compact strip shares + -- the wide strip's columns) is squeezed: characters become fractional + -- pixels, blended by coverage, so the silhouette survives. + local columns = spec.code and spec.code.columns + if columns and columns > 0 then + char_px = min(char_px, max(1, w - 2 * inset) / columns) + end + return { + width = w, + height = h, + inset = inset, + min_px = spec.min_px or max(2, floor(ch / 10 + 0.5)), + outline_px = max(1, inset), + char_px = char_px, + } +end + +--- Code-shape character width in px for a cell `cell_w` px wide: 1px at +--- ordinary DPI, 2px on HiDPI cells, so a 12-cell strip holds ~120 columns. +function M.char_px(cell_w) + return max(1, floor((cell_w or 10) / 8)) +end + +--- Columns of text the code-shape strip can show. +function M.code_columns(spec) + local m = M.metrics(spec) + return max(1, floor((m.width - 2 * m.inset) / m.char_px)) +end + +--- Pixel extent [y0, y1) of a span before the minimum-height rule. +function M.span_extent(span, line_count, height) + line_count = max(1, line_count or 1) + local first = max(1, min(line_count, span.first)) + local last = max(first, min(line_count, span.last)) + return (first - 1) * height / line_count, last * height / line_count +end + +-- Overlay runs: per pixel row, { x0, x1, rgb, alpha } painted per pixel on +-- top of the row's edge/inner background. +local function add_run(overlay, py, x0, x1, rgb, alpha) + local row = overlay[py] + if not row then + row = {} + overlay[py] = row + end + row[#row + 1] = { x0, x1, rgb, alpha } +end + +-- Code shape: each line's text as char_px-wide pixels. Lines taller than +-- 2px leave a gap below the text (glyphs ~70% of the line); thinner lines +-- share pixel rows and blend by coverage. +local function code_runs(overlay, code, m, map_lines, h) + local lp = h / max(1, map_lines) + local glyph = lp >= 2 and max(1, floor(lp * 0.7)) or lp + local x_max = m.width - m.inset + for line, runs in pairs(code.runs or {}) do + local y0 = (line - 1) * lp + local py0, py1, alpha + if lp >= 2 then + py0 = floor(y0) + py1 = py0 + glyph - 1 + alpha = 0.9 + else + py0 = floor(y0) + py1 = min(h - 1, floor(y0 + glyph - 1e-9)) + alpha = 0.9 * min(1, glyph) + end + if py0 < h then + for _, run in ipairs(runs) do + local x0 = m.inset + run[1] * m.char_px + local x1 = min(x_max, m.inset + run[2] * m.char_px) -- fractional when squeezed + if x1 > x0 then + for py = py0, min(h - 1, py1) do + add_run(overlay, py, x0, x1, run[3], alpha) + end + end + end + end + end +end + +--- Rasterize the strip. spec = { line_count, map_lines?, cols, rows, +--- cell_w, cell_h, palette = { add|delete|change = { fill = {r,g,b} }, +--- outline = {r,g,b}, search = {r,g,b}, diagnostics = { [severity] = rgb } }, +--- search_lines?, diagnostics? = { { line, severity } }, code? = { runs }, +--- inset?, min_px?, char_px? }. code.columns (optional) is the number of +--- text columns the runs were built for; a narrower strip squeezes them. `map_lines` (default line_count) is the +--- number of lines the full height represents. Hunk marks fill the inner +--- columns; with `code`, text is drawn over them and search matches are a +--- translucent band under the text, otherwise a right-half tick on top. +--- Returns bytes (RGBA), width_px, height_px. +function M.render(spans, spec) + local m = M.metrics(spec) + local w, h = m.width, m.height + if w <= 0 or h <= 0 then + return "", w, h + end + local map_lines = max(1, spec.map_lines or spec.line_count or 1) + local edge, inner = {}, {} + for y = 0, h - 1 do + edge[y] = { 0, 0, 0, 0 } + inner[y] = { 0, 0, 0, 0 } + end + local palette = spec.palette or {} + local ordered = {} + for i, s in ipairs(spans or {}) do + ordered[i] = s + end + -- Other chunks first (by kind priority), the current chunk on top. + table.sort(ordered, function(a, b) + if a.current ~= b.current then + return not a.current + end + local pa, pb = KIND_PRIORITY[a.kind] or 0, KIND_PRIORITY[b.kind] or 0 + if pa ~= pb then + return pa < pb + end + return a.first < b.first + end) + + local extents = {} + for i, s in ipairs(ordered) do + local y0, y1 = M.span_extent(s, map_lines, h) + extents[i] = { at_least(y0, y1, m.min_px, h) } + end + -- Outlines go under every current fill, so a chunk made of several spans + -- (a change with embedded adds) reads as one outlined block. + local outline = palette.outline + for i, s in ipairs(ordered) do + if s.current and outline then + local y0, y1 = extents[i][1] - m.outline_px, extents[i][2] + m.outline_px + paint(edge, h, y0, y1, outline) + paint(inner, h, y0, y1, outline) + end + end + for i, s in ipairs(ordered) do + local colors = palette[s.kind] + if colors and colors.fill then + paint(inner, h, extents[i][1], extents[i][2], colors.fill) + end + end + + local overlay = {} + local search = palette.search + if search then + for _, line in ipairs(spec.search_lines or {}) do + local y0, y1 = at_least((line - 1) * h / map_lines, line * h / map_lines, m.min_px, h) + if spec.code then + paint(inner, h, y0, y1, search, 0.6) + else + local x0 = floor(w / 2) + for py = floor(y0), min(h - 1, math.ceil(y1) - 1) do + add_run(overlay, py, x0, w - m.inset, search, 1) + end + end + end + end + if spec.code then + code_runs(overlay, spec.code, m, map_lines, h) + end + -- Diagnostics: a tick over the left third, on top of everything. + local diag_colors = palette.diagnostics or {} + local tick_w = max(2, floor(w / 3)) + for _, d in ipairs(spec.diagnostics or {}) do + local rgb = diag_colors[d.severity] + if rgb then + local y0, y1 = at_least((d.line - 1) * h / map_lines, d.line * h / map_lines, m.min_px, h) + for py = max(0, y0), min(h - 1, y1 - 1) do + add_run(overlay, py, 0, tick_w, rgb, 1) + end + end + end + + local inner_w = w - 2 * m.inset + local rows_out = {} + local cache = {} + local transparent = "\0\0\0\0" + for y = 0, h - 1 do + local runs = overlay[y] + if runs then + -- Per-pixel row: background, then each run blended on top. + local px = {} + local e, n = edge[y], inner[y] + for x = 0, w - 1 do + local src = (x < m.inset or x >= w - m.inset) and e or n + px[x] = { src[1], src[2], src[3], src[4] } + end + for _, run in ipairs(runs) do + local x0, x1 = run[1], run[2] + for x = max(0, floor(x0)), min(w, math.ceil(x1)) - 1 do + local cov = min(x1, x + 1) - max(x0, x) + if cov > 0 then + blend(px[x], run[3], run[4] * min(1, cov)) + end + end + end + local parts = {} + for x = 0, w - 1 do + parts[x + 1] = px[x][4] > 0 and encode(px[x]) or transparent + end + rows_out[y + 1] = table.concat(parts) + else + local e, n = encode(edge[y]), encode(inner[y]) + local key = e .. n + local row = cache[key] + if not row then + local side = string.rep(e, m.inset) + row = side .. string.rep(n, inner_w) .. side + cache[key] = row + end + rows_out[y + 1] = row + end + end + return table.concat(rows_out), w, h +end + +return M diff --git a/lua/diffbandit/minimap/editor.lua b/lua/diffbandit/minimap/editor.lua new file mode 100644 index 0000000..97a3fa2 --- /dev/null +++ b/lua/diffbandit/minimap/editor.lua @@ -0,0 +1,182 @@ +-- Minimaps for ordinary editor windows: one per window, opened by +-- :DiffBanditMinimap or automatically (config.minimap.auto). The strip is a +-- float on the window's right edge (minimap/placement.lua) showing git hunks +-- against the index and diagnostics (minimap/providers.lua). DiffBandit's +-- own tabs (diff, merge, folder, panel) are skipped: their views have their +-- own overview gutters. +local Minimap = require("diffbandit.minimap") +local placement = require("diffbandit.minimap.placement") +local providers = require("diffbandit.minimap.providers") +local state = require("diffbandit.state") + +local M = {} + +local instances = {} +local auto_group + +local function options() + return state.get_config().minimap or {} +end + +local function in_diffbandit_tab(win) + local tab = vim.api.nvim_win_get_tabpage(win) + return state.sessions[tab] ~= nil or state.panels[tab] ~= nil +end + +--- Whether `win` may show a minimap: a normal window on a file-like buffer +--- whose filetype is not excluded, outside DiffBandit's own tabs. +function M.eligible(win) + if not (win and vim.api.nvim_win_is_valid(win)) then + return false + end + if vim.api.nvim_win_get_config(win).relative ~= "" then + return false + end + if in_diffbandit_tab(win) then + return false + end + local buf = vim.api.nvim_win_get_buf(win) + if vim.bo[buf].buftype ~= "" then + return false + end + local ft = vim.bo[buf].filetype + for _, excluded in ipairs(options().exclude_filetypes or {}) do + if ft == excluded then + return false + end + end + return true +end + +function M.get(win) + local mm = instances[win] + if mm and not mm.disposed then + return mm + end + instances[win] = nil + return nil +end + +function M.close(win) + win = (win == nil or win == 0) and vim.api.nvim_get_current_win() or win + local mm = instances[win] + instances[win] = nil + if mm then + mm:close() + end +end + +function M.open(win) + win = (win == nil or win == 0) and vim.api.nvim_get_current_win() or win + if not auto_group then + M.setup() + end + if not M.eligible(win) then + return nil + end + local existing = M.get(win) + if existing then + existing:render() + return existing + end + local config = state.get_config() + local opts = options() + local provider = providers.editor(vim.api.nvim_win_get_buf(win), opts) + local mm = Minimap.new({ + config = config, + options = opts, + target = function() + return win + end, + placement = placement.float({ min_window_width = opts.min_window_width, zindex = opts.zindex }), + provider = provider, + on_target_closed = function() + M.close(win) + end, + }) + mm.editor_provider = provider + instances[win] = mm + mm:render() + return mm +end + +function M.toggle(win) + win = (win == nil or win == 0) and vim.api.nvim_get_current_win() or win + if M.get(win) then + M.close(win) + return nil + end + return M.open(win) +end + +--- All open editor minimaps (tests, :checkhealth-style listing). +function M.list() + local out = {} + for win in pairs(instances) do + local mm = M.get(win) + if mm then + out[#out + 1] = mm + end + end + return out +end + +-- A window switched buffers: rebind, or close when no longer eligible. +local function on_buf_win_enter(win) + local mm = M.get(win) + if mm then + if not M.eligible(win) then + M.close(win) + return + end + local buf = vim.api.nvim_win_get_buf(win) + if mm.editor_provider.bufnr ~= buf then + mm.editor_provider.set_buffer(mm, buf) + end + mm:render() + return + end + if options().auto then + M.open(win) + end +end + +--- Install the buffer-switch tracking (and auto-open when configured). +--- Called from setup(); safe to call again after a config change. +function M.setup() + auto_group = vim.api.nvim_create_augroup("DiffBanditMinimapEditor", { clear = true }) + vim.api.nvim_create_autocmd({ "BufWinEnter", "WinNew", "TabEnter" }, { + group = auto_group, + callback = function() + -- Scheduled: a DiffBandit view registers its tab only after its + -- windows exist, and a new window gets its buffer after WinNew. + vim.schedule(function() + -- Every minimap window, not only the current one: a buffer can be + -- loaded into another window (previews, nvim_win_set_buf). + local current = vim.api.nvim_get_current_win() + local wins = { current } + for win in pairs(instances) do + if win ~= current then + wins[#wins + 1] = win + end + end + for _, win in ipairs(wins) do + if vim.api.nvim_win_is_valid(win) then + on_buf_win_enter(win) + end + end + end) + end, + }) + if options().auto then + vim.schedule(function() + for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do + if M.eligible(win) then + M.open(win) + end + end + end) + end +end + +return M diff --git a/lua/diffbandit/minimap/init.lua b/lua/diffbandit/minimap/init.lua new file mode 100644 index 0000000..b5bf5e2 --- /dev/null +++ b/lua/diffbandit/minimap/init.lua @@ -0,0 +1,475 @@ +-- Minimap: an overview strip describing one target window. +-- +-- The same object backs the diff view's overview gutters and the minimap of +-- ordinary editor windows. What differs is supplied by the caller: +-- * a placement (minimap/placement.lua): the diff layout's sidecar split +-- (managed), or a float on the target window's right edge; +-- * a provider (minimap/providers.lua): the lines, hunk marks and +-- diagnostics to draw (diff-view hunks, or git hunks against the index). +-- The minimap owns everything else: viewport / cursor / selection / search +-- overlays, the scale (min_reduction), the code shape, the image or cell +-- renderer (its own Kitty host, unless a host such as the diff session +-- lends its renderer), its triggers and the mouse. +local color = require("diffbandit.kitty.color") +local detect = require("diffbandit.kitty.detect") +local kitty = require("diffbandit.kitty") +local kitty_overview = require("diffbandit.kitty.overview") +local mouse = require("diffbandit.minimap.mouse") +local overview = require("diffbandit.util.overview") +local overview_raster = require("diffbandit.kitty.overview_raster") +local overview_syntax = require("diffbandit.util.overview_syntax") +local ui = require("diffbandit.util.ui") + +local Minimap = {} +Minimap.__index = Minimap + +local SLOT = "map" +local next_id = 0 + +local function valid_win(win) + return win ~= nil and vim.api.nvim_win_is_valid(win) +end + +local function valid_buf(buf) + return buf ~= nil and vim.api.nvim_buf_is_valid(buf) +end + +--- Strip width (cells) for these options and renderer. +function Minimap.width_for(options, renderer) + return overview.width(options, renderer) +end + +--- spec = { +--- config, full plugin config (Kitty detection, cell size, colour space) +--- options, overview options: config.ui.overview or config.minimap +--- target, fn() -> win: the window the strip describes +--- placement, { ensure(mm, width) -> win, buf | nil, hide(), close() } +--- provider, { buf(mm) -> buf, lines(mm) -> lines, marks(mm) -> marks, +--- line_count?(mm), diagnostics?(mm), attach?(mm), +--- detach?(mm), on_text_changed?(mm) } +--- on_target_closed?(mm) +--- width?(mm, renderer) -> cells: a host-chosen width (the diff view's +--- focus-following wide/compact strips); default from options +--- renderer_host? a Kitty host whose connector_renderer this minimap +--- follows (the diff session, which also sizes the strip from +--- it); it must repaint the minimap on flips and call +--- refresh_images() on theme refreshes. Default: attach as +--- its own host. +--- on_keyboard_enter?(mm) entered by keyboard; default focuses the target +--- } +function Minimap.new(spec) + next_id = next_id + 1 + local mm = setmetatable({ + id = next_id, + config = spec.config, + options = spec.options or {}, + target = spec.target, + placement = spec.placement, + provider = spec.provider, + on_target_closed = spec.on_target_closed, + width_fn = spec.width, + renderer_host = spec.renderer_host, + on_keyboard_enter = spec.on_keyboard_enter, + ns = vim.api.nvim_create_namespace("DiffBanditMinimap" .. next_id), + code_generation = 0, + }, Minimap) + if not mm.renderer_host then + -- Its own Kitty host: resolves "kitty" | "cells" now, follows a later + -- probe answer or a terminal rejection, and re-sends on theme changes. + kitty.attach(mm, { + on_change = function() + mm:render() + end, + refresh = function() + mm:refresh_images() + end, + }) + end + if mm.provider.attach then + mm.provider.attach(mm) + end + mm:install_autocmds() + return mm +end + +function Minimap:target_win() + local win = self.target() + return valid_win(win) and win or nil +end + +function Minimap:target_buf() + local buf = self.provider.buf(self) + return valid_buf(buf) and buf or nil +end + +--- "kitty" | "cells" for this minimap right now. +function Minimap:renderer() + local host = self.renderer_host or self + return overview.renderer(self.options, host.connector_renderer) +end + +--- Theme refresh / retransmission: re-send the image, rebuild syntax colours. +function Minimap:refresh_images() + kitty_overview.invalidate(self) + self.code_generation = self.code_generation + 1 + self:render() +end + +function Minimap:width() + local renderer = self:renderer() + if self.width_fn then + return self.width_fn(self, renderer) + end + return Minimap.width_for(self.options, renderer) +end + +-- --- state ----------------------------------------------------------------- + +local function scale(self, line_count, height, renderer) + local code = overview.code_shape(self.options, renderer) + local cell, char_px + if renderer == "kitty" then + cell = detect.cell_size(self.config) + char_px = overview_raster.char_px(cell.w) + end + local min_red = overview.min_reduction(self.options, code, cell, char_px) + return { + code = code, + cell = cell, + char_px = char_px, + map_lines = overview.map_lines(line_count, height, min_red), + } +end + +local function search_lines(self, lines) + if not overview.search_enabled(self.options) then + return {} + end + local pattern = overview.active_search() + if not pattern then + return {} + end + local buf = self:target_buf() + local tick = buf and vim.api.nvim_buf_get_changedtick(buf) or 0 + local key = table.concat({ + pattern, tostring(vim.o.ignorecase), tostring(vim.o.smartcase), tostring(lines), tick, + }, "\31") + if self.search_cache and self.search_cache.key == key then + return self.search_cache.lines + end + local found = overview.search_lines(lines, pattern) + self.search_cache = { key = key, lines = found } + return found +end + +local VISUAL_MODES = { v = true, V = true, ["\22"] = true } + +local function selection(win) + if vim.api.nvim_get_current_win() ~= win then + return nil + end + if not VISUAL_MODES[vim.api.nvim_get_mode().mode] then + return nil + end + return { first = vim.fn.line("v"), last = vim.fn.line(".") } +end + +-- Code-shape runs, cached per lines, column count and buffer tick. Edits of +-- the same buffer keep the previous shape until typing pauses: treesitter +-- capture iteration costs ~20ms per 1000 lines. The runs always cover the +-- full-width strip's columns (code_width); a compact strip squeezes them, +-- so switching sizes never rebuilds them. +local function code_layer(self, lines, sc) + local cols = Minimap.width_for(self.options, "kitty") + local spec = { cols = cols, rows = 1, cell_w = sc.cell.w, cell_h = sc.cell.h, char_px = sc.char_px } + local max_cols = overview_raster.code_columns(spec) + local buf = self:target_buf() + local tick = buf and vim.api.nvim_buf_get_changedtick(buf) or 0 + -- The filetype is part of the key: a buffer whose filetype is set after + -- the first build (late detection, :setf) gets its syntax colours. + local ft = buf and vim.bo[buf].filetype or "" + local key = table.concat({ tostring(lines), max_cols, tick, self.code_generation, ft }, ":") + local cached = self.code_cache + if cached and cached.key == key then + return cached + end + if cached and cached.buf == buf and cached.columns == max_cols and cached.generation == self.code_generation + and cached.ft == ft then + local delay = tonumber(self.options.code_debounce_ms) or 400 + ui.reschedule_once(self, "code_rebuild_scheduled", function() + if self.code_cache == cached then + self.code_cache = nil + self:render() + end + end, delay) + return cached + end + local space = color.image_colorspace(self.config) + local built = overview_syntax.build(lines, { + buf = buf, + max_cols = max_cols, + tabstop = buf and vim.bo[buf].tabstop or 8, + encode = function(rgb) + if space == "display-p3" then + return color.srgb_to_display_p3(rgb) + end + return rgb + end, + }) + built.key = key + built.buf = buf + built.generation = self.code_generation + built.ft = ft + built.columns = max_cols + self.code_cache = built + return built +end + +--- Everything one paint needs, or nil when the target is gone. +function Minimap:state() + local win = self:target_win() + if not win then + return nil + end + local renderer = self:renderer() + local height = math.max(1, vim.api.nvim_win_get_height(win)) + -- The text itself is only needed for the code shape and search ticks; + -- otherwise a line count is enough (no full-buffer copy per keystroke). + local need_lines = overview.code_shape(self.options, renderer) + or (overview.search_enabled(self.options) and overview.active_search() ~= nil) + or not self.provider.line_count + local lines = need_lines and (self.provider.lines(self) or {}) or {} + local line_count = need_lines and #lines or self.provider.line_count(self) + local sc = scale(self, line_count, height, renderer) + return { + win = win, + renderer = renderer, + scale = sc, + lines = lines, + marks = self.provider.marks(self) or {}, + diagnostics = self.provider.diagnostics and self.provider.diagnostics(self) or {}, + line_count = line_count, + map_lines = sc.map_lines, + height = height, + cursor_line = vim.api.nvim_win_get_cursor(win)[1], + viewport = { first = vim.fn.line("w0", win), last = vim.fn.line("w$", win) }, + selection = overview.selection_enabled(self.options) and selection(win) or nil, + search_lines = search_lines(self, lines), + } +end + +-- --- paint ----------------------------------------------------------------- + +local function set_modifiable(buf, value) + pcall(vim.api.nvim_set_option_value, "modifiable", value, { buf = buf }) +end + +function Minimap:render() + if self.disposed then + return + end + local state = self:state() + if not state then + self.placement.hide(self) + return + end + local width = self:width() + local win, buf = self.placement.ensure(self, width) + if not (valid_win(win) and valid_buf(buf)) then + return + end + self.win, self.buf = win, buf + if overview.mouse_enabled(self.options) then + mouse.register(win, buf, self) + end + set_modifiable(buf, true) + local painted = false + if state.renderer == "kitty" then + painted = kitty_overview.paint(self, SLOT, { + buf = buf, + ns = self.ns, + marks = state.marks, + line_count = state.line_count, + map_lines = state.map_lines, + rows = state.height, + cols = width, + overlays = overview.row_overlays(state, self.options), + search_lines = state.search_lines, + diagnostics = state.diagnostics, + code = state.scale.code and code_layer(self, state.lines, state.scale) or nil, + }) + else + kitty_overview.destroy_side(self, SLOT) + end + if not painted then + overview.render_side(buf, self.ns, state, self.options) + end + set_modifiable(buf, false) +end + +--- Debounced repaint (cursor moves, scrolling, mode changes). +function Minimap:request_render() + if self.disposed then + return + end + local delay = tonumber(self.options.debounce_ms) or 32 + ui.schedule_once(self, "render_scheduled", function() + self:render() + end, delay) +end + +--- The image state of this minimap (tests, perf). +function Minimap:image_state() + return (self.kitty_overview_states or {})[SLOT] +end + +--- Drop the cached code shape (a new document). +function Minimap:reset_code() + self.code_cache = nil +end + +-- --- interaction ------------------------------------------------------------- + +--- Centre the target on the line under strip row `row` (1-based). +function Minimap:jump_to_row(row) + local state = self:state() + if not state then + return + end + local line = overview.line_for_row(row, state.line_count, state.map_lines, state.height) + pcall(vim.api.nvim_win_set_cursor, state.win, { line, 0 }) + pcall(vim.api.nvim_win_call, state.win, function() + vim.cmd("normal! zz") + end) + self:render() +end + +--- Scroll the target by `count` lines (positive = down). +function Minimap:scroll(count) + local win = self:target_win() + if not win or count == 0 then + return + end + local key = count > 0 and "\5" or "\25" + pcall(vim.api.nvim_win_call, win, function() + vim.cmd("normal! " .. math.abs(count) .. key) + end) +end + +function Minimap:focus_target() + local win = self:target_win() + if win and vim.api.nvim_get_current_win() ~= win then + pcall(vim.api.nvim_set_current_win, win) + end +end + +-- --- lifecycle --------------------------------------------------------------- + +function Minimap:install_autocmds() + local group = vim.api.nvim_create_augroup("DiffBanditMinimap" .. self.id, { clear = true }) + self.augroup = group + local function on_target(fn) + return function(ev) + if self.disposed then + return + end + return fn(ev) + end + end + vim.api.nvim_create_autocmd("WinScrolled", { + group = group, + callback = on_target(function() + -- ev.match names only the first scrolled window; v:event has all. + local target = self:target_win() + if target and (vim.v.event or {})[tostring(target)] then + self:request_render() + end + end), + }) + vim.api.nvim_create_autocmd({ "CursorMoved", "CursorMovedI", "ModeChanged" }, { + group = group, + callback = on_target(function() + if vim.api.nvim_get_current_win() == self:target_win() then + self:request_render() + end + end), + }) + vim.api.nvim_create_autocmd("CmdlineLeave", { + group = group, + pattern = { "/", "?" }, + callback = on_target(function() + vim.schedule(function() + self:request_render() + end) + end), + }) + if self.provider.diagnostics then + vim.api.nvim_create_autocmd("DiagnosticChanged", { + group = group, + callback = on_target(function(ev) + if ev.buf == self:target_buf() then + self:request_render() + end + end), + }) + end + if self.provider.on_text_changed then + vim.api.nvim_create_autocmd({ "TextChanged", "TextChangedI" }, { + group = group, + callback = on_target(function(ev) + if ev.buf == self:target_buf() then + self.provider.on_text_changed(self) + end + end), + }) + end + if self.placement.follows_layout then + -- A float must follow its target when the layout moves. + vim.api.nvim_create_autocmd({ "WinResized", "VimResized" }, { + group = group, + callback = on_target(function() + vim.schedule(function() + self:render() + end) + end), + }) + end + vim.api.nvim_create_autocmd("WinClosed", { + group = group, + callback = on_target(function(ev) + if tonumber(ev.match) == self.target() then + vim.schedule(function() + if self.on_target_closed then + self.on_target_closed(self) + else + self:close() + end + end) + end + end), + }) +end + +function Minimap:close() + if self.disposed then + return + end + self.disposed = true + ui.cancel_schedule_once(self, "render_scheduled") + ui.cancel_schedule_once(self, "code_rebuild_scheduled") + if self.augroup then + pcall(vim.api.nvim_del_augroup_by_id, self.augroup) + end + mouse.unregister(self) + if self.provider.detach then + self.provider.detach(self) + end + if self.renderer_host then + kitty_overview.destroy(self) + else + kitty.detach(self) + end + self.placement.close(self) +end + +return Minimap diff --git a/lua/diffbandit/minimap/mouse.lua b/lua/diffbandit/minimap/mouse.lua new file mode 100644 index 0000000..4368750 --- /dev/null +++ b/lua/diffbandit/minimap/mouse.lua @@ -0,0 +1,259 @@ +-- Mouse for minimaps, without mappings in the user's buffers. +-- +-- A click on a minimap window enters it (Neovim focuses even unfocusable +-- windows on click) and getmousepos() names it: WinEnter jumps the target to +-- the clicked row and keeps focus there for a drag. While the minimap holds +-- focus, its own buffer's maps handle (keep jumping), +-- (focus back to the target). The wheel is observed in +-- vim.on_key: over a minimap it scrolls the target. Whatever the wheel does +-- to the strip window itself (a split scrolls) is undone on WinScrolled. +-- Entering a minimap by keyboard (w, :wincmd) bounces to the target, +-- or to wherever the minimap's on_keyboard_enter sends it (the diff view +-- continues the window cycle). +local M = {} + +local by_win = {} +local mapped_bufs = {} +local group +local last_left_press = 0 +local LEFT_MOUSE +local button_down = false +local after_release = {} +local install_observer + +local function now() + local uv = vim.uv or vim.loop + return uv.now() +end + +local function wheel_lines() + local v = tostring(vim.o.mousescroll or ""):match("ver:(%d+)") + return tonumber(v) or 3 +end + +-- Strip row (1-based) under the mouse for minimap window `win`. +local function mouse_row(win) + local pos = vim.fn.getmousepos() + if pos.winid == win then + return pos.winrow + end + local top = vim.api.nvim_win_get_position(win)[1] + return pos.screenrow - top +end + +--- Entering minimap window `win`; `by_mouse` = a left click caused it. +function M.on_enter(win, by_mouse, row) + local mm = by_win[win] + if not mm or mm.disposed then + return + end + if by_mouse then + mm.dragging = true + mm:jump_to_row(row or mouse_row(win)) + else + vim.schedule(function() + if not mm.disposed and vim.api.nvim_get_current_win() == win then + if mm.on_keyboard_enter then + mm.on_keyboard_enter(mm) + else + mm:focus_target() + end + end + end) + end +end + +--- Whether the latest left click (within a second) entered window `win`. +function M.clicked(win) + return vim.fn.getmousepos().winid == win and (now() - last_left_press) < 1000 +end + +local function flush_after_release() + local pending = after_release + after_release = {} + for _, fn in ipairs(pending) do + vim.schedule(fn) + end +end + +--- Run `fn` now, or once the left button is released when a click or drag +--- is in progress (layout changes mid-drag would shift the selection). +function M.when_released(fn) + install_observer() + if not button_down then + fn() + return + end + after_release[#after_release + 1] = fn +end + +--- Minimap window `win` scrolled: the strip stays at its top (line r is +--- strip row r). The target scroll comes from the wheel observer. +function M.on_scrolled(win) + local mm = by_win[win] + if not mm or mm.disposed or not vim.api.nvim_win_is_valid(win) then + return + end + if vim.fn.line("w0", win) ~= 1 then + vim.api.nvim_win_call(win, function() + vim.fn.winrestview({ topline = 1 }) + end) + end +end + +--- A wheel step over minimap window `win` (+1 down, -1 up). +function M.on_wheel(win, direction) + local mm = by_win[win] + if not mm or mm.disposed then + return + end + mm:scroll(direction * wheel_lines()) +end + +local observer_installed = false + +install_observer = function() + if observer_installed then + return + end + observer_installed = true + local function code(k) + return vim.keycode and vim.keycode(k) or vim.api.nvim_replace_termcodes(k, true, true, true) + end + LEFT_MOUSE = code("") + local LEFT_DRAG = code("") + local WHEEL = { [code("")] = 1, [code("")] = -1 } + -- Observe (never consume) keys: a WinEnter right after a is + -- a click; any other entry is the keyboard. The button state defers + -- layout changes (when_released) until a click or drag ends; any other + -- key also ends it, in case the release happened outside Neovim. + vim.on_key(function(key, typed) + local k = (typed ~= nil and typed ~= "") and typed or key + if k == LEFT_MOUSE then + last_left_press = now() + button_down = true + return + end + if k == LEFT_DRAG then + return + end + if button_down then + button_down = false + flush_after_release() + end + if WHEEL[k] then + local win = vim.fn.getmousepos().winid + if by_win[win] then + local dir = WHEEL[k] + vim.schedule(function() + M.on_wheel(win, dir) + end) + end + end + end, vim.api.nvim_create_namespace("DiffBanditMinimapMouse")) +end + +local function install() + if group then + return + end + install_observer() + group = vim.api.nvim_create_augroup("DiffBanditMinimapMouse", { clear = true }) + vim.api.nvim_create_autocmd("WinEnter", { + group = group, + callback = function() + local win = vim.api.nvim_get_current_win() + if not by_win[win] then + return + end + M.on_enter(win, M.clicked(win)) + end, + }) + vim.api.nvim_create_autocmd("WinScrolled", { + group = group, + callback = function() + -- One WinScrolled names only the first scrolled window; v:event + -- lists them all. + for id in pairs(vim.v.event or {}) do + local win = tonumber(id) + if win and by_win[win] then + M.on_scrolled(win) + end + end + end, + }) +end + +local function map_buffer(buf) + if mapped_bufs[buf] then + return + end + mapped_bufs[buf] = true + local function current() + return by_win[vim.api.nvim_get_current_win()] + end + local function map(lhs, fn) + vim.keymap.set({ "n", "x" }, lhs, function() + local mm = current() + if mm and not mm.disposed then + fn(mm, vim.api.nvim_get_current_win()) + end + end, { buffer = buf, silent = true, nowait = true, desc = "DiffBandit minimap" }) + end + map("", function(mm, win) + mm.dragging = true + mm:jump_to_row(mouse_row(win)) + end) + map("", function(mm, win) + mm:jump_to_row(mouse_row(win)) + end) + map("", function(mm) + mm.dragging = false + mm:focus_target() + end) + -- The wheel is handled by the key observer; keep it from scrolling the + -- strip while it has focus. + map("", function() end) + map("", function() end) + -- Leaving by keyboard after a click (no release seen) still returns. + for _, lhs in ipairs({ "", "q", "i", "a", "" }) do + map(lhs, function(mm) + mm.dragging = false + mm:focus_target() + end) + end + vim.api.nvim_create_autocmd("BufWipeout", { + group = group, + buffer = buf, + callback = function() + mapped_bufs[buf] = nil + end, + }) +end + +--- Route mouse events of minimap window `win` (buffer `buf`) to `mm`. +function M.register(win, buf, mm) + install() + for w, other in pairs(by_win) do + if other == mm and w ~= win then + by_win[w] = nil + end + end + by_win[win] = mm + map_buffer(buf) +end + +function M.unregister(mm) + for w, other in pairs(by_win) do + if other == mm then + by_win[w] = nil + end + end +end + +--- Minimap registered for window `win` (tests). +function M.minimap_for(win) + return by_win[win] +end + +return M diff --git a/lua/diffbandit/minimap/placement.lua b/lua/diffbandit/minimap/placement.lua new file mode 100644 index 0000000..36de710 --- /dev/null +++ b/lua/diffbandit/minimap/placement.lua @@ -0,0 +1,145 @@ +-- Where a minimap's strip lives. +-- +-- managed: a window/buffer pair owned by a host layout (the diff view's +-- overview sidecar splits). The host sizes and closes them. +-- float: a float over the right edge of an editor window's text area, +-- editor-relative (like the connector canvas) and re-placed whenever the +-- layout changes. It never disturbs the window layout; it covers the last +-- `width` text columns, and hides when the window is too narrow. +local M = {} + +local function valid_win(win) + return win ~= nil and vim.api.nvim_win_is_valid(win) +end + +local function valid_buf(buf) + return buf ~= nil and vim.api.nvim_buf_is_valid(buf) +end + +function M.managed(get_win, get_buf) + return { + ensure = function() + local win, buf = get_win(), get_buf() + if valid_win(win) and valid_buf(buf) then + return win, buf + end + return nil + end, + hide = function() end, + close = function() end, + } +end + +local function scratch_buf() + local buf = vim.api.nvim_create_buf(false, true) + vim.bo[buf].bufhidden = "hide" + vim.bo[buf].buftype = "nofile" + vim.bo[buf].swapfile = false + vim.bo[buf].undolevels = -1 + vim.bo[buf].filetype = "diffbandit_minimap" + return buf +end + +--- Text-area geometry of `win` in editor cells: row, col, width, height +--- (0-based row/col, winbar excluded). +function M.text_area(win) + local info = vim.fn.getwininfo(win)[1] + if not info then + return nil + end + return { + row = info.winrow - 1 + (info.winbar or 0), + col = info.wincol - 1, + width = info.width, + height = info.height, + } +end + +--- opts = { min_window_width, zindex } +function M.float(opts) + opts = opts or {} + local self = { follows_layout = true } + + local function close_win() + if valid_win(self.win) then + pcall(vim.api.nvim_win_close, self.win, true) + end + self.win, self.cfg_key = nil, nil + end + + function self.ensure(mm, width) + local target = mm:target_win() + if not target then + close_win() + return nil + end + -- Editor-relative floats open in the current tab: a background tab's + -- minimap waits for its tab to be shown. + if vim.api.nvim_win_get_tabpage(target) ~= vim.api.nvim_get_current_tabpage() then + return nil + end + local area = M.text_area(target) + local min_width = tonumber(opts.min_window_width) or 60 + if not area or area.width < math.max(min_width, width + 1) or area.height < 1 then + close_win() + return nil + end + if not valid_buf(self.buf) then + self.buf = scratch_buf() + end + local cfg = { + relative = "editor", + row = area.row, + col = area.col + area.width - width, + width = width, + height = area.height, + focusable = false, + zindex = tonumber(opts.zindex) or 20, + border = "none", + } + local key = table.concat({ cfg.row, cfg.col, cfg.width, cfg.height }, ":") + if valid_win(self.win) then + if self.cfg_key ~= key then + pcall(vim.api.nvim_win_set_config, self.win, cfg) + self.cfg_key = key + end + return self.win, self.buf + end + local open_cfg = vim.tbl_extend("force", cfg, { style = "minimal", noautocmd = true, mouse = true }) + local ok, win = pcall(vim.api.nvim_open_win, self.buf, false, open_cfg) + if not ok then + open_cfg.mouse = nil + ok, win = pcall(vim.api.nvim_open_win, self.buf, false, open_cfg) + end + if not ok then + return nil + end + local hl = "DiffBanditOverviewContext" + vim.wo[win].winhighlight = table.concat({ + "Normal:" .. hl, "NormalNC:" .. hl, "NormalFloat:" .. hl, "EndOfBuffer:" .. hl, + }, ",") + vim.wo[win].wrap = false + vim.wo[win].cursorline = false + vim.wo[win].winblend = 0 + vim.wo[win].fillchars = "eob: " + vim.wo[win].scrolloff = 0 + self.win, self.cfg_key = win, key + return win, self.buf + end + + function self.hide() + close_win() + end + + function self.close() + close_win() + if valid_buf(self.buf) then + pcall(vim.api.nvim_buf_delete, self.buf, { force = true }) + end + self.buf = nil + end + + return self +end + +return M diff --git a/lua/diffbandit/minimap/providers.lua b/lua/diffbandit/minimap/providers.lua new file mode 100644 index 0000000..e345cca --- /dev/null +++ b/lua/diffbandit/minimap/providers.lua @@ -0,0 +1,245 @@ +-- What a minimap draws: lines (code shape, scale), hunk marks and +-- diagnostics. +-- +-- session_side: one side of a 2-way diff view (its hunks, its lines). +-- editor: an ordinary buffer: hunks of the buffer against the git +-- index (read asynchronously, re-diffed with vim.diff after +-- edits) and vim.diagnostic entries. +local overview = require("diffbandit.util.overview") +local ui = require("diffbandit.util.ui") + +local M = {} + +function M.session_side(session, side) + local function buf() + return side == "left" and session.left_buf or session.right_buf + end + return { + buf = buf, + lines = function() + return ((session.view or {})[side]) or {} + end, + marks = function() + return session:overview_marks(side) + end, + } +end + +-- --- git hunks --------------------------------------------------------------- + +local root_cache = {} + +local function git_root(path) + local dir = vim.fn.fnamemodify(path, ":h") + local cached = root_cache[dir] + if cached ~= nil then + return cached or nil + end + local ok, git = pcall(require, "diffbandit.git") + local root = ok and git.find_root(dir) or nil + root_cache[dir] = root or false + return root +end + +local function text_diff() + return (vim.text and vim.text.diff) or vim.diff +end + +--- Hunks of `new` against `old` (line lists) as +--- { { start, count, kind } } in `new` lines; a deletion is a zero-count +--- hunk at the line after which lines were removed (at least 1). +function M.line_hunks(old, new) + local a = #old > 0 and (table.concat(old, "\n") .. "\n") or "" + local b = #new > 0 and (table.concat(new, "\n") .. "\n") or "" + local ok, indices = pcall(text_diff(), a, b, { result_type = "indices" }) + local out = {} + if not ok or type(indices) ~= "table" then + return out + end + for _, h in ipairs(indices) do + local ca, sb, cb = h[2], h[3], h[4] + if cb == 0 then + out[#out + 1] = { start = math.max(1, sb), count = 0, kind = "delete" } + elseif ca == 0 then + out[#out + 1] = { start = sb, count = cb, kind = "add" } + else + out[#out + 1] = { start = sb, count = cb, kind = "change" } + end + end + return out +end + +--- Per-line marks from line hunks; the hunk under `cursor` is current. +function M.hunk_marks(hunks, cursor) + local marks = {} + for _, h in ipairs(hunks or {}) do + local last = h.start + math.max(1, h.count) - 1 + local current = cursor ~= nil and cursor >= h.start and cursor <= last + for line = h.start, last do + marks[#marks + 1] = { line = line, kind = h.kind, current = current } + end + end + return marks +end + +--- opts = config.minimap (git, git_debounce_ms, diagnostics). +function M.editor(bufnr, opts) + opts = opts or {} + local p = { bufnr = bufnr } + local git_state = {} + + local function lines() + if not vim.api.nvim_buf_is_valid(p.bufnr) then + return {} + end + local tick = vim.api.nvim_buf_get_changedtick(p.bufnr) + if p.lines_tick ~= tick then + p.lines_cache = vim.api.nvim_buf_get_lines(p.bufnr, 0, -1, false) + p.lines_tick = tick + end + return p.lines_cache + end + + local function rediff(mm) + if git_state.index_lines then + git_state.hunks = M.line_hunks(git_state.index_lines, lines()) + else + git_state.hunks = {} + end + if mm then + mm:request_render() + end + end + + local function refresh_index(mm) + if opts.git == false or not vim.api.nvim_buf_is_valid(p.bufnr) then + return + end + local name = vim.api.nvim_buf_get_name(p.bufnr) + if name == "" or vim.bo[p.bufnr].buftype ~= "" then + return + end + local root = git_root(name) + if not root then + return + end + local git = require("diffbandit.git") + local rel = git.relpath(root, name) + local gen = (git_state.gen or 0) + 1 + git_state.gen = gen + git.read_index_async(root, rel, function(text) + if git_state.gen ~= gen or (mm and mm.disposed) then + return + end + if text then + local index_lines = vim.split(text, "\n", { plain = true }) + if index_lines[#index_lines] == "" then + index_lines[#index_lines] = nil + end + git_state.index_lines = index_lines + else + git_state.index_lines = nil -- untracked or not in the index + end + rediff(mm) + end) + end + + function p.buf() + return p.bufnr + end + + p.lines = lines + + function p.line_count() + if not vim.api.nvim_buf_is_valid(p.bufnr) then + return 0 + end + return vim.api.nvim_buf_line_count(p.bufnr) + end + + -- Marks are rebuilt only when the hunks or the hunk under the cursor + -- change, so the image key and spans can be reused by table identity. + function p.marks(mm) + local win = mm:target_win() + local cursor = win and vim.api.nvim_win_get_cursor(win)[1] or nil + local hunks = git_state.hunks + local current = 0 + for i, h in ipairs(hunks or {}) do + if cursor and cursor >= h.start and cursor <= h.start + math.max(1, h.count) - 1 then + current = i + break + end + end + local cache = p.marks_cache + if not (cache and cache.hunks == hunks and cache.current == current) then + cache = { hunks = hunks, current = current, marks = M.hunk_marks(hunks, cursor) } + p.marks_cache = cache + end + return cache.marks + end + + local diag = opts.diagnostics + if type(diag) ~= "table" then + diag = { enabled = diag ~= false } + end + if diag.enabled ~= false then + function p.diagnostics() + return overview.buffer_diagnostics(p.bufnr, diag.min_severity or "warn") + end + end + + function p.on_text_changed(mm) + ui.reschedule_once(p, "git_rediff_scheduled", function() + if not mm.disposed then + rediff(mm) + end + end, tonumber(opts.git_debounce_ms) or 300) + end + + function p.attach(mm) + p.augroup = vim.api.nvim_create_augroup("DiffBanditMinimapGit" .. mm.id, { clear = true }) + -- The index changes on write-and-stage, external git commands (focus + -- returns) and DiffBandit's own staging (buffer entered again). + vim.api.nvim_create_autocmd({ "BufWritePost", "BufEnter", "FocusGained" }, { + group = p.augroup, + callback = function(ev) + if ev.event == "FocusGained" or ev.buf == p.bufnr then + refresh_index(mm) + end + end, + }) + refresh_index(mm) + end + + function p.detach() + ui.cancel_schedule_once(p, "git_rediff_scheduled") + git_state.gen = (git_state.gen or 0) + 1 + if p.augroup then + pcall(vim.api.nvim_del_augroup_by_id, p.augroup) + p.augroup = nil + end + end + + --- The target window now shows another buffer. + function p.set_buffer(mm, buf) + p.bufnr = buf + p.lines_cache, p.lines_tick = nil, nil + git_state.index_lines, git_state.hunks = nil, nil + mm:reset_code() + refresh_index(mm) + end + + --- Test hook: the git state. + function p._git_state() + return git_state + end + + return p +end + +--- Forget cached repository roots (tests, repositories created later). +function M.reset_roots() + root_cache = {} +end + +return M diff --git a/lua/diffbandit/session/init.lua b/lua/diffbandit/session/init.lua index 2910b1a..d7f46c3 100644 --- a/lua/diffbandit/session/init.lua +++ b/lua/diffbandit/session/init.lua @@ -16,6 +16,10 @@ local config_mod = require("diffbandit.config") local view_builder = require("diffbandit.diff.view") local diff_document = require("diffbandit.diff.document") local kitty = require("diffbandit.kitty") +local Minimap = require("diffbandit.minimap") +local minimap_placement = require("diffbandit.minimap.placement") +local minimap_providers = require("diffbandit.minimap.providers") +local minimap_mouse = require("diffbandit.minimap.mouse") local Session = {} Session.__index = Session @@ -113,13 +117,17 @@ function Session.start(sources, config, opts) self.ns = vim.api.nvim_create_namespace("DiffBanditHighlights" .. self.id) self.active_ns = vim.api.nvim_create_namespace("DiffBanditActive" .. self.id) self.path_ns = vim.api.nvim_create_namespace("DiffBanditConnectorPaths" .. self.id) - self.overview_ns = vim.api.nvim_create_namespace("DiffBanditOverview" .. self.id) self.autocmd_group = nil self.disposed = false assign_pane_metrics(self, sources, view) - self.overview_enabled = overview.enabled(self.config) - self.overview_width = self.overview_enabled and overview.width(self.config) or 0 + self.overview_enabled = overview.enabled(config_mod.section(self.config, "ui", "overview")) + -- Provisional (cells) width; fixed up once the Kitty renderer resolves. + self.overview_width = self.overview_enabled and overview.width(config_mod.section(self.config, "ui", "overview")) or 0 + self.left_overview_width, self.right_overview_width = self.overview_width, self.overview_width + -- The focused side's code-shape strip is full width, the other compact; + -- the editable right side starts focused. + self.overview_expanded_side = self.overview_expanded_side or "right" self.connector_width_cache = {} self:invalidate_render_caches() if model.base_paths then @@ -149,6 +157,12 @@ function Session.start(sources, config, opts) self:precompute_connector_core_width() self.last_viewport_render_key = nil self:rerender_for_viewport() + -- Viewport-only paints skip the overview; it switches renderer too, + -- and the code-shape image is wider than the cell overview. + if self:update_overview_widths() then + self:resize_layout() + end + self:render_overviews() end, refresh = function() if self.disposed then @@ -156,10 +170,16 @@ function Session.start(sources, config, opts) end self.last_viewport_render_key = nil self:rerender_for_viewport() + for _, mm in pairs(self.minimaps or {}) do + mm:refresh_images() + end end, }) + -- The overview widths depend on the resolved renderer (code shape). + self:update_overview_widths() self:open_layout() + self:create_minimaps() if self.panel_enabled then panel_mod.attach(self, { initial_selection = self.panel_initial_selection, @@ -215,52 +235,128 @@ function Session:resize_layout() session_layout.resize(self) end -function Session:render_overviews() +-- Overview gutters: two managed minimaps (minimap/), one per pane, drawn +-- into the layout's sidecar splits. They own their triggers (cursor, scroll, +-- mode, search), images and mouse; the session repaints them on full renders +-- (hunks or the current chunk changed). +function Session:overview_options() + return config_mod.section(self.config, "ui", "overview") +end + +-- Strip width of `side` (or the full width when nil). With the code shape +-- and ui.overview.focus_follow, only the focused side's strip is full width; +-- the other is compact (the same code shape, squeezed). +function Session:overview_strip_width(side) if not self.overview_enabled then + return 0 + end + local opts = self:overview_options() + local renderer = overview.renderer(opts, self.connector_renderer) + local width = Minimap.width_for(opts, renderer) + if side and overview.code_shape(opts, renderer) and opts.focus_follow ~= false + and side ~= self.overview_expanded_side then + width = math.max(2, math.min(width, tonumber(opts.compact_width) or 4)) + end + return width +end + +--- Recompute both strip widths; true when either changed. +function Session:update_overview_widths() + local left, right = self:overview_strip_width("left"), self:overview_strip_width("right") + local changed = left ~= self.left_overview_width or right ~= self.right_overview_width + self.left_overview_width, self.right_overview_width = left, right + self.overview_width = math.max(left, right) + return changed +end + +--- Give `side`'s overview the full width (focus moved to that pane); the +--- other side's strip turns compact and the panes re-balance. +function Session:expand_overview(side) + if self.disposed or self.overview_expanded_side == side then return end - if not ( - self.left_overview_buf - and self.right_overview_buf - and self.left_overview_win - and self.right_overview_win - and vim.api.nvim_buf_is_valid(self.left_overview_buf) - and vim.api.nvim_buf_is_valid(self.right_overview_buf) - and vim.api.nvim_win_is_valid(self.left_overview_win) - and vim.api.nvim_win_is_valid(self.right_overview_win) - ) then + self.overview_expanded_side = side + if not self.minimaps or not self:update_overview_widths() then return end + self:resize_layout() + self:render_overviews() + -- The panes moved: the connector follows on WinResized; repaint routes. + self.last_viewport_render_key = nil + self:request_viewport_rerender() +end - local left_height = math.max(1, vim.api.nvim_win_get_height(self.left_win)) - local right_height = math.max(1, vim.api.nvim_win_get_height(self.right_win)) - local left_cursor = vim.api.nvim_win_is_valid(self.left_win) and vim.api.nvim_win_get_cursor(self.left_win)[1] or 1 - local right_cursor = vim.api.nvim_win_is_valid(self.right_win) and vim.api.nvim_win_get_cursor(self.right_win)[1] or 1 - - set_buffer_options(self.left_overview_buf, { modifiable = true }) - set_buffer_options(self.right_overview_buf, { modifiable = true }) - overview.render_side_with_marks( - self.left_overview_buf, - self.overview_ns, - self:overview_marks("left"), - #((self.view or {}).left or {}), - left_height, - left_cursor, - self.current_chunk, - self.config - ) - overview.render_side_with_marks( - self.right_overview_buf, - self.overview_ns, - self:overview_marks("right"), - #((self.view or {}).right or {}), - right_height, - right_cursor, - self.current_chunk, - self.config - ) - set_buffer_options(self.left_overview_buf, { modifiable = false }) - set_buffer_options(self.right_overview_buf, { modifiable = false }) +-- Resizing the panes in the middle of a click or drag would move the text +-- under the mouse (the selection lands columns off): wait for the release. +function Session:expand_overview_after_click(side) + minimap_mouse.when_released(function() + self:expand_overview(side) + end) +end + +-- Focus landed on chrome (gutters, headers, overview strips): continue the +-- window cycle into the source pane other than the last one used. +function Session:leave_chrome() + if self.disposed then + return + end + local target + if self.last_source_side == "left" then + target = self.right_win + elseif self.last_source_side == "right" then + target = self.left_win + else + target = self.right_win + end + if target and vim.api.nvim_win_is_valid(target) then + pcall(vim.api.nvim_set_current_win, target) + end +end + +function Session:create_minimaps() + if not self.overview_enabled or self.minimaps then + return + end + self.minimaps = {} + for _, side in ipairs({ "left", "right" }) do + self.minimaps[side] = Minimap.new({ + config = self.config, + options = self:overview_options(), + target = function() + return side == "left" and self.left_win or self.right_win + end, + placement = minimap_placement.managed(function() + return self[side .. "_overview_win"] + end, function() + return self[side .. "_overview_buf"] + end), + provider = minimap_providers.session_side(self, side), + width = function() + return self[side .. "_overview_width"] or self.overview_width + end, + -- The layout closes the panes; the session closes its minimaps. + on_target_closed = function() end, + -- The session sizes the strips from its own renderer; one source of + -- truth, so the width and the paint never disagree after a flip. + renderer_host = self, + on_keyboard_enter = function() + self:leave_chrome() + end, + }) + end +end + +function Session:render_overviews() + for _, mm in pairs(self.minimaps or {}) do + mm:render() + end +end + +function Session:close_minimaps() + for _, mm in pairs(self.minimaps or {}) do + mm:close() + end + self.minimaps = nil end function Session:get_scroll_padding() @@ -354,34 +450,27 @@ function Session:setup_autocmds() if win == self.left_win then self.last_source_win = self.left_win self.last_source_side = "left" + self:expand_overview_after_click("left") elseif win == self.right_win then if self.right and self.right.editable then document.ensure_language_features(self.right_buf, self.right.filetype) end self.last_source_win = self.right_win self.last_source_side = "right" + self:expand_overview_after_click("right") elseif win == self.left_num_win - or win == self.left_overview_win or win == self.connector_win or win == self.right_num_win - or win == self.right_overview_win or win == self.left_header_win or win == self.center_header_win - or win == self.right_header_win then + or win == self.right_header_win + or ((win == self.left_overview_win or win == self.right_overview_win) + and not minimap_mouse.minimap_for(win)) then + -- Overview strips with the mouse enabled are handled by + -- minimap/mouse (a click jumps; keyboard entry calls + -- leave_chrome through on_keyboard_enter). vim.schedule(function() - if not self.disposed then - local target - if self.last_source_side == "left" then - target = self.right_win - elseif self.last_source_side == "right" then - target = self.left_win - else - target = self.right_win - end - if target and vim.api.nvim_win_is_valid(target) then - pcall(vim.api.nvim_set_current_win, target) - end - end + self:leave_chrome() end) end end, @@ -418,7 +507,6 @@ function Session:setup_autocmds() self.last_source_win = self.left_win self.last_source_side = "left" self:sync_from_left() - self:request_overview_render() end, }) @@ -433,7 +521,6 @@ function Session:setup_autocmds() self.last_source_win = self.right_win self.last_source_side = "right" self:sync_from_right() - self:request_overview_render() end, }) @@ -683,21 +770,6 @@ function Session:request_viewport_rerender() end, delay) end --- Debounced overview paint for CursorMoved (full render still paints immediately). -function Session:request_overview_render() - if self.disposed or not self.overview_enabled then - return - end - local overview_cfg = config_mod.section(self.config, "ui", "overview") - local delay = tonumber(overview_cfg.debounce_ms) - if delay == nil then - delay = 32 - end - ui.schedule_once(self, "overview_rerender_scheduled", function() - self:render_overviews() - end, delay) -end - -- Immediate re-diff after editable right text changed (also used after undo). function Session:refresh_editable_right() if self.disposed or not (self.right and self.right.editable) then @@ -964,7 +1036,6 @@ function Session:goto_document_edge(edge) -- changed, so refresh status/overview explicitly for top/bottom position. self:set_viewport_toplines_preserve_cursors(left_topline, right_topline, left_line, right_line) self:render_status_headers() - ui.cancel_schedule_once(self, "overview_rerender_scheduled") self:render_overviews() if focused_win == self.left_win or focused_win == self.right_win then @@ -993,6 +1064,7 @@ function Session:dispose() return end self.disposed = true + self:close_minimaps() kitty.detach(self) self:clear_keymaps() -- Editable right reuses the user's buffer; without this, band/intraline @@ -1084,6 +1156,14 @@ end function Session:replace_sources(sources, opts) opts = opts or {} self:clear_keymaps() + -- A new document builds its code-shape overview at once; a re-diff of + -- the same sources (typing in the editable right) keeps the shape and + -- rebuilds it after code_debounce_ms. + if not (sources and sources.left == self.left and sources.right == self.right) then + for _, mm in pairs(self.minimaps or {}) do + mm:reset_code() + end + end if sources and sources.right and sources.right.editable then local acquired, acquire_err = document.acquire_buffer(sources.right.editable) if acquired then diff --git a/lua/diffbandit/session/layout.lua b/lua/diffbandit/session/layout.lua index 615d699..fda5630 100644 --- a/lua/diffbandit/session/layout.lua +++ b/lua/diffbandit/session/layout.lua @@ -14,6 +14,14 @@ local set_window_height = nvim.set_window_height local M = {} + +-- Overview strip width of one side: the focused side's strip can be wide +-- while the other is compact (session.left/right_overview_width); hosts +-- without per-side widths use session.overview_width for both. +local function overview_width(session, side) + return session[side .. "_overview_width"] or session.overview_width or 0 +end + local function create_buffers(session) -- Create buffers with basic options first (avoid bufhidden=wipe until in windows) local left_buf = vim.api.nvim_create_buf(false, true) @@ -169,7 +177,7 @@ local function open_windows(session) split = "right", win = panel_nav_win, }) - left_overview_win = open_sidecar_win(session.left_overview_buf, left_win, "left", session.overview_width) + left_overview_win = open_sidecar_win(session.left_overview_buf, left_win, "left", overview_width(session, "left")) end panel_commit_win = vim.api.nvim_open_win(session.panel.commit_buf, false, { split = "below", @@ -187,7 +195,7 @@ local function open_windows(session) split = "below", win = left_header_win, }) - left_overview_win = open_sidecar_win(session.left_overview_buf, left_win, "left", session.overview_width) + left_overview_win = open_sidecar_win(session.left_overview_buf, left_win, "left", overview_width(session, "left")) center_header_win = open_status_win(session.center_header_buf, left_header_win, "right") right_header_win = open_status_win(session.right_header_buf, center_header_win, "right") @@ -199,14 +207,14 @@ local function open_windows(session) left_num_win = open_gutter_win(session.left_num_buf, left_win, session.left_number_pane_width) connector_win = open_gutter_win(session.connector_buf, left_num_win, session.connector_core_width) right_num_win = open_gutter_win(session.right_num_buf, connector_win, session.right_number_pane_width) - right_overview_win = open_sidecar_win(session.right_overview_buf, right_win, "right", session.overview_width) + right_overview_win = open_sidecar_win(session.right_overview_buf, right_win, "right", overview_width(session, "right")) else if not session.panel_enabled then left_win = vim.api.nvim_get_current_win() vim.api.nvim_win_set_buf(left_win, session.left_buf) end if not left_overview_win then - left_overview_win = open_sidecar_win(session.left_overview_buf, left_win, "left", session.overview_width) + left_overview_win = open_sidecar_win(session.left_overview_buf, left_win, "left", overview_width(session, "left")) end right_win = vim.api.nvim_open_win(session.right_buf, false, { split = "right", @@ -216,7 +224,7 @@ local function open_windows(session) left_num_win = open_gutter_win(session.left_num_buf, left_win, session.left_number_pane_width) connector_win = open_gutter_win(session.connector_buf, left_num_win, session.connector_core_width) right_num_win = open_gutter_win(session.right_num_buf, connector_win, session.right_number_pane_width) - right_overview_win = open_sidecar_win(session.right_overview_buf, right_win, "right", session.overview_width) + right_overview_win = open_sidecar_win(session.right_overview_buf, right_win, "right", overview_width(session, "right")) end session.left_win = left_win @@ -250,10 +258,10 @@ end local function apply_window_options(session) set_window_options(session.left_win, layout.win_opts.source()) - for _, win in ipairs({ session.left_overview_win, session.right_overview_win }) do + for side, win in pairs({ left = session.left_overview_win, right = session.right_overview_win }) do if win then set_window_options(win, layout.win_opts.gutter(layout.winhl.overview)) - set_window_width(win, session.overview_width) + set_window_width(win, overview_width(session, side)) end end @@ -393,13 +401,13 @@ function M.resize(session) local function apply_fixed_widths() if session.left_overview_win then - set_window_width(session.left_overview_win, session.overview_width) + set_window_width(session.left_overview_win, overview_width(session, "left")) end set_window_width(session.left_num_win, session.left_number_pane_width) set_window_width(session.connector_win, session.connector_core_width) set_window_width(session.right_num_win, session.right_number_pane_width) if session.right_overview_win then - set_window_width(session.right_overview_win, session.overview_width) + set_window_width(session.right_overview_win, overview_width(session, "right")) end end @@ -412,7 +420,8 @@ function M.resize(session) local separator_width = #valid_windows - 1 total_width = total_width + separator_width - local overview_fixed_width = (session.overview_enabled and (session.overview_width * 2) or 0) + local overview_fixed_width = session.overview_enabled + and (overview_width(session, "left") + overview_width(session, "right")) or 0 local fixed_width = session.left_number_pane_width + session.connector_core_width + session.right_number_pane_width + overview_fixed_width local content_width = total_width - fixed_width - separator_width @@ -427,7 +436,8 @@ function M.resize(session) apply_fixed_widths() if session.status_enabled then - local overview_header_extra = session.overview_enabled and (session.overview_width + 1) or 0 + local left_header_extra = session.overview_enabled and (overview_width(session, "left") + 1) or 0 + local right_header_extra = session.overview_enabled and (overview_width(session, "right") + 1) or 0 local center_fixed_width = session.left_number_pane_width + session.connector_core_width + session.right_number_pane_width local center_header_width = center_fixed_width + 2 -- Image connectors shrink the core to an IntelliJ-sized divider; the @@ -440,9 +450,9 @@ function M.resize(session) header_extra = math.max(0, math.min(need - center_header_width, left_width + right_width - 20)) end local left_extra = math.floor(header_extra / 2) - set_window_width(session.left_header_win, left_width + overview_header_extra - left_extra) + set_window_width(session.left_header_win, left_width + left_header_extra - left_extra) set_window_width(session.center_header_win, center_header_width + header_extra) - set_window_width(session.right_header_win, right_width + overview_header_extra - (header_extra - left_extra)) + set_window_width(session.right_header_win, right_width + right_header_extra - (header_extra - left_extra)) set_window_height(session.left_header_win, 1) set_window_height(session.center_header_win, 1) set_window_height(session.right_header_win, 1) diff --git a/lua/diffbandit/session/render.lua b/lua/diffbandit/session/render.lua index efcfc6c..43892bc 100644 --- a/lua/diffbandit/session/render.lua +++ b/lua/diffbandit/session/render.lua @@ -1704,8 +1704,6 @@ function M.render(session) if not viewport_only then session:render_status_headers() - -- Full render paints overviews immediately; cancel any armed CursorMoved debounce. - ui.cancel_schedule_once(session, "overview_rerender_scheduled") session:render_overviews() end diff --git a/lua/diffbandit/util/overview.lua b/lua/diffbandit/util/overview.lua index 572def0..35ef4f0 100644 --- a/lua/diffbandit/util/overview.lua +++ b/lua/diffbandit/util/overview.lua @@ -1,4 +1,7 @@ -local config_mod = require("diffbandit.config") +-- Overview strip helpers shared by every minimap (diff view gutters and +-- editor-buffer minimaps): projection, overlays and the cell renderer. +-- Option arguments (`opts`) are an overview options table — the diff view +-- passes config.ui.overview, editor minimaps pass config.minimap. local M = {} local KIND_PRIORITY = { @@ -24,27 +27,161 @@ local KIND_HIGHLIGHTS = { change = "DiffBanditOverviewChange", } -local function overview_config(config) - return config_mod.section(config, "ui", "overview") +local function overview_config(opts) + return opts or {} end -function M.enabled(config) - return overview_config(config).enabled ~= false +function M.enabled(opts) + return overview_config(opts).enabled ~= false end -function M.width(config) - return math.max(1, tonumber(overview_config(config).width) or 1) +--- Overview width in cells. The code-shape image (ui.overview.code_shape) +--- is wider; it applies only when the overview is drawn as an image. +function M.width(opts, renderer) + local cfg = overview_config(opts) + if renderer == "kitty" and cfg.code_shape == true then + return math.max(2, tonumber(cfg.code_width) or 12) + end + return math.max(1, tonumber(cfg.width) or 1) +end + +local function cursor_enabled(opts) + return overview_config(opts).cursor ~= false +end +M.cursor_enabled = cursor_enabled + +function M.viewport_enabled(opts) + return overview_config(opts).viewport ~= false +end + +function M.search_enabled(opts) + return overview_config(opts).search ~= false +end + +function M.selection_enabled(opts) + return overview_config(opts).selection ~= false end -local function cursor_enabled(config) - return overview_config(config).cursor ~= false +function M.mouse_enabled(opts) + return overview_config(opts).mouse ~= false +end + +--- Whether the code-shape image is active for this renderer. +function M.code_shape(opts, renderer) + return renderer == "kitty" and overview_config(opts).code_shape == true +end + +--- "kitty" when the overview should be drawn as an image for a host whose +--- connector renderer is `connector_renderer`, else "cells". +--- ui.overview.renderer = "auto" (follow the connector) | "cells". +function M.renderer(opts, connector_renderer) + if overview_config(opts).renderer == "cells" then + return "cells" + end + return connector_renderer == "kitty" and "kitty" or "cells" +end + +--- Minimum reduction factor: source lines per overview row. Files shorter +--- than rows x factor are not stretched to fill the strip; they take the top +--- of it at that scale. ui.overview.min_reduction = number, or nil ("auto": +--- 0 = always fill for the hunk overview; for code shape, the factor that +--- draws characters twice as tall as wide, like VS Code's minimap). +--- cell = { w, h } pixels and char_px are only used for code shape. +function M.min_reduction(opts, code_shape, cell, char_px) + local value = tonumber(overview_config(opts).min_reduction) + if value then + return math.max(0, value) + end + if code_shape and cell then + return (cell.h or cell[2] or 20) / (2 * math.max(1, char_px or 1)) + end + return 0 end -local function row_for_line(line, line_count, height) +--- Lines the full strip height represents: the line count, or more when +--- the minimum reduction keeps a short file from stretching. +function M.map_lines(line_count, height, min_reduction) line_count = math.max(1, tonumber(line_count) or 1) + return math.max(line_count, math.max(1, height or 1) * (min_reduction or 0)) +end + +-- `map_lines` is the number of lines the full strip represents (the line +-- count, unless a minimum reduction applies). +local function row_for_line(line, map_lines, height) + map_lines = math.max(1, tonumber(map_lines) or 1) height = math.max(1, tonumber(height) or 1) - line = math.max(1, math.min(line_count, tonumber(line) or 1)) - return math.max(1, math.min(height, math.floor(((line - 1) * height) / line_count) + 1)) + line = math.max(1, math.min(map_lines, tonumber(line) or 1)) + return math.max(1, math.min(height, math.floor(((line - 1) * height) / map_lines) + 1)) +end +M.row_for_line = row_for_line + +--- Source line under overview row `row` (1-based): the line at the row's +--- middle, clamped to the document. +function M.line_for_row(row, line_count, map_lines, height) + line_count = math.max(1, tonumber(line_count) or 1) + map_lines = math.max(line_count, tonumber(map_lines) or line_count) + height = math.max(1, tonumber(height) or 1) + row = math.max(1, math.min(height, row)) + local line = math.floor((row - 0.5) * map_lines / height) + 1 + return math.max(1, math.min(line_count, line)) +end + +--- Overview rows [first, last] covered by the source line range +--- range = { first, last }, or nil. +function M.viewport_rows(range, line_count, height, map_lines) + if not range or not range.first or not range.last then + return nil + end + line_count = math.max(1, tonumber(line_count) or 1) + map_lines = map_lines or line_count + local first = math.max(1, math.min(line_count, math.min(range.first, range.last))) + local last = math.max(first, math.min(line_count, math.max(range.first, range.last))) + return row_for_line(first, map_lines, height), row_for_line(last, map_lines, height) +end + +--- The active search pattern when its matches are highlighted ('hlsearch' +--- on and not suspended by :nohlsearch), else nil. +function M.active_search() + if not vim.o.hlsearch or vim.v.hlsearch ~= 1 then + return nil + end + local pattern = vim.fn.getreg("/") + if type(pattern) ~= "string" or pattern == "" then + return nil + end + return pattern +end + +--- Lines (1-based) of `lines` matching the Vim regex `pattern`, honouring +--- 'ignorecase' and 'smartcase' like / does. Invalid patterns match nothing. +function M.search_lines(lines, pattern) + local out = {} + if not pattern or pattern == "" then + return out + end + -- vim.regex ignores 'ignorecase': pick the case mode explicitly, as / + -- would, unless the pattern carries its own \c / \C. + local pat = pattern + if not pat:find("\\[cC]") then + local ignore = vim.o.ignorecase + if ignore and vim.o.smartcase then + -- Uppercase outside an escape makes the search case-sensitive. + local stripped = pat:gsub("\\.", "") + ignore = not stripped:find("%u") + end + pat = (ignore and "\\c" or "\\C") .. pat + end + local ok, re = pcall(vim.regex, pat) + if not ok or not re then + return out + end + for i, line in ipairs(lines or {}) do + local okm, s = pcall(re.match_str, re, line) + if okm and s then + out[#out + 1] = i + end + end + return out end local function mark_for_meta(meta, side) @@ -91,10 +228,10 @@ local function should_replace(existing, next_mark) return (KIND_PRIORITY[next_mark.kind] or 0) > (KIND_PRIORITY[existing.kind] or 0) end -function M.project_marks(marks, line_count, height) +function M.project_marks(marks, map_lines, height) local rows = {} for _, mark in ipairs(marks or {}) do - local row = row_for_line(mark.line, line_count, height) + local row = row_for_line(mark.line, map_lines, height) if should_replace(rows[row], mark) then rows[row] = { kind = mark.kind, @@ -111,24 +248,110 @@ local function set_lines(buf, height, width) for i = 1, math.max(1, height or 1) do lines[i] = text end + local modifiable = vim.bo[buf].modifiable + vim.bo[buf].modifiable = true vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) + vim.bo[buf].modifiable = modifiable +end + +--- Row-level overlays shared by the cell and image overviews: +--- { band = {first,last}?, selection = {first,last}?, cursor_row?, +--- search_rows = { [row] = true } }. `state` is render_side's input. +function M.row_overlays(state, opts) + local height = math.max(1, tonumber(state.height) or 1) + local map_lines = state.map_lines or M.map_lines(state.line_count, height, 0) + local out = { search_rows = {} } + if M.viewport_enabled(opts) then + local first, last = M.viewport_rows(state.viewport, state.line_count, height, map_lines) + out.band = first and { first = first, last = last } or nil + end + if M.selection_enabled(opts) then + local first, last = M.viewport_rows(state.selection, state.line_count, height, map_lines) + out.selection = first and { first = first, last = last } or nil + end + if cursor_enabled(opts) and state.cursor_line then + out.cursor_row = row_for_line(state.cursor_line, map_lines, height) + end + for _, line in ipairs(state.search_lines or {}) do + out.search_rows[row_for_line(line, map_lines, height)] = true + end + -- Most severe diagnostic per row (1 = error .. 4 = hint). + out.diagnostic_rows = {} + for _, d in ipairs(state.diagnostics or {}) do + local row = row_for_line(d.line, map_lines, height) + local cur = out.diagnostic_rows[row] + if not cur or d.severity < cur then + out.diagnostic_rows[row] = d.severity + end + end + return out end -function M.render_side_with_marks(buf, namespace, marks, line_count, height, cursor_line, current_chunk, config) +M.DIAGNOSTIC_HIGHLIGHTS = { + "DiffBanditOverviewDiagError", + "DiffBanditOverviewDiagWarn", + "DiffBanditOverviewDiagInfo", + "DiffBanditOverviewDiagHint", +} + +--- Diagnostics of `buf` as { line, severity } (1-based lines), at least as +--- severe as `min_severity` (vim.diagnostic.severity value or name). +function M.buffer_diagnostics(buf, min_severity) + local sev = min_severity + if type(sev) == "string" then + sev = vim.diagnostic.severity[sev:upper()] + end + sev = tonumber(sev) or vim.diagnostic.severity.HINT + local ok, items = pcall(vim.diagnostic.get, buf, { severity = { min = sev } }) + local out = {} + if not ok then + return out + end + for _, d in ipairs(items) do + out[#out + 1] = { line = (d.lnum or 0) + 1, severity = d.severity or 1 } + end + table.sort(out, function(a, b) + if a.line ~= b.line then + return a.line < b.line + end + return a.severity < b.severity + end) + return out +end + +local function in_range(range, row) + return range ~= nil and row >= range.first and row <= range.last +end +M.in_range = in_range + +--- Paint the cell overview. state = { marks, line_count, height, +--- map_lines?, cursor_line?, viewport = {first,last}?, +--- selection = {first,last}?, search_lines?, diagnostics? }. +--- Marks win a row's background; elsewhere the visual selection, then the +--- viewport band, show. Search matches are a right-half block over +--- whatever sits on the row; the cursor row is underlined. +function M.render_side(buf, namespace, state, opts) if not (buf and vim.api.nvim_buf_is_valid(buf)) then return end - - local width = M.width(config) - height = math.max(1, tonumber(height) or 1) + local width = M.width(opts) + local height = math.max(1, tonumber(state.height) or 1) + local map_lines = state.map_lines or M.map_lines(state.line_count, height, 0) set_lines(buf, height, width) vim.api.nvim_buf_clear_namespace(buf, namespace, 0, -1) + local overlays = M.row_overlays(vim.tbl_extend("force", state, { height = height, map_lines = map_lines }), opts) for row = 1, height do - vim.api.nvim_buf_add_highlight(buf, namespace, "DiffBanditOverviewContext", row - 1, 0, -1) + local hl = "DiffBanditOverviewContext" + if in_range(overlays.selection, row) then + hl = "DiffBanditOverviewSelection" + elseif in_range(overlays.band, row) then + hl = "DiffBanditOverviewViewport" + end + vim.api.nvim_buf_add_highlight(buf, namespace, hl, row - 1, 0, -1) end - local rows = M.project_marks(marks, line_count, height) + local rows = M.project_marks(state.marks, map_lines, height) for row, mark in pairs(rows) do local hl = KIND_HIGHLIGHTS[mark.kind] if hl then @@ -136,10 +359,30 @@ function M.render_side_with_marks(buf, namespace, marks, line_count, height, cur end end - if cursor_enabled(config) and cursor_line then - local cursor_row = row_for_line(cursor_line, line_count, height) - vim.api.nvim_buf_set_extmark(buf, namespace, cursor_row - 1, 0, { - end_row = cursor_row - 1, + -- One cell per row: a diagnostic (left-half block, severity colour) wins + -- over a search match (right-half block) on the same row. + for row in pairs(overlays.search_rows) do + if not overlays.diagnostic_rows[row] then + vim.api.nvim_buf_set_extmark(buf, namespace, row - 1, width - 1, { + virt_text = { { "▐", "DiffBanditOverviewSearch" } }, + virt_text_pos = "overlay", + hl_mode = "combine", + priority = 8000, + }) + end + end + for row, sev in pairs(overlays.diagnostic_rows) do + vim.api.nvim_buf_set_extmark(buf, namespace, row - 1, 0, { + virt_text = { { "▌", M.DIAGNOSTIC_HIGHLIGHTS[sev] or M.DIAGNOSTIC_HIGHLIGHTS[1] } }, + virt_text_pos = "overlay", + hl_mode = "combine", + priority = 8100, + }) + end + + if overlays.cursor_row then + vim.api.nvim_buf_set_extmark(buf, namespace, overlays.cursor_row - 1, 0, { + end_row = overlays.cursor_row - 1, end_col = math.max(1, width), hl_group = "DiffBanditOverviewCursor", hl_mode = "combine", diff --git a/lua/diffbandit/util/overview_syntax.lua b/lua/diffbandit/util/overview_syntax.lua new file mode 100644 index 0000000..124a482 --- /dev/null +++ b/lua/diffbandit/util/overview_syntax.lua @@ -0,0 +1,212 @@ +-- Text shape + syntax colours for the code-shape overview image. +-- +-- build() turns a pane's source lines into per-line runs of display columns +-- with a colour each: { [line] = { { c0, c1, rgb }, ... } } (c1 exclusive, +-- whitespace skipped). Colours come from treesitter highlight captures when +-- a parser is available, from legacy syntax for short files otherwise, and +-- from Normal's foreground as a last resort. line_runs() is pure. +local M = {} + +local byte = string.byte + +local function hl_rgb(name) + local ok, hl = pcall(vim.api.nvim_get_hl, 0, { name = name, link = false }) + local v = ok and hl and hl.fg or nil + if type(v) ~= "number" then + return nil + end + return { math.floor(v / 65536) % 256, math.floor(v / 256) % 256, v % 256 } +end + +--- Display-column runs of one line. `colors` maps 1-based byte index to an +--- rgb table (nil = default). Tabs expand to `tabstop`; a UTF-8 sequence +--- takes one column. Columns past `max_cols` are dropped. +function M.line_runs(text, colors, default, tabstop, max_cols) + local runs = {} + local col = 0 + local cur + tabstop = math.max(1, tabstop or 8) + local n = #text + local i = 1 + while i <= n and col < max_cols do + local b = byte(text, i) + local len = 1 + if b >= 0xF0 then + len = 4 + elseif b >= 0xE0 then + len = 3 + elseif b >= 0xC0 then + len = 2 + end + if b == 9 then + col = (math.floor(col / tabstop) + 1) * tabstop + cur = nil + elseif b == 32 then + col = col + 1 + cur = nil + else + local rgb = (colors and colors[i]) or default + if cur and cur[3] == rgb and cur[2] == col then + cur[2] = col + 1 + else + cur = { col, col + 1, rgb } + runs[#runs + 1] = cur + end + col = col + 1 + end + i = i + len + end + return runs +end + +-- Capture name -> rgb (or false), most specific group first. +local function capture_color(cache, name, lang) + local key = name .. "." .. lang + local v = cache[key] + if v ~= nil then + return v or nil + end + local rgb = hl_rgb("@" .. key) + local parts = name + while not rgb and parts do + rgb = hl_rgb("@" .. parts) + parts = parts:match("^(.*)%.[^.]*$") + end + cache[key] = rgb or false + return rgb +end + +-- Treesitter: { [line] = { [byte] = rgb } } or nil when unavailable. +local function treesitter_colors(buf, lines, encode) + local line_count = #lines + if not (buf and vim.api.nvim_buf_is_valid(buf)) then + return nil + end + local ft = vim.bo[buf].filetype + if not ft or ft == "" then + return nil + end + local lang = vim.treesitter.language.get_lang(ft) or ft + local ok, parser = pcall(vim.treesitter.get_parser, buf, lang, { error = false }) + if not ok or not parser then + return nil + end + local okq, query = pcall(vim.treesitter.query.get, lang, "highlights") + if not okq or not query then + return nil + end + local okp, trees = pcall(function() + return parser:parse() + end) + if not okp or not trees or not trees[1] then + return nil + end + local root = trees[1]:root() + local out, cache = {}, {} + local encoded = {} + for id, node in query:iter_captures(root, buf, 0, line_count) do + local name = query.captures[id] + if name and name:sub(1, 1) ~= "_" and name ~= "spell" and name ~= "nospell" and name ~= "conceal" then + local rgb = capture_color(cache, name, lang) + if rgb then + local enc = encoded[rgb] + if not enc then + enc = encode(rgb) + encoded[rgb] = enc + end + local sr, sc, er, ec = node:range() + for row = sr, math.min(er, line_count - 1) do + local line = out[row + 1] + if not line then + line = {} + out[row + 1] = line + end + local from = row == sr and sc or 0 + local to = math.min(row == er and ec or math.huge, #(lines[row + 1] or "")) + for b = from + 1, to do + line[b] = enc + end + end + end + end + end + return out +end + +-- Legacy syntax: colour each run of non-blank bytes by the syntax group at +-- its first byte. Only for short files (synID is per call). +local function syntax_colors(buf, lines, encode, max_lines) + if #lines > max_lines then + return nil + end + local ok, has = pcall(function() + return vim.b[buf].current_syntax ~= nil + end) + if not ok or not has then + return nil + end + local out = {} + local by_id = {} + pcall(vim.api.nvim_buf_call, buf, function() + for l, text in ipairs(lines) do + local line + local s = 1 + while true do + local a, b = text:find("%S+", s) + if not a then + break + end + local id = vim.fn.synIDtrans(vim.fn.synID(l, a, 1)) + if id ~= 0 then + local rgb = by_id[id] + if rgb == nil then + local hex = vim.fn.synIDattr(id, "fg#") + rgb = false + if type(hex) == "string" and hex:match("^#%x%x%x%x%x%x$") then + rgb = encode({ tonumber(hex:sub(2, 3), 16), tonumber(hex:sub(4, 5), 16), tonumber(hex:sub(6, 7), 16) }) + end + by_id[id] = rgb + end + if rgb then + line = line or {} + for i = a, b do + line[i] = rgb + end + end + end + s = b + 1 + end + out[l] = line + end + end) + return out +end + +--- opts = { buf?, max_cols, tabstop?, encode? (rgb -> rgb, colour space), +--- syntax_max_lines? }. Returns { runs = { [line] = runs }, source = +--- "treesitter" | "syntax" | "plain" }. +function M.build(lines, opts) + opts = opts or {} + local encode = opts.encode or function(rgb) + return rgb + end + local default = encode(hl_rgb("Normal") or { 200, 200, 200 }) + local colors = treesitter_colors(opts.buf, lines, encode) + local source = "treesitter" + if not colors and opts.buf then + colors = syntax_colors(opts.buf, lines, encode, opts.syntax_max_lines or 3000) + source = "syntax" + end + if not colors then + colors = {} + source = "plain" + end + local runs = {} + local max_cols = math.max(1, opts.max_cols or 120) + for l, text in ipairs(lines) do + runs[l] = M.line_runs(text, colors[l], default, opts.tabstop, max_cols) + end + return { runs = runs, source = source } +end + +return M diff --git a/plugin/diffbandit.lua b/plugin/diffbandit.lua index 215c7f2..983d3bd 100644 --- a/plugin/diffbandit.lua +++ b/plugin/diffbandit.lua @@ -107,6 +107,21 @@ end, { desc = "Open DiffBandit diff view for two files", }) +vim.api.nvim_create_user_command("DiffBanditMinimap", function(opts) + local action = opts.fargs[1] or "toggle" + if action ~= "toggle" and action ~= "open" and action ~= "close" then + nvim.notify_error("usage: DiffBanditMinimap [toggle|open|close]") + return + end + diffbandit.minimap(action) +end, { + nargs = "?", + complete = function() + return { "toggle", "open", "close" } + end, + desc = "Toggle the DiffBandit minimap for the current window", +}) + vim.api.nvim_create_user_command("DiffBanditBuffers", function(opts) local args = opts.fargs if #args < 2 then diff --git a/tests/run.lua b/tests/run.lua index b3b6c2c..14d4d0d 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -44,6 +44,7 @@ local specs = { "35_render_recovery.lua", "40_oracle.lua", "45_kitty.lua", + "55_minimap.lua", } local map_entries = {} diff --git a/tests/specs/45_kitty.lua b/tests/specs/45_kitty.lua index abb265d..8bb676a 100644 --- a/tests/specs/45_kitty.lua +++ b/tests/specs/45_kitty.lua @@ -499,8 +499,12 @@ do cfg.ui.connector_renderer = "kitty" cfg.ui.kitty.cell_px = { 10, 20 } cfg.ui.perf = { enabled = true, log = false, budget_warn_ms = 1e9 } + -- Layout/connector specs use the one-column hunk strip; the code-shape + -- overview (the default) has its own specs. + cfg.ui.overview.code_shape = false return cfg end + assert_eq(config.ui.overview.code_shape, true, "diff-view overviews draw the code shape by default") local function fixture_lines() local left, right = {}, {} @@ -561,10 +565,15 @@ do assert_eq(session.connector_renderer, "kitty", "forced kitty mode") assert_eq(st ~= nil, true, "session owns a canvas state") assert_eq(count_matching(",i=" .. st.id .. ","), st.transmits, "every transmit targets our image id") - assert_eq(count_matching("\27_Ga=T,U=1,f=32,"), st.transmits, "every transmit is a virtual (placeholder) placement") + -- The overview strips are images too (one per side). + local all_transmits = st.transmits + for _, mm in pairs(session.minimaps or {}) do + all_transmits = all_transmits + ((mm:image_state() or {}).transmits or 0) + end + assert_eq(count_matching("\27_Ga=T,U=1,f=32,"), all_transmits, "every transmit is a virtual (placeholder) placement") assert_eq(st.transmits >= 1, true, "image transmitted") if require("diffbandit.kitty.zlib").available() then - assert_eq(count_matching(",o=z,"), st.transmits, "direct transmissions are zlib-compressed") + assert_eq(count_matching(",o=z,"), all_transmits, "direct transmissions are zlib-compressed") end local hl = vim.api.nvim_get_hl(0, { name = st.hl, link = false }) assert_eq(hl.fg, st.id, "placeholder highlight fg encodes the image id") @@ -846,6 +855,475 @@ do reset_detect() end + -- ------------------------------------------------------------------ + -- image overview: pixel-resolution overview strips + -- ------------------------------------------------------------------ + do + local ov_raster = require("diffbandit.kitty.overview_raster") + local spans = ov_raster.spans({ + { line = 10, kind = "change" }, { line = 11, kind = "change" }, { line = 12, kind = "change" }, + { line = 14, kind = "delete" }, + { line = 15, kind = "delete", current = true }, { line = 16, kind = "delete", current = true }, + }) + assert_eq(#spans, 3, "contiguous same-kind lines merge; the current flag splits spans") + assert_eq(spans[1].first == 10 and spans[1].last == 12 and spans[1].kind == "change", true, "change span 10-12") + assert_eq(spans[2].first == 14 and spans[2].last == 14 and not spans[2].current, true, "delete line 14") + assert_eq(spans[3].first == 15 and spans[3].last == 16 and spans[3].current, true, "current span 15-16") + + local RED, GREEN, BLUE, WHITE = { 200, 0, 0 }, { 0, 200, 0 }, { 0, 0, 200 }, { 255, 255, 255 } + local pal = { delete = { fill = RED }, add = { fill = GREEN }, change = { fill = BLUE }, outline = WHITE } + local spec = { line_count = 1000, cols = 1, rows = 50, cell_w = 10, cell_h = 20, palette = pal } + local function px(bytes, w, x, y) + local i = (y * w + x) * 4 + 1 + return { bytes:byte(i, i + 3) } + end + local function opaque_rows(bytes, w, h, x, rgb) + local n = 0 + for y = 0, h - 1 do + local p = px(bytes, w, x, y) + if p[1] == rgb[1] and p[2] == rgb[2] and p[3] == rgb[3] and p[4] == 255 then + n = n + 1 + end + end + return n + end + + -- Two one-line hunks 10 lines apart share one cell row (1000 lines over + -- 50 rows = 20 lines per row) but stay two separate marks in pixels. + local bytes, w, h = ov_raster.render(ov_raster.spans({ + { line = 101, kind = "add" }, { line = 111, kind = "add" }, + }), spec) + assert_eq(w == 10 and h == 1000 and #bytes == w * h * 4, true, "1x50 cells at 10x20 px -> 10x1000 RGBA") + local m = ov_raster.metrics(spec) + assert_eq(m.inset, 1, "1px transparent inset at 10px cells") + assert_eq(m.min_px, 2, "one-line hunks get at least 2px") + assert_eq(opaque_rows(bytes, w, h, 5, GREEN), 4, "both one-line hunks drawn at their minimum height") + assert_eq(px(bytes, w, 5, 100)[4], 255, "hunk at line 101 sits at y=100") + assert_eq(px(bytes, w, 5, 105)[4], 0, "transparent gap between hunks in the same cell row") + assert_eq(px(bytes, w, 5, 110)[4], 255, "hunk at line 111 sits at y=110") + assert_eq(px(bytes, w, 0, 100)[4], 0, "inset column stays transparent for other chunks") + + -- Current chunk: outlined in the inset columns and one px above/below. + bytes = ov_raster.render(ov_raster.spans({ + { line = 501, kind = "change", current = true }, { line = 502, kind = "change", current = true }, + { line = 503, kind = "change", current = true }, + }), spec) + local edge = px(bytes, w, 0, 501) + assert_eq(edge[1] == 255 and edge[4] == 255, true, "current chunk outline in the left inset column") + edge = px(bytes, w, 9, 501) + assert_eq(edge[1] == 255 and edge[4] == 255, true, "current chunk outline in the right inset column") + local above = px(bytes, w, 5, 499) + assert_eq(above[1] == 255 and above[4] == 255, true, "outline one px above the chunk") + local body = px(bytes, w, 5, 501) + assert_eq(body[3] == 200 and body[1] == 0, true, "chunk body keeps its kind colour") + + -- Session integration: one image per overview strip. + reset_detect() + local session = start_session(kitty_config()) + session:render() + local lst, rst = session.minimaps.left:image_state(), session.minimaps.right:image_state() + assert_eq(lst ~= nil and rst ~= nil, true, "both overview strips own an image") + assert_ne(lst.id, rst.id, "overview strips use distinct image ids") + assert_ne(lst.id, session.kitty_state.id, "overview ids differ from the connector's") + assert_eq(lst.transmits >= 1 and rst.transmits >= 1, true, "overview images transmitted") + local height = vim.api.nvim_win_get_height(session.left_win) + local lines = vim.api.nvim_buf_get_lines(session.left_overview_buf, 0, -1, false) + assert_eq(#lines, height, "one placeholder line per overview row") + assert_eq(lines[1], protocol.placeholder_run(0, 0, 1), "overview row 0 addresses image row 0") + assert_eq(lines[height], protocol.placeholder_run(height - 1, 0, 1), "last overview row addresses the last image row") + assert_eq(#lst.last_spans, 2, "left strip: the delete and the change on the left") + + local function row_groups(buf) + local rows = {} + for _, mk in ipairs(vim.api.nvim_buf_get_extmarks(buf, session.minimaps.left.ns, 0, -1, { details = true })) do + rows[mk[2] + 1] = mk[4].hl_group + end + return rows + end + local groups = row_groups(session.left_overview_buf) + for r = 1, height do + assert_eq(type(groups[r]) == "string" and groups[r]:find(lst.hl, 1, true) == 1, true, + "every overview row carries an image placeholder group (row " .. r .. ")") + end + assert_eq(vim.api.nvim_get_hl(0, { name = lst.hl .. "Viewport", link = false }).fg, lst.id, + "viewport band keeps the image id as foreground") + local ov = require("diffbandit.util.overview") + local left_count = #session.view.left + local function expected_band() + return ov.viewport_rows({ + first = vim.fn.line("w0", session.left_win), last = vim.fn.line("w$", session.left_win), + }, left_count, height) + end + local function check_band(label) + local first, last = expected_band() + local cursor_row = ov.row_for_line(vim.api.nvim_win_get_cursor(session.left_win)[1], left_count, height) + for r = 1, height do + local want = lst.hl + if r == cursor_row then + want = lst.hl .. "Cursor" + elseif r >= first and r <= last then + want = lst.hl .. "Viewport" + end + assert_eq(groups[r], want, label .. " (row " .. r .. ")") + end + return first, last + end + local first, last = check_band("band + cursor rows") + assert_eq(groups[1], lst.hl .. "Cursor", "cursor row (line 1) uses the cursor background") + assert_eq(first == 1 and last < height, true, "band covers the top rows only") + + -- Scrolling moves the band without re-sending any image. + local before_l, before_r = lst.transmits, rst.transmits + vim.api.nvim_win_set_cursor(session.left_win, { 250, 0 }) + session:render_overviews() + groups = row_groups(session.left_overview_buf) + first = check_band("band follows the scroll") + assert_eq(first > 1 and groups[1] == lst.hl, true, "band left the top after scrolling down") + assert_eq(lst.transmits == before_l and rst.transmits == before_r, true, "scrolling never re-sends overview images") + + -- A theme change re-sends both strips. + require("diffbandit.kitty").invalidate(session.minimaps.left) + require("diffbandit.kitty").invalidate(session.minimaps.right) + session:render_overviews() + assert_eq(lst.transmits == before_l + 1 and rst.transmits == before_r + 1, true, "invalidate re-sends overview images") + + local lid, rid = lst.id, rst.id + session:close() + assert_eq(count_matching(protocol.delete(lid)) == 1 and count_matching(protocol.delete(rid)) == 1, true, + "closing the session deletes both overview images") + reset_detect() + + -- ui.overview.renderer = "cells" keeps the cell overview under kitty. + local cfg = kitty_config() + cfg.ui.overview.renderer = "cells" + session = start_session(cfg) + session:render() + assert_eq(session.connector_renderer, "kitty", "connector still uses the image renderer") + assert_eq(session.minimaps.left:image_state() == nil and session.minimaps.right:image_state() == nil, true, + "no overview images with renderer = cells") + assert_eq(vim.api.nvim_buf_get_lines(session.left_overview_buf, 0, 1, false)[1], " ", "cell overview text") + session:close() + reset_detect() + end + + -- ------------------------------------------------------------------ + -- overview wishlist: scale, search, selection, code shape, mouse + -- ------------------------------------------------------------------ + do + local ov = require("diffbandit.util.overview") + local ov_raster = require("diffbandit.kitty.overview_raster") + local ov_syntax = require("diffbandit.util.overview_syntax") + local minimap_mouse = require("diffbandit.minimap.mouse") + + -- Minimum reduction: short files stop stretching. + assert_eq(ov.map_lines(100, 50, 0), 100, "no minimum reduction: the file fills the strip") + assert_eq(ov.map_lines(100, 50, 4), 200, "min_reduction 4 over 50 rows represents 200 lines") + assert_eq(ov.map_lines(1000, 50, 4), 1000, "long files are unaffected") + assert_eq(ov.row_for_line(100, 200, 50), 25, "last line of a short file lands mid-strip") + assert_eq(ov.min_reduction({}, false), 0, "auto: hunk overview always fills") + assert_eq(ov.min_reduction({}, true, { w = 10, h = 20 }, 1), 10, + "auto: code shape draws characters twice as tall as wide") + assert_eq(ov.min_reduction({ min_reduction = 3 }, true, { w = 10, h = 20 }, 1), 3, + "explicit min_reduction wins") + -- Click mapping: overview row -> source line. + assert_eq(ov.line_for_row(1, 300, 300, 19), 8, "row 1 maps to the line at its middle") + assert_eq(ov.line_for_row(3, 300, 300, 19), 40, "row 3") + assert_eq(ov.line_for_row(50, 100, 200, 50), 100, "rows past a short file clamp to its last line") + for _, line in ipairs({ 1, 37, 150, 300 }) do + local row = ov.row_for_line(line, 300, 19) + assert_eq(ov.row_for_line(ov.line_for_row(row, 300, 300, 19), 300, 19), row, "click row round-trips " .. line) + end + + -- Search matches honour ignorecase/smartcase. + local saved_ic, saved_scs = vim.o.ignorecase, vim.o.smartcase + vim.o.ignorecase, vim.o.smartcase = true, true + local hits = ov.search_lines({ "Foo", "foo", "bar" }, "foo") + assert_eq(#hits == 2 and hits[1] == 1 and hits[2] == 2, true, "lowercase pattern ignores case") + hits = ov.search_lines({ "Foo", "foo", "bar" }, "Foo") + assert_eq(#hits == 1 and hits[1] == 1, true, "smartcase: uppercase makes the search case-sensitive") + assert_eq(#ov.search_lines({ "a(" }, "\\("), 0, "invalid patterns match nothing") + vim.o.ignorecase, vim.o.smartcase = saved_ic, saved_scs + + -- Code shape text runs. + local D, K = { 1, 1, 1 }, { 9, 9, 9 } + local runs = ov_syntax.line_runs("\tab c", nil, D, 4, 100) + assert_eq(#runs == 2 and runs[1][1] == 4 and runs[1][2] == 6 and runs[2][1] == 8 and runs[2][2] == 9, true, + "tabs expand, blanks split runs: " .. vim.inspect(runs)) + runs = ov_syntax.line_runs("ab", { [1] = K }, D, 4, 100) + assert_eq(#runs == 2 and runs[1][3] == K and runs[2][3] == D, true, "a colour change splits a run") + runs = ov_syntax.line_runs("é x", nil, D, 4, 100) + assert_eq(runs[2][1], 2, "a UTF-8 sequence takes one column") + runs = ov_syntax.line_runs("abcdef", nil, D, 4, 3) + assert_eq(#runs == 1 and runs[1][2] == 3, true, "columns past the strip are dropped") + do + local buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, { "local x = 'str'" }) + vim.bo[buf].filetype = "lua" + local has_parser = pcall(vim.treesitter.get_parser, buf, "lua") + local built = ov_syntax.build({ "local x = 'str'" }, { buf = buf, max_cols = 50 }) + if has_parser then + assert_eq(built.source, "treesitter", "lua buffers colour the code shape from treesitter") + local first, last = built.runs[1][1], built.runs[1][#built.runs[1]] + assert_eq(first[3] ~= nil and last[3] ~= nil, true, "runs carry colours") + end + assert_eq(ov_syntax.build({ "x y" }, { max_cols = 50 }).source, "plain", "no buffer: plain Normal colour") + vim.api.nvim_buf_delete(buf, { force = true }) + end + + -- Raster: search ticks, code text, map_lines. + local RED, GREEN, YEL = { 200, 0, 0 }, { 0, 200, 0 }, { 250, 250, 0 } + local pal = { add = { fill = GREEN }, delete = { fill = RED }, change = { fill = RED }, search = YEL } + local function px(bytes, w, x, y) + local i = (y * w + x) * 4 + 1 + return { bytes:byte(i, i + 3) } + end + local bytes, w = ov_raster.render({}, { + line_count = 1000, cols = 1, rows = 50, cell_w = 10, cell_h = 20, palette = pal, search_lines = { 501 }, + }) + local tick = px(bytes, w, 8, 500) + assert_eq(tick[1] == 250 and tick[4] == 255, true, "search match: tick on the right half") + assert_eq(px(bytes, w, 2, 500)[4], 0, "search tick leaves the left half clear") + + bytes, w = ov_raster.render({}, { + line_count = 50, map_lines = 50, cols = 4, rows = 5, cell_w = 10, cell_h = 20, palette = pal, + code = { runs = { [1] = { { 0, 3, RED } }, [2] = { { 2, 4, GREEN } } } }, + }) + local p = px(bytes, w, 1, 0) + assert_eq(p[1] == 200 and p[4] == 230, true, "code: line 1 text at x=inset: " .. vim.inspect(p)) + assert_eq(px(bytes, w, 4, 0)[4], 0, "code: past the run is clear") + assert_eq(px(bytes, w, 1, 1)[4], 0, "code: gap row under 2px lines") + p = px(bytes, w, 3, 2) + assert_eq(p[2] == 200 and p[4] == 230, true, "code: line 2 text starts at its column") + + bytes, w = ov_raster.render({}, { + line_count = 50, cols = 4, rows = 5, cell_w = 10, cell_h = 20, palette = pal, search_lines = { 10 }, + code = { runs = {} }, + }) + p = px(bytes, w, 20, 18) + assert_eq(p[1] == 250 and p[4] == 153, true, "code: search match is a translucent band: " .. vim.inspect(p)) + + bytes, w = ov_raster.render(ov_raster.spans({ { line = 50, kind = "add" } }), { + line_count = 50, map_lines = 100, cols = 1, rows = 5, cell_w = 10, cell_h = 20, palette = pal, + }) + assert_eq(px(bytes, w, 5, 49)[4] == 255 and px(bytes, w, 5, 60)[4] == 0, true, + "map_lines 100: the last of 50 lines sits mid-strip") + + -- Session: code shape widens the overview image. + reset_detect() + local cfg = kitty_config() + cfg.ui.overview.code_shape = true + cfg.ui.overview.focus_follow = false -- both full width (focus-follow below) + local session = start_session(cfg) + session:render() + assert_eq(session.overview_width, 12, "code shape: 12-cell overview") + assert_eq(vim.api.nvim_win_get_width(session.left_overview_win), 12, "overview window resized") + local lmm, rmm = session.minimaps.left, session.minimaps.right + local lst = lmm:image_state() + local line1 = vim.api.nvim_buf_get_lines(session.left_overview_buf, 0, 1, false)[1] + assert_eq(line1, protocol.placeholder_run(0, 0, 12), "placeholders span the code-shape width") + assert_eq(lmm.code_cache ~= nil and #lmm.code_cache.runs, #session.view.left, "code runs built for every left line") + local st = lmm:state() + assert_eq(st.map_lines, math.max(#session.view.left, st.height * 10), "code shape: auto min reduction") + + -- Edits keep the previous code shape, then rebuild once typing pauses. + do + local rst0 = rmm:image_state() + local cached = rmm.code_cache + local sent_before = rst0.transmits + session.config.ui.overview.code_debounce_ms = 30 + vim.bo[session.right_buf].modifiable = true + vim.api.nvim_buf_set_lines(session.right_buf, 0, 1, false, { "edited" }) + session:render_overviews() + assert_eq(rmm.code_cache, cached, "an edit keeps the previous code shape at first") + assert_eq(rst0.transmits, sent_before, "no re-send while typing") + assert_eq(wait_for(function() + return rmm.code_cache ~= cached and rst0.transmits == sent_before + 1 + end, 1000), true, "code shape rebuilt and re-sent after the pause") + end + + -- Search matches re-send the image once; cursor moves don't. + local saved_hls = vim.o.hlsearch + vim.o.hlsearch = true + vim.fn.setreg("/", "changed") + vim.v.hlsearch = 1 + local rst = rmm:image_state() + local before = rst.transmits + session:render_overviews() + st = rmm:state() + assert_eq(#st.search_lines == 2 and st.search_lines[1] == 5, true, "right search matches: " .. vim.inspect(st.search_lines)) + assert_eq(rst.transmits, before + 1, "new search matches re-send the image") + session:render_overviews() + assert_eq(rst.transmits, before + 1, "unchanged matches do not re-send") + vim.v.hlsearch = 0 + session:render_overviews() + assert_eq(#rmm:state().search_lines, 0, ":nohlsearch hides matches") + vim.o.hlsearch = saved_hls + vim.fn.setreg("/", "") + + -- Visual selection rows use the selection background. + local height = vim.api.nvim_win_get_height(session.left_win) + require("diffbandit.kitty.overview").paint(lmm, "map", { + buf = session.left_overview_buf, ns = lmm.ns, marks = {}, line_count = 300, + rows = height, cols = 12, overlays = { selection = { first = 3, last = 5 }, band = { first = 1, last = 8 } }, + }) + local groups = {} + for _, mk in ipairs(vim.api.nvim_buf_get_extmarks(session.left_overview_buf, lmm.ns, 0, -1, { details = true })) do + groups[mk[2] + 1] = mk[4].hl_group + end + assert_eq(groups[4], lst.hl .. "Selection", "selection rows show the selection background") + assert_eq(groups[7], lst.hl .. "Viewport", "viewport band outside the selection") + assert_eq(vim.api.nvim_get_hl(0, { name = lst.hl .. "Selection", link = false }).fg, lst.id, + "selection group keeps the image id") + local ovl = ov.row_overlays({ line_count = 300, height = 30, selection = { first = 100, last = 40 } }, cfg.ui.overview) + assert_eq(ovl.selection.first == 4 and ovl.selection.last == 10, true, "selection rows normalise a reversed range") + + -- Mouse, through window events only (no maps on the source buffers). + local ovwin = session.left_overview_win + assert_eq(minimap_mouse.minimap_for(ovwin), lmm, "overview window routed to its minimap") + minimap_mouse.on_enter(ovwin, true, 3) + local want = ov.line_for_row(3, #session.view.left, st.map_lines, st.height) + assert_eq(vim.api.nvim_win_get_cursor(session.left_win)[1], want, "click moves the pane to the row's line") + assert_eq(lmm.dragging, true, "a click starts a drag") + vim.api.nvim_set_current_win(ovwin) -- keyboard entry (no click) + assert_eq(wait_for(function() + return vim.api.nvim_get_current_win() == session.left_win + end), true, "entering the overview by keyboard bounces to its pane") + local w0 = vim.fn.line("w0", session.left_win) + vim.api.nvim_win_call(ovwin, function() + vim.cmd("normal! 3\5") -- what a wheel over the split does to it + end) + assert_eq(vim.fn.line("w0", ovwin), 4, "the strip itself scrolled") + minimap_mouse.on_scrolled(ovwin) + assert_eq(vim.fn.line("w0", ovwin), 1, "the strip snaps back to its top") + minimap_mouse.on_wheel(ovwin, 1) + assert_eq(vim.fn.line("w0", session.left_win), w0 + 3, "a wheel step over the strip scrolls the pane") + local function has_map(buf, lhs) + for _, m in ipairs(vim.api.nvim_buf_get_keymap(buf, "n")) do + if m.lhs == lhs then + return true + end + end + return false + end + assert_eq(has_map(session.left_overview_buf, ""), true, "drag map lives on the strip's own buffer") + assert_eq(has_map(session.left_buf, ""), false, "no mouse maps on the source buffers") + session:close() + assert_eq(minimap_mouse.minimap_for(ovwin), nil, "closing the session unregisters its strips") + reset_detect() + + -- Squeezed code shape: runs built for more columns than fit become + -- fractional pixels with partial coverage. + do + local RED2 = { 200, 0, 0 } + local spec = { line_count = 10, cols = 1, rows = 1, cell_w = 10, cell_h = 20, palette = {}, + code = { columns = 24, runs = { [1] = { { 0, 12, RED2 } }, [2] = { { 0, 1, RED2 } } } } } + local m = ov_raster.metrics(spec) + assert_eq(m.char_px, (10 - 2 * m.inset) / 24, "24 columns squeezed into the strip's inner width") + local sq, sw = ov_raster.render({}, spec) + local function a(x, y) + return sq:byte((y * sw + x) * 4 + 4) + end + assert_eq(a(m.inset, 0) > 200 and a(m.inset + 3, 0) > 200 and a(m.inset + 5, 0) == 0, true, + "a 12-column run fills half the squeezed width") + assert_eq(a(m.inset, 2) > 0 and a(m.inset, 2) < 100, true, "a one-column run is a partially covered pixel") + end + + -- Focus-follow: the focused pane's strip is full width, the other + -- compact; focus moving swaps them without rebuilding the code shape. + do + reset_detect() + local fcfg = kitty_config() + fcfg.ui.overview.code_shape = true + local fsession = start_session(fcfg) + fsession:render() + assert_eq(fsession.overview_expanded_side, "right", "the editable right side starts expanded") + assert_eq(vim.api.nvim_win_get_width(fsession.right_overview_win), 12, "right strip full width") + assert_eq(vim.api.nvim_win_get_width(fsession.left_overview_win), 4, "left strip compact") + local fl, fr = fsession.minimaps.left, fsession.minimaps.right + assert_eq(vim.api.nvim_buf_get_lines(fsession.left_overview_buf, 0, 1, false)[1], + protocol.placeholder_run(0, 0, 4), "compact strip placeholders") + local left_pane = vim.api.nvim_win_get_width(fsession.left_win) + local right_pane = vim.api.nvim_win_get_width(fsession.right_win) + local code_before = fl.code_cache + local sent_before = fl:image_state().transmits + vim.api.nvim_set_current_win(fsession.left_win) -- WinEnter + assert_eq(fsession.overview_expanded_side, "left", "focus on the left pane expands its strip") + assert_eq(vim.api.nvim_win_get_width(fsession.left_overview_win), 12, "left strip now full width") + assert_eq(vim.api.nvim_win_get_width(fsession.right_overview_win), 4, "right strip now compact") + assert_eq(vim.api.nvim_win_get_width(fsession.left_win), left_pane, "left pane keeps its width") + assert_eq(vim.api.nvim_win_get_width(fsession.right_win), right_pane, "right pane keeps its width") + assert_eq(fl.code_cache, code_before, "swapping sizes reuses the code shape") + assert_eq(fl:image_state().transmits, sent_before + 1, "the expanded strip re-rasterizes once") + assert_eq(fr.code_cache.columns, fl.code_cache.columns, "both strips share the full-width columns") + vim.api.nvim_set_current_win(fsession.left_win) + assert_eq(fl:image_state().transmits, sent_before + 1, "re-entering the same pane changes nothing") + vim.api.nvim_set_current_win(fsession.right_win) + assert_eq(vim.api.nvim_win_get_width(fsession.right_overview_win), 12, "back to the right") + + -- Keyboard entry into a strip continues the window cycle (it used to + -- bounce back to the strip's own pane, trapping w). + vim.api.nvim_set_current_win(fsession.right_overview_win) + assert_eq(vim.wait(500, function() + return vim.api.nvim_get_current_win() == fsession.left_win + end, 10), true, "w from the right pane through its strip reaches the left pane") + vim.api.nvim_set_current_win(fsession.right_win) + + -- A re-diff of the same sources (typing in the editable right) keeps + -- the code shape until the debounce rebuilds it. + local shape = fr.code_cache + assert_eq(shape ~= nil, true, "right strip has a code shape") + fsession:refresh_editable_right() + fr:render() + assert_eq(fr.code_cache, shape, "same-source re-diff keeps the code shape") + + -- A click that changes panes resizes them only after the release. + local mouse_mod = require("diffbandit.minimap.mouse") + local saved_mouse = vim.o.mouse + vim.o.mouse = "" + local ran = false + vim.api.nvim_feedkeys(vim.keycode(""), "xt", false) + mouse_mod.when_released(function() + ran = true + end) + assert_eq(ran, false, "layout change waits while the button is down") + vim.api.nvim_feedkeys(vim.keycode(""), "xt", false) + assert_eq(vim.wait(200, function() + return ran + end, 5), true, "layout change runs after the release") + vim.o.mouse = saved_mouse + fsession:close() + reset_detect() + end + + -- Cells: search block and selection band. + local ccfg = vim.deepcopy(config) + session = start_session(ccfg) + session:render() + local buf = session.left_overview_buf + local cns = session.minimaps.left.ns + ov.render_side(buf, cns, { + marks = {}, line_count = 300, height = 20, search_lines = { 160 }, selection = { first = 30, last = 45 }, + diagnostics = { { line = 10, severity = 2 }, { line = 12, severity = 1 } }, + }, ccfg.ui.overview) + local search_row, sel_rows, diag = nil, 0, nil + for _, mk in ipairs(vim.api.nvim_buf_get_extmarks(buf, cns, 0, -1, { details = true })) do + local d = mk[4] + if d.virt_text and d.virt_text[1][1] == "▐" then + search_row = mk[2] + 1 + elseif d.virt_text and d.virt_text[1][1] == "▌" then + diag = { row = mk[2] + 1, hl = d.virt_text[1][2] } + elseif d.hl_group == "DiffBanditOverviewSelection" then + sel_rows = sel_rows + 1 + end + end + assert_eq(search_row, ov.row_for_line(160, 300, 20), "cells: search match as a right-half block") + assert_eq(sel_rows, 2, "cells: selection rows") + assert_eq(diag.row == 1 and diag.hl == "DiffBanditOverviewDiagError", true, + "cells: the most severe diagnostic of a row as a left-half block") + session:close() + reset_detect() + end + do -- Cells fallback: default auto in a headless (no TUI) Neovim is today's -- renderer, untouched — glyphs present, nothing written to a terminal. @@ -868,6 +1346,17 @@ do end assert_eq(route_marks > 0, true, "cells mode still paints its route cells") assert_eq(placeholders, 0, "cells mode paints no image placeholders") + -- The cell overview shades the visible range (viewport band). + local band_rows, last_band = 0, 0 + for _, mk in ipairs(vim.api.nvim_buf_get_extmarks(session.left_overview_buf, session.minimaps.left.ns, 0, -1, + { details = true })) do + if mk[4].hl_group == "DiffBanditOverviewViewport" then + band_rows = band_rows + 1 + last_band = math.max(last_band, mk[2] + 1) + end + end + local ov_height = vim.api.nvim_win_get_height(session.left_win) + assert_eq(band_rows > 0 and last_band < ov_height, true, "cell overview draws a viewport band over the visible rows") session:close() reset_detect() end diff --git a/tests/specs/55_minimap.lua b/tests/specs/55_minimap.lua new file mode 100644 index 0000000..b0d2eb6 --- /dev/null +++ b/tests/specs/55_minimap.lua @@ -0,0 +1,213 @@ +-- Editor minimap: the shared minimap component on ordinary editor windows +-- (float placement, git-hunk and diagnostics providers, window-event mouse, +-- buffer switches, DiffBandit-tab exclusion). Wrapped in one block so its +-- locals stay out of the shared runner chunk's top-level scope. +do + local minimap_editor = require("diffbandit.minimap.editor") + local minimap_mouse = require("diffbandit.minimap.mouse") + local providers = require("diffbandit.minimap.providers") + local placement = require("diffbandit.minimap.placement") + local mm_state = require("diffbandit.state") + + local function wait(pred, ms) + return vim.wait(ms or 2000, pred, 10) + end + + local function git(args, cwd) + local result = vim.system(vim.list_extend({ "git" }, args), { cwd = cwd, text = true }):wait() + if result.code ~= 0 then + error("git " .. table.concat(args, " ") .. ": " .. tostring(result.stderr)) + end + end + + -- Pure: hunks of a buffer against its index text. + local h = providers.line_hunks({ "a", "b", "c" }, { "a", "B", "c", "d" }) + assert_eq(#h, 2, "change + add: " .. vim.inspect(h)) + assert_eq(h[1].kind == "change" and h[1].start == 2 and h[1].count == 1, true, "line 2 changed") + assert_eq(h[2].kind == "add" and h[2].start == 4 and h[2].count == 1, true, "line 4 added") + h = providers.line_hunks({ "a", "b", "c" }, { "a", "c" }) + assert_eq(#h == 1 and h[1].kind == "delete" and h[1].start == 1 and h[1].count == 0, true, + "a deletion marks the line it follows: " .. vim.inspect(h)) + h = providers.line_hunks({ "a", "b" }, { "b" }) + assert_eq(h[1].kind == "delete" and h[1].start == 1, true, "a deletion at the top marks line 1") + local marks = providers.hunk_marks({ { start = 3, count = 2, kind = "add" }, { start = 9, count = 0, kind = "delete" } }, 4) + assert_eq(#marks, 3, "two added lines + one deletion mark") + assert_eq(marks[1].current and marks[2].current and not marks[3].current, true, "the hunk under the cursor is current") + + -- Setup: a repo with a committed file, opened in a wide window. + local saved_config = vim.deepcopy(mm_state.get_config()) + local cfg = vim.deepcopy(saved_config) + cfg.minimap.git_debounce_ms = 20 + cfg.minimap.debounce_ms = 1 + cfg.minimap.diagnostics = { enabled = true, min_severity = "warn" } + mm_state.set_config(cfg) + local saved_columns = vim.o.columns + vim.o.columns = 120 + local repo = vim.fn.tempname() + vim.fn.mkdir(repo, "p") + git({ "init", "-q" }, repo) + git({ "config", "user.email", "diffbandit@example.test" }, repo) + git({ "config", "user.name", "DiffBandit Test" }, repo) + local lines = {} + for i = 1, 200 do + lines[i] = "line " .. i + end + vim.fn.writefile(lines, repo .. "/file.txt") + vim.fn.writefile({ "other" }, repo .. "/other.txt") + git({ "add", "." }, repo) + git({ "commit", "-q", "-m", "base" }, repo) + providers.reset_roots() + + vim.cmd("tabnew") + vim.cmd("edit " .. vim.fn.fnameescape(repo .. "/file.txt")) + local win = vim.api.nvim_get_current_win() + local buf = vim.api.nvim_get_current_buf() + assert_eq(minimap_editor.eligible(win), true, "a file window is eligible") + + local mm = assert(minimap_editor.open(win)) + assert_eq(minimap_editor.get(win), mm, "one minimap per window") + assert_eq(minimap_editor.open(win), mm, "opening again reuses it") + local fwin = mm.win + assert_eq(fwin ~= nil and vim.api.nvim_win_is_valid(fwin), true, "minimap float opened") + local fcfg = vim.api.nvim_win_get_config(fwin) + local area = placement.text_area(win) + assert_eq(fcfg.relative ~= "" and fcfg.focusable == false, true, "an unfocusable float") + local fpos = vim.api.nvim_win_get_position(fwin) + assert_eq(fpos[2], area.col + area.width - 1, "float sits on the window's last text column (cells: 1 wide)") + assert_eq(vim.api.nvim_win_get_height(fwin), area.height, "float spans the text height") + assert_eq(#vim.api.nvim_buf_get_lines(mm.buf, 0, -1, false), area.height, "one strip line per row") + assert_eq(#mm:state().marks, 0, "clean file: no hunks") + + -- Edits: re-diffed against the index after the debounce. + vim.api.nvim_buf_set_lines(buf, 99, 100, false, { "changed 100" }) + vim.api.nvim_buf_set_lines(buf, 150, 150, false, { "added a", "added b" }) + vim.api.nvim_exec_autocmds("TextChanged", { buffer = buf }) + assert_eq(wait(function() + return #mm:state().marks == 3 + end), true, "edits produce hunks against the index: " .. vim.inspect(mm:state().marks)) + local kinds = {} + for _, m in ipairs(mm:state().marks) do + kinds[m.line] = m.kind + end + assert_eq(kinds[100] == "change" and kinds[151] == "add" and kinds[152] == "add", true, "kinds per line") + mm:render() + local rows = {} + for _, mk in ipairs(vim.api.nvim_buf_get_extmarks(mm.buf, mm.ns, 0, -1, { details = true })) do + if mk[4].hl_group == "DiffBanditOverviewChange" or mk[4].hl_group == "DiffBanditOverviewAdd" then + rows[#rows + 1] = mk[2] + end + end + assert_eq(#rows >= 1, true, "cell strip paints the hunks") + + -- Diagnostics: warn and up by default. + local dns = vim.api.nvim_create_namespace("diffbandit_minimap_test") + vim.diagnostic.set(dns, buf, { + { lnum = 9, col = 0, severity = vim.diagnostic.severity.ERROR, message = "e" }, + { lnum = 19, col = 0, severity = vim.diagnostic.severity.HINT, message = "h" }, + }) + local diags = mm:state().diagnostics + assert_eq(#diags == 1 and diags[1].line == 10 and diags[1].severity == 1, true, + "errors shown, hints filtered: " .. vim.inspect(diags)) + mm:render() + local saw_diag = false + for _, mk in ipairs(vim.api.nvim_buf_get_extmarks(mm.buf, mm.ns, 0, -1, { details = true })) do + local vt = mk[4].virt_text + saw_diag = saw_diag or (vt and vt[1][2] == "DiffBanditOverviewDiagError") + end + assert_eq(saw_diag, true, "diagnostic drawn on the strip") + vim.diagnostic.reset(dns, buf) + + -- Mouse: a click on the float jumps the window; keyboard entry bounces. + assert_eq(minimap_mouse.minimap_for(fwin), mm, "float routed to its minimap") + minimap_mouse.on_enter(fwin, true, area.height) + assert_eq(vim.api.nvim_win_get_cursor(win)[1] > 150, true, "click on the last row jumps near the end") + vim.api.nvim_set_current_win(fwin) + assert_eq(wait(function() + return vim.api.nvim_get_current_win() == win + end), true, "keyboard entry bounces to the window") + + -- Writing and staging refreshes the index side. + vim.cmd("silent write") + git({ "add", "file.txt" }, repo) + vim.api.nvim_exec_autocmds("BufEnter", { buffer = buf }) + assert_eq(wait(function() + return #mm:state().marks == 0 + end), true, "staged edits leave no hunks") + + -- Switching buffers in the window rebinds the minimap. + vim.cmd("edit " .. vim.fn.fnameescape(repo .. "/other.txt")) + local other = vim.api.nvim_get_current_buf() + assert_eq(wait(function() + return mm.editor_provider.bufnr == other + end), true, "minimap follows the window's new buffer") + assert_eq(mm:state().line_count, 1, "the line count comes from the new buffer") + vim.cmd("help help") + local help_win = vim.api.nvim_get_current_win() + assert_eq(minimap_editor.eligible(help_win), false, "help windows are excluded") + vim.cmd("close") + + -- Narrow windows hide the strip; it returns when the window widens. + vim.cmd("vsplit") + vim.cmd("vertical resize 30") + local narrow = vim.api.nvim_get_current_win() + vim.wait(50) + local placed = mm.placement.ensure(mm, 1) + assert_eq(placed ~= nil, true, "the original window is still wide enough") + local nmm = minimap_editor.open(narrow) + assert_eq(nmm ~= nil and (nmm.placement.ensure(nmm, 1)) == nil, true, "a narrow window hides its strip") + vim.cmd("close") + assert_eq(wait(function() + return minimap_editor.get(narrow) == nil + end), true, "closing a window closes its minimap") + + -- Toggle closes; the float goes away. + minimap_editor.toggle(win) + assert_eq(minimap_editor.get(win), nil, "toggle closes") + assert_eq(vim.api.nvim_win_is_valid(fwin), false, "float closed") + assert_eq(minimap_mouse.minimap_for(fwin), nil, "mouse routing dropped") + vim.cmd("tabclose") + + -- DiffBandit's own tabs keep their overview gutters, never a minimap. + local session = assert(require("diffbandit").buffers(vim.api.nvim_create_buf(true, false), vim.api.nvim_create_buf(true, false))) + assert_eq(minimap_editor.eligible(session.right_win), false, "diff windows are not eligible") + session:close() + + -- Image renderer: the code shape, 12 cells wide. + local detect = require("diffbandit.kitty.detect") + detect._reset() + detect._set_sink(function() end) + local kcfg = vim.deepcopy(cfg) + kcfg.ui.connector_renderer = "kitty" + kcfg.ui.kitty.cell_px = { 10, 20 } + mm_state.set_config(kcfg) + vim.cmd("tabnew") + vim.cmd("edit " .. vim.fn.fnameescape(repo .. "/file.txt")) + local kwin = vim.api.nvim_get_current_win() + local kmm = assert(minimap_editor.open(kwin)) + assert_eq(kmm:renderer(), "kitty", "editor minimap follows the image renderer") + assert_eq(vim.api.nvim_win_get_width(kmm.win), 12, "code-shape width") + local st = kmm:image_state() + assert_eq(st ~= nil and st.transmits >= 1, true, "image transmitted") + assert_eq(kmm.code_cache ~= nil and #kmm.code_cache.runs, 202, "code shape built from the saved buffer (two lines added above)") + local built = kmm.code_cache + vim.bo[vim.api.nvim_win_get_buf(kwin)].filetype = "lua" + kmm:render() + assert_eq(kmm.code_cache ~= built and kmm.code_cache.ft == "lua", true, + "a filetype set after the first build rebuilds the code shape at once") + local first = vim.api.nvim_buf_get_lines(kmm.buf, 0, 1, false)[1] + assert_eq(first, require("diffbandit.kitty.protocol").placeholder_run(0, 0, 12), "placeholders fill the float") + local id = st.id + local deleted = false + detect._set_sink(function(data) + deleted = deleted or data == require("diffbandit.kitty.protocol").delete(id) + end) + vim.cmd("tabclose") + assert_eq(wait(function() + return deleted + end), true, "closing the window deletes the minimap image") + detect._set_sink(nil) + detect._reset() + + mm_state.set_config(saved_config) + vim.o.columns = saved_columns +end