From 1e629937eaed827221a7fdb5c367ab9b1f671025 Mon Sep 17 00:00:00 2001 From: Corey Kaylor Date: Wed, 4 Nov 2026 07:55:08 -0500 Subject: [PATCH 1/3] Keep panes and gutters aligned under a winbar A winbar takes the top row of a window. Breadcrumb plugins such as LspSaga set one on the source panes only, shifting the code a row below its number/connector gutters; a global 'winbar' reaches every split, but the Kitty canvas was placed at the connector's top row (the bar), one row above the code. A global winbar cannot be hidden per window (an empty local value falls back to it), so instead the bar row is kept uniform: when any aligned window shows a winbar, gutters, overview strips and bar-less panes get a blank one. The user's own bars are kept. Session, merge and folder hosts re-align on OptionSet winbar and BufWinEnter (buffer swaps reset it). The Kitty canvas now starts below the connector's winbar, and viewport heights use text rows (winheight) instead of nvim_win_get_height, which counts the bar row. Fixes #14 --- lua/diffbandit/folder/init.lua | 12 +++ lua/diffbandit/kitty/canvas.lua | 8 +- lua/diffbandit/merge/init.lua | 37 +++++-- lua/diffbandit/session/init.lua | 24 +++-- lua/diffbandit/session/layout.lua | 19 ++++ lua/diffbandit/session/render.lua | 4 +- lua/diffbandit/session/render_host.lua | 10 +- lua/diffbandit/util/layout.lua | 3 + lua/diffbandit/util/nvim.lua | 4 +- lua/diffbandit/util/winbar.lua | 127 +++++++++++++++++++++++++ tests/specs/30_session_snap.lua | 60 ++++++++++++ tests/specs/45_kitty.lua | 20 ++++ 12 files changed, 303 insertions(+), 25 deletions(-) create mode 100644 lua/diffbandit/util/winbar.lua diff --git a/lua/diffbandit/folder/init.lua b/lua/diffbandit/folder/init.lua index f71e08d..d732011 100644 --- a/lua/diffbandit/folder/init.lua +++ b/lua/diffbandit/folder/init.lua @@ -4,6 +4,7 @@ local nvim = require("diffbandit.util.nvim") local process = require("diffbandit.util.process") local ui = require("diffbandit.util.ui") local layout = require("diffbandit.util.layout") +local winbar = require("diffbandit.util.winbar") local config_mod = require("diffbandit.config") local Folder = {} @@ -419,6 +420,7 @@ function Folder:open_layout() end set_window_options(gutter_win, layout.win_opts.gutter()) set_window_width(gutter_win, self.gutter_width) + winbar.align(self:winbar_spec()) self:resize_layout() vim.api.nvim_set_current_win(left_win) self.title = string.format("DiffBandit Folder: %s ↔ %s", self.left_root, self.right_root) @@ -981,6 +983,11 @@ function Folder:run_digest_batch(batch, generation) end) end +-- The windows whose rows line up under a winbar (see util/winbar). +function Folder:winbar_spec() + return { content = { self.left_win, self.right_win }, gutters = { self.gutter_win } } +end + function Folder:setup_autocmds() local augroup = vim.api.nvim_create_augroup("DiffBanditFolder" .. tostring(self.id), { clear = true }) self.autocmd_group = augroup @@ -1001,6 +1008,11 @@ function Folder:setup_autocmds() end, }) end + winbar.watch(self, augroup, function() + return self:winbar_spec() + end, function() + self:render() + end) vim.api.nvim_create_autocmd({ "VimResized", "WinResized" }, { group = augroup, callback = function() diff --git a/lua/diffbandit/kitty/canvas.lua b/lua/diffbandit/kitty/canvas.lua index 2c180b0..d779d8b 100644 --- a/lua/diffbandit/kitty/canvas.lua +++ b/lua/diffbandit/kitty/canvas.lua @@ -15,6 +15,7 @@ local detect = require("diffbandit.kitty.detect") local ids = require("diffbandit.kitty.ids") local protocol = require("diffbandit.kitty.protocol") local raster = require("diffbandit.kitty.raster") +local winbar = require("diffbandit.util.winbar") local zlib = require("diffbandit.kitty.zlib") local M = {} @@ -140,7 +141,7 @@ function M.layout(host) local right_edge = win_col(sr.win) + sr.col local cols = right_edge - left_edge + 1 local core_start = win_col(host.connector_win) - left_edge - local rows = math.min(vim.api.nvim_win_get_height(host.connector_win), protocol.MAX_INDEX + 1) + local rows = math.min(vim.fn.winheight(host.connector_win), protocol.MAX_INDEX + 1) if cols < 1 or cols > protocol.MAX_INDEX + 1 or rows < 1 or core_start < 0 then return nil end @@ -522,8 +523,11 @@ local function ensure_float(host, st, layout) -- Editor-relative at the connector's screen row and the screen-left wedge -- column (relative="win" floats mis-position under some grids); paint -- re-checks the position every time, so layout changes follow on the - -- next render. + -- next render. The window position is its top row, which is the winbar + -- when it has one (util/winbar keeps the bar uniform across the panes); + -- the ribbons start at the first text row below it. local row = vim.api.nvim_win_get_position(host.connector_win)[1] + + winbar.rows(host.connector_win) local col = vim.api.nvim_win_get_position(layout.screen_left.win)[2] + layout.screen_left.col local cfg = { relative = "editor", diff --git a/lua/diffbandit/merge/init.lua b/lua/diffbandit/merge/init.lua index ed45c61..2e49767 100644 --- a/lua/diffbandit/merge/init.lua +++ b/lua/diffbandit/merge/init.lua @@ -4,6 +4,7 @@ local merge_model = require("diffbandit.merge.model") local nvim = require("diffbandit.util.nvim") local keymaps = require("diffbandit.util.keymaps") local layout = require("diffbandit.util.layout") +local winbar = require("diffbandit.util.winbar") local document = require("diffbandit.util.document") local pair_renderer = require("diffbandit.merge.pair_renderer") local panel_mod = require("diffbandit.panel") @@ -325,6 +326,22 @@ function Merge.start_for_path(path, opts, config) return Merge.start(data, config, opts) end +-- The windows whose rows line up under a winbar (see util/winbar). +function Merge:winbar_spec() + return { + content = { self.local_win, self.result_win, self.remote_win }, + gutters = { + self.local_num_win, + self.local_result_connector_win, + self.result_left_num_win, + self.result_right_num_win, + self.result_remote_connector_win, + self.remote_num_win, + }, + headers = { self.local_header_win, self.result_header_win, self.remote_header_win }, + } +end + function Merge:configure_windows() for _, win in ipairs({ self.local_win, self.result_win, self.remote_win }) do if win and vim.api.nvim_win_is_valid(win) then @@ -383,11 +400,7 @@ function Merge:configure_windows() set_win_width(self.remote_header_win, content_width + (remote_visible and (num_pane_width + core_width) or 0)) end - for _, win in ipairs({ self.local_win, self.result_win, self.remote_win }) do - if win and vim.api.nvim_win_is_valid(win) then - pcall(vim.api.nvim_set_option_value, "winbar", "", { scope = "local", win = win }) - end - end + winbar.align(self:winbar_spec()) if self.panel then local panel_config = config_mod.section(self.config, "git", "panel") for _, win in ipairs({ self.panel.nav_win, self.panel.commit_win }) do @@ -514,6 +527,14 @@ function Merge:setup_autocmds() group = self.augroup, callback = reassert_merge_maps, }) + -- Breadcrumb plugins set a pane's winbar lazily; keep the gutters on the + -- same bar row. + winbar.watch(self, self.augroup, function() + return self:winbar_spec() + end, function() + self:sync_gutter_viewports() + self:render() + end) -- The three content panes scroll independently; DiffBandit owns gutter -- synchronization (same contract as two-way sessions — no scrollbind). vim.api.nvim_create_autocmd("WinScrolled", { @@ -1652,9 +1673,9 @@ function Merge:goto_document_edge(edge) local local_count = math.max(1, vim.api.nvim_buf_line_count(self.local_buf)) local result_count = math.max(1, vim.api.nvim_buf_line_count(self.result_buf)) local remote_count = math.max(1, vim.api.nvim_buf_line_count(self.remote_buf)) - local local_height = self.local_win and vim.api.nvim_win_is_valid(self.local_win) and vim.api.nvim_win_get_height(self.local_win) or 1 - local result_height = self.result_win and vim.api.nvim_win_is_valid(self.result_win) and vim.api.nvim_win_get_height(self.result_win) or 1 - local remote_height = self.remote_win and vim.api.nvim_win_is_valid(self.remote_win) and vim.api.nvim_win_get_height(self.remote_win) or 1 + local local_height = self.local_win and vim.api.nvim_win_is_valid(self.local_win) and nvim.get_window_height(self.local_win) or 1 + local result_height = self.result_win and vim.api.nvim_win_is_valid(self.result_win) and nvim.get_window_height(self.result_win) or 1 + local remote_height = self.remote_win and vim.api.nvim_win_is_valid(self.remote_win) and nvim.get_window_height(self.remote_win) or 1 local local_line = edge == "bottom" and local_count or 1 local result_line = edge == "bottom" and result_count or 1 diff --git a/lua/diffbandit/session/init.lua b/lua/diffbandit/session/init.lua index 2910b1a..788ac6e 100644 --- a/lua/diffbandit/session/init.lua +++ b/lua/diffbandit/session/init.lua @@ -16,6 +16,7 @@ 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 winbar = require("diffbandit.util.winbar") local Session = {} Session.__index = Session @@ -232,8 +233,8 @@ function Session:render_overviews() return 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_height = math.max(1, nvim.get_window_height(self.left_win)) + local right_height = math.max(1, nvim.get_window_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 @@ -268,7 +269,7 @@ function Session:get_scroll_padding() local wins = { self.left_win, self.right_win, self.connector_win } for _, win in ipairs(wins) do if win and vim.api.nvim_win_is_valid(win) then - height = math.max(height, vim.api.nvim_win_get_height(win)) + height = math.max(height, nvim.get_window_height(win)) end end return math.max(0, height - 1) @@ -387,6 +388,15 @@ function Session:setup_autocmds() end, }) + -- A winbar set on a source pane after open (breadcrumb plugins attach + -- lazily) moves its text a row down; give the gutters the same bar row. + winbar.watch(self, augroup, function() + return session_layout.winbar_spec(self) + end, function() + self:sync_gutter_viewports() + self:render() + end) + vim.api.nvim_create_autocmd("WinScrolled", { group = augroup, callback = function(event) @@ -872,8 +882,8 @@ function Session:align_chunk_viewports(chunk) -- keeps the UI responsive and coalesces repaints. Long jumps outside the -- window render synchronously so no unstyled rows ever appear. local clip = self.last_render_clip - local left_height = vim.api.nvim_win_is_valid(self.left_win) and vim.api.nvim_win_get_height(self.left_win) or 1 - local right_height = vim.api.nvim_win_is_valid(self.right_win) and vim.api.nvim_win_get_height(self.right_win) or 1 + local left_height = vim.api.nvim_win_is_valid(self.left_win) and nvim.get_window_height(self.left_win) or 1 + local right_height = vim.api.nvim_win_is_valid(self.right_win) and nvim.get_window_height(self.right_win) or 1 local within_styled_window = clip ~= nil and left_topline >= clip.left_min and (left_topline + left_height) <= clip.left_max @@ -942,8 +952,8 @@ function Session:goto_document_edge(edge) local right_line_count = math.max(1, #self.view.right) local left_line = edge == "bottom" and left_line_count or 1 local right_line = edge == "bottom" and right_line_count or 1 - local left_height = vim.api.nvim_win_is_valid(self.left_win) and vim.api.nvim_win_get_height(self.left_win) or 1 - local right_height = vim.api.nvim_win_is_valid(self.right_win) and vim.api.nvim_win_get_height(self.right_win) or 1 + local left_height = vim.api.nvim_win_is_valid(self.left_win) and nvim.get_window_height(self.left_win) or 1 + local right_height = vim.api.nvim_win_is_valid(self.right_win) and nvim.get_window_height(self.right_win) or 1 local left_topline = edge == "bottom" and math.max(1, left_line_count - left_height + 1) or 1 local right_topline = edge == "bottom" and math.max(1, right_line_count - right_height + 1) or 1 diff --git a/lua/diffbandit/session/layout.lua b/lua/diffbandit/session/layout.lua index 615d699..bef1e2e 100644 --- a/lua/diffbandit/session/layout.lua +++ b/lua/diffbandit/session/layout.lua @@ -6,6 +6,7 @@ local nvim = require("diffbandit.util.nvim") local document = require("diffbandit.util.document") local config_mod = require("diffbandit.config") local layout = require("diffbandit.util.layout") +local winbar = require("diffbandit.util.winbar") local set_buffer_options = nvim.set_buffer_options local set_window_options = nvim.set_window_options @@ -240,11 +241,27 @@ end -- queue) silently reverts them to that buffer's defaults — 'wrap' coming -- back on desyncs the pane's screen rows from its unwrappable gutters, -- and 'scrolloff'/folds undo ]c alignment. Re-apply after every right-buffer swap. +-- The windows whose rows line up under a winbar (see util/winbar). +local function winbar_spec(session) + return { + content = { session.left_win, session.right_win }, + gutters = { + session.left_overview_win, + session.left_num_win, + session.connector_win, + session.right_num_win, + session.right_overview_win, + }, + headers = { session.left_header_win, session.center_header_win, session.right_header_win }, + } +end + local function apply_right_source_window_options(session) local right_source_winhl = session.overview_enabled and layout.winhl.hidden_source or layout.winhl.source set_window_options(session.right_win, layout.win_opts.source(right_source_winhl, { signcolumn = session.right.editable and "auto" or "no", })) + winbar.align(winbar_spec(session)) end local function apply_window_options(session) @@ -341,6 +358,8 @@ function M.apply_right_source_window_options(session) apply_right_source_window_options(session) end +M.winbar_spec = winbar_spec + function M.open(session) vim.cmd("tabnew") session.tabpage = vim.api.nvim_get_current_tabpage() diff --git a/lua/diffbandit/session/render.lua b/lua/diffbandit/session/render.lua index efcfc6c..2e57838 100644 --- a/lua/diffbandit/session/render.lua +++ b/lua/diffbandit/session/render.lua @@ -234,7 +234,7 @@ local function build_render_context(self) local left_lines, right_lines = self:display_lines() local left_topline = get_win_view_topline(self.left_win) local right_topline = get_win_view_topline(self.right_win) - local left_height = vim.api.nvim_win_is_valid(self.left_win) and vim.api.nvim_win_get_height(self.left_win) or 1 + local left_height = vim.api.nvim_win_is_valid(self.left_win) and nvim.get_window_height(self.left_win) or 1 local right_text_hl_mode = self.right.editable and "combine" or "replace" -- Compute connector routing lanes using extracted paths module. Projection @@ -242,7 +242,7 @@ local function build_render_context(self) -- functions of the viewport state — cache per state so revisited viewports -- (navigation back and forth, debounced duplicate repaints) are free. local paths = self:base_paths() - local win_height = vim.api.nvim_win_is_valid(self.right_win) and vim.api.nvim_win_get_height(self.right_win) or 1 + local win_height = vim.api.nvim_win_is_valid(self.right_win) and nvim.get_window_height(self.right_win) or 1 local kitty = kitty_mode(self) local plan_cache_key = string.format("%s:%s:%d:%d:%d:%d%s", tostring(left_topline), tostring(right_topline), left_height, win_height, diff --git a/lua/diffbandit/session/render_host.lua b/lua/diffbandit/session/render_host.lua index f043346..43f0c47 100644 --- a/lua/diffbandit/session/render_host.lua +++ b/lua/diffbandit/session/render_host.lua @@ -18,8 +18,8 @@ local get_win_view_topline = nvim.get_win_view_topline local function live_viewport_render_key(self) local left_topline = get_win_view_topline(self.left_win) local right_topline = get_win_view_topline(self.right_win) - local left_height = vim.api.nvim_win_is_valid(self.left_win) and vim.api.nvim_win_get_height(self.left_win) or 1 - local right_height = vim.api.nvim_win_is_valid(self.right_win) and vim.api.nvim_win_get_height(self.right_win) or 1 + local left_height = vim.api.nvim_win_is_valid(self.left_win) and nvim.get_window_height(self.left_win) or 1 + local right_height = vim.api.nvim_win_is_valid(self.right_win) and nvim.get_window_height(self.right_win) or 1 return ui.viewport_render_key(left_topline, right_topline, left_height, right_height, self.current_chunk) end @@ -275,8 +275,8 @@ end function M.project_paths_for_viewport(self, paths) local left_topline = get_win_view_topline(self.left_win) local right_topline = get_win_view_topline(self.right_win) - local left_height = vim.api.nvim_win_is_valid(self.left_win) and vim.api.nvim_win_get_height(self.left_win) or 1 - local right_height = vim.api.nvim_win_is_valid(self.right_win) and vim.api.nvim_win_get_height(self.right_win) or 1 + local left_height = vim.api.nvim_win_is_valid(self.left_win) and nvim.get_window_height(self.left_win) or 1 + local right_height = vim.api.nvim_win_is_valid(self.right_win) and nvim.get_window_height(self.right_win) or 1 return self:project_paths_for_toplines(paths, left_topline, right_topline, left_height, right_height) end @@ -288,7 +288,7 @@ function M.precompute_connector_core_width(self) required_core = require("diffbandit.kitty").core_width(self.config) else local viewport_rows = vim.api.nvim_win_is_valid(self.left_win) - and vim.api.nvim_win_get_height(self.left_win) or nil + and nvim.get_window_height(self.left_win) or nil required_core = connector.pressure_core_width( self:base_paths(), connector_width.base(self.view, self.config), diff --git a/lua/diffbandit/util/layout.lua b/lua/diffbandit/util/layout.lua index 6248838..5c79f16 100644 --- a/lua/diffbandit/util/layout.lua +++ b/lua/diffbandit/util/layout.lua @@ -36,11 +36,14 @@ M.winhl = { source = split .. ",CursorLine:DiffBanditCursorLine,SignColumn:DiffBanditSignColumn", hidden_source = hidden_split .. ",CursorLine:DiffBanditCursorLine,SignColumn:DiffBanditSignColumn", gutter = "Normal:DiffBanditConnectorContext,NormalNC:DiffBanditConnectorContext," + .. "WinBar:DiffBanditConnectorContext,WinBarNC:DiffBanditConnectorContext," .. split .. ",CursorLine:DiffBanditCursorLine", overview = "Normal:DiffBanditOverviewContext,NormalNC:DiffBanditOverviewContext," + .. "WinBar:DiffBanditOverviewContext,WinBarNC:DiffBanditOverviewContext," .. hidden_split .. ",CursorLine:DiffBanditCursorLine", status = "Normal:DiffBanditStatus,NormalNC:DiffBanditStatus," .. "StatusLine:DiffBanditStatusLine,StatusLineNC:DiffBanditStatusLine," + .. "WinBar:DiffBanditStatus,WinBarNC:DiffBanditStatus," .. split .. ",CursorLine:DiffBanditStatus", panel = split .. ",Normal:DiffBanditStatus,NormalNC:DiffBanditStatus,CursorLine:DiffBanditCursorLine", } diff --git a/lua/diffbandit/util/nvim.lua b/lua/diffbandit/util/nvim.lua index 2177abf..524fea3 100644 --- a/lua/diffbandit/util/nvim.lua +++ b/lua/diffbandit/util/nvim.lua @@ -55,9 +55,11 @@ function M.get_window_width(win) return 0 end +-- Text rows of a window. nvim_win_get_height counts the winbar row too; +-- every DiffBandit viewport computation means the rows code is drawn on. function M.get_window_height(win) if win and vim.api.nvim_win_is_valid(win) then - return vim.api.nvim_win_get_height(win) + return math.max(0, vim.fn.winheight(win)) end return 0 end diff --git a/lua/diffbandit/util/winbar.lua b/lua/diffbandit/util/winbar.lua new file mode 100644 index 0000000..1cd42f6 --- /dev/null +++ b/lua/diffbandit/util/winbar.lua @@ -0,0 +1,127 @@ +-- 'winbar' takes the top screen row of a window. DiffBandit lines content +-- panes up row for row with their number/connector gutters, so a winbar on +-- some of those windows and not the others (LspSaga breadcrumbs on the +-- source panes) shifts the code one row against its gutters. +-- +-- Removing winbars is not an option: a global 'winbar' cannot be turned off +-- for one window (an empty local value falls back to the global one), and +-- plugins re-apply theirs on every cursor move. Instead the bar row is kept +-- uniform: when any aligned window shows a winbar, every aligned window +-- does — gutters get a blank bar, and content panes keep their own bar or +-- get a blank one. The user's breadcrumbs stay where they put them. +local M = {} + +-- The blank bar DiffBandit owns. Recognised by value, so a buffer swap +-- (which resets window-local options) or a plugin overwriting it is seen +-- as "not ours" on the next sync. +M.BLANK = " " + +local function valid(win) + return win and vim.api.nvim_win_is_valid(win) +end + +local function local_value(win) + local ok, value = pcall(vim.api.nvim_get_option_value, "winbar", { scope = "local", win = win }) + return ok and value or "" +end + +local function set_local(win, value) + if local_value(win) ~= value then + pcall(vim.api.nvim_set_option_value, "winbar", value, { scope = "local", win = win }) + return true + end + return false +end + +-- A winbar somebody other than DiffBandit wants on this window (the local +-- value, or the global one it falls back to). +local function foreign_bar(win) + local value = local_value(win) + if value == "" then + value = vim.go.winbar + end + return value ~= "" and value ~= M.BLANK +end + +--- Screen rows the window's winbar occupies above its text (0 or 1). +function M.rows(win) + if not valid(win) then + return 0 + end + local info = vim.fn.getwininfo(win)[1] + return info and info.winbar or 0 +end + +--- Make the winbar row uniform across a host's aligned windows. +--- spec.content: panes whose text the gutters follow (may carry a user bar) +--- spec.gutters: number/connector/overview windows (only ever blank bars) +--- spec.headers: one-row status windows above the panes; they share a row +--- with each other, not with the panes, so they only need a bar when the +--- global winbar forces one on some of them anyway. +--- Returns true when any window's bar changed (its text height changed). +function M.align(spec) + local content, gutters, headers = {}, {}, {} + for _, win in ipairs(spec.content or {}) do + if valid(win) then content[#content + 1] = win end + end + for _, win in ipairs(spec.gutters or {}) do + if valid(win) then gutters[#gutters + 1] = win end + end + for _, win in ipairs(spec.headers or {}) do + if valid(win) then headers[#headers + 1] = win end + end + + local any = vim.go.winbar ~= "" + for _, win in ipairs(content) do + any = any or foreign_bar(win) + end + + local changed = false + local function blank_or_clear(win, want_blank) + if want_blank then + changed = set_local(win, M.BLANK) or changed + elseif local_value(win) == M.BLANK then + changed = set_local(win, "") or changed + end + end + -- A global bar already fills every pane; only blank the panes that would + -- otherwise have none, and drop a stale blank so the global bar shows. + for _, win in ipairs(content) do + blank_or_clear(win, any and vim.go.winbar == "" and not foreign_bar(win)) + end + for _, win in ipairs(gutters) do + blank_or_clear(win, any) + end + for _, win in ipairs(headers) do + blank_or_clear(win, vim.go.winbar ~= "") + end + return changed +end + +--- Re-align whenever a winbar changes (plugins set breadcrumbs lazily, on +--- LspAttach or CursorMoved) or a buffer swap resets window-local options. +--- `get_spec()` returns the align spec (nil once the host is gone); +--- `on_change()` runs after an alignment changed window heights. +function M.watch(host, augroup, get_spec, on_change) + local pending = false + local function sync() + if pending then + return + end + pending = true + vim.schedule(function() + pending = false + if host.disposed then + return + end + local spec = get_spec() + if spec and M.align(spec) and on_change then + on_change() + end + end) + end + vim.api.nvim_create_autocmd("OptionSet", { group = augroup, pattern = "winbar", callback = sync }) + vim.api.nvim_create_autocmd("BufWinEnter", { group = augroup, callback = sync }) +end + +return M diff --git a/tests/specs/30_session_snap.lua b/tests/specs/30_session_snap.lua index 1b6e585..10a916d 100644 --- a/tests/specs/30_session_snap.lua +++ b/tests/specs/30_session_snap.lua @@ -561,3 +561,63 @@ do pcall(vim.api.nvim_buf_delete, buf2, { force = true }) end + +-- Suite 16g: winbar keeps content panes and gutters on the same rows +-- (GitHub issue #14). A breadcrumb plugin's bar on one source pane, or a +-- global 'winbar', must shift every aligned window's text by the same row. +do + local winbar = require("diffbandit.util.winbar") + local session_layout_mod = require("diffbandit.session.layout") + local function text_row(win) + return vim.fn.win_screenpos(win)[1] + winbar.rows(win) + end + local function aligned_windows(session) + return { + session.left_overview_win, session.left_win, session.left_num_win, session.connector_win, + session.right_num_win, session.right_win, session.right_overview_win, + } + end + local function assert_rows_aligned(session, label) + vim.cmd("redraw") + local want_row, want_height = text_row(session.left_win), vim.fn.winheight(session.left_win) + for _, win in ipairs(aligned_windows(session)) do + if win then + assert_eq(text_row(win), want_row, label .. ": text starts on the same row in win " .. win) + assert_eq(vim.fn.winheight(win), want_height, label .. ": same text height in win " .. win) + end + end + end + local function start() + return assert((Session.start({ + left = source_mod.from_lines({ "a", "b", "c" }, nil, "left"), + right = source_mod.from_lines({ "a", "B", "c", "d" }, nil, "right"), + }, config))) + end + + -- Per-window bar on one pane (LspSaga breadcrumbs). + local session = start() + assert_rows_aligned(session, "no winbar") + vim.wo[session.right_win].winbar = "crumbs" + assert_eq(winbar.align(session_layout_mod.winbar_spec(session)), true, "a new pane bar realigns") + assert_rows_aligned(session, "pane winbar") + assert_eq(vim.wo[session.right_win].winbar, "crumbs", "the user's bar is kept") + assert_eq(vim.wo[session.connector_win].winbar, winbar.BLANK, "gutters get a blank bar") + assert_eq(winbar.align(session_layout_mod.winbar_spec(session)), false, "aligning is idempotent") + vim.wo[session.right_win].winbar = "" + winbar.align(session_layout_mod.winbar_spec(session)) + assert_rows_aligned(session, "bar removed") + assert_eq(vim.wo[session.connector_win].winbar, "", "blank bars are withdrawn with the last real one") + session:close() + + -- Global bar: kept on the panes, blanked on gutters and headers. + local saved = vim.go.winbar + vim.go.winbar = "%f" + session = start() + assert_rows_aligned(session, "global winbar") + assert_eq(vim.wo[session.connector_win].winbar, winbar.BLANK, "gutters blank the global bar") + if session.center_header_win then + assert_eq(vim.wo[session.center_header_win].winbar, winbar.BLANK, "headers blank the global bar") + end + session:close() + vim.go.winbar = saved +end diff --git a/tests/specs/45_kitty.lua b/tests/specs/45_kitty.lua index 4f526d5..ef9a9f7 100644 --- a/tests/specs/45_kitty.lua +++ b/tests/specs/45_kitty.lua @@ -787,6 +787,26 @@ do reset_detect() end + do + -- Issue #14: under a winbar the canvas starts at the connector's first + -- TEXT row (below the bar) and is as tall as the text area, not one row + -- above the code it connects. + reset_detect() + local saved = vim.go.winbar + vim.go.winbar = "%f" + local session = start_session(kitty_config()) + session:render() + local st = session.kitty_state + local cwin = session.connector_win + assert_eq(require("diffbandit.util.winbar").rows(cwin), 1, "connector shows the (blank) bar row") + local text_row = vim.api.nvim_win_get_position(cwin)[1] + 1 + assert_eq(vim.api.nvim_win_get_position(st.float_win)[1], text_row, "canvas starts below the winbar") + assert_eq(vim.api.nvim_win_get_height(st.float_win), vim.fn.winheight(cwin), "canvas spans the text rows only") + session:close() + vim.go.winbar = saved + reset_detect() + end + do -- Insertion points: scroll the add (right 20-21, left origin 19) on screen. reset_detect() From 5fcdd26d8d6325af8bf7e015dd809ceb8ab6df9a Mon Sep 17 00:00:00 2001 From: Corey Kaylor Date: Wed, 4 Nov 2026 08:34:27 -0500 Subject: [PATCH 2/3] Catch winbars set from inside other autocmds LspSaga renders breadcrumbs from its CursorMoved, BufEnter and symbol-update callbacks. OptionSet does not fire there (autocmds are not nested), and a bar appearing fires neither WinResized nor WinScrolled, so a bar added after the first render (focus moving to the right pane, ]f/[f) left the gutters a row off. Showing a winbar always redraws its window, so the watcher now rides on a decoration provider: on_win checks (read-only) whether a host's bar rows are in step and schedules the realign when they are not. This covers any source of change: nested or top-level sets, global toggles, and buffer swaps. --- lua/diffbandit/folder/init.lua | 2 +- lua/diffbandit/merge/init.lua | 2 +- lua/diffbandit/session/init.lua | 2 +- lua/diffbandit/util/winbar.lua | 152 ++++++++++++++++++++++++-------- tests/specs/30_session_snap.lua | 26 ++++++ 5 files changed, 142 insertions(+), 42 deletions(-) diff --git a/lua/diffbandit/folder/init.lua b/lua/diffbandit/folder/init.lua index d732011..7f39de3 100644 --- a/lua/diffbandit/folder/init.lua +++ b/lua/diffbandit/folder/init.lua @@ -1008,7 +1008,7 @@ function Folder:setup_autocmds() end, }) end - winbar.watch(self, augroup, function() + winbar.watch(self, function() return self:winbar_spec() end, function() self:render() diff --git a/lua/diffbandit/merge/init.lua b/lua/diffbandit/merge/init.lua index 2e49767..376e13b 100644 --- a/lua/diffbandit/merge/init.lua +++ b/lua/diffbandit/merge/init.lua @@ -529,7 +529,7 @@ function Merge:setup_autocmds() }) -- Breadcrumb plugins set a pane's winbar lazily; keep the gutters on the -- same bar row. - winbar.watch(self, self.augroup, function() + winbar.watch(self, function() return self:winbar_spec() end, function() self:sync_gutter_viewports() diff --git a/lua/diffbandit/session/init.lua b/lua/diffbandit/session/init.lua index 788ac6e..ef15014 100644 --- a/lua/diffbandit/session/init.lua +++ b/lua/diffbandit/session/init.lua @@ -390,7 +390,7 @@ function Session:setup_autocmds() -- A winbar set on a source pane after open (breadcrumb plugins attach -- lazily) moves its text a row down; give the gutters the same bar row. - winbar.watch(self, augroup, function() + winbar.watch(self, function() return session_layout.winbar_spec(self) end, function() self:sync_gutter_viewports() diff --git a/lua/diffbandit/util/winbar.lua b/lua/diffbandit/util/winbar.lua index 1cd42f6..2daabde 100644 --- a/lua/diffbandit/util/winbar.lua +++ b/lua/diffbandit/util/winbar.lua @@ -26,7 +26,7 @@ local function local_value(win) end local function set_local(win, value) - if local_value(win) ~= value then + if valid(win) and local_value(win) ~= value then pcall(vim.api.nvim_set_option_value, "winbar", value, { scope = "local", win = win }) return true end @@ -52,14 +52,10 @@ function M.rows(win) return info and info.winbar or 0 end ---- Make the winbar row uniform across a host's aligned windows. ---- spec.content: panes whose text the gutters follow (may carry a user bar) ---- spec.gutters: number/connector/overview windows (only ever blank bars) ---- spec.headers: one-row status windows above the panes; they share a row ---- with each other, not with the panes, so they only need a bar when the ---- global winbar forces one on some of them anyway. ---- Returns true when any window's bar changed (its text height changed). -function M.align(spec) +-- The local winbar values that would make the spec's windows uniform, as +-- a list of { win, value } (empty when already aligned). Reads only, so it +-- is safe to call from a decoration provider during redraw. +local function plan(spec) local content, gutters, headers = {}, {}, {} for _, win in ipairs(spec.content or {}) do if valid(win) then content[#content + 1] = win end @@ -71,57 +67,135 @@ function M.align(spec) if valid(win) then headers[#headers + 1] = win end end - local any = vim.go.winbar ~= "" + local global = vim.go.winbar ~= "" + local any = global for _, win in ipairs(content) do any = any or foreign_bar(win) end - local changed = false - local function blank_or_clear(win, want_blank) - if want_blank then - changed = set_local(win, M.BLANK) or changed - elseif local_value(win) == M.BLANK then - changed = set_local(win, "") or changed + local changes = {} + local function want(win, blank) + local value = local_value(win) + if blank and value ~= M.BLANK then + changes[#changes + 1] = { win, M.BLANK } + elseif not blank and value == M.BLANK then + changes[#changes + 1] = { win, "" } end end -- A global bar already fills every pane; only blank the panes that would -- otherwise have none, and drop a stale blank so the global bar shows. for _, win in ipairs(content) do - blank_or_clear(win, any and vim.go.winbar == "" and not foreign_bar(win)) + want(win, any and not global and not foreign_bar(win)) end for _, win in ipairs(gutters) do - blank_or_clear(win, any) + want(win, any) end for _, win in ipairs(headers) do - blank_or_clear(win, vim.go.winbar ~= "") + want(win, global) + end + return changes +end + +--- Make the winbar row uniform across a host's aligned windows. +--- spec.content: panes whose text the gutters follow (may carry a user bar) +--- spec.gutters: number/connector/overview windows (only ever blank bars) +--- spec.headers: one-row status windows above the panes; they share a row +--- with each other, not with the panes, so they only need a bar when the +--- global winbar forces one on some of them anyway. +--- Returns true when any window's bar changed (its text height changed). +function M.align(spec) + local changed = false + for _, change in ipairs(plan(spec)) do + changed = set_local(change[1], change[2]) or changed end return changed end ---- Re-align whenever a winbar changes (plugins set breadcrumbs lazily, on ---- LspAttach or CursorMoved) or a buffer swap resets window-local options. ---- `get_spec()` returns the align spec (nil once the host is gone); ---- `on_change()` runs after an alignment changed window heights. -function M.watch(host, augroup, get_spec, on_change) - local pending = false - local function sync() - if pending then +--- True when the spec's windows already share one bar row. +function M.aligned(spec) + return #plan(spec) == 0 +end + +-- Hosts being watched: host -> { get_spec, on_change, pending }. +local watched = {} +local provider_ns + +local function sync(host, entry) + if entry.pending then + return + end + entry.pending = true + vim.schedule(function() + entry.pending = false + if host.disposed or watched[host] ~= entry then return end - pending = true - vim.schedule(function() - pending = false - if host.disposed then - return - end - local spec = get_spec() - if spec and M.align(spec) and on_change then - on_change() + local spec = entry.get_spec() + if spec and M.align(spec) and entry.on_change then + entry.on_change() + end + end) +end + +local function spec_has(spec, win) + for _, field in ipairs({ "content", "gutters" }) do + for _, w in ipairs(spec[field] or {}) do + if w == win then + return true end - end) + end + end + return false +end + +-- Plugins set winbars from inside their own autocmds (LspSaga renders on +-- CursorMoved, BufEnter and its symbol-update event), where OptionSet does +-- not fire (autocmds are not nested), and a bar appearing fires neither +-- WinResized nor WinScrolled. Showing it always redraws the window, so the +-- check rides on the redraw: on_win compares (read-only) and a mismatch +-- schedules the realign, since options cannot change mid-redraw. +local function ensure_provider() + if provider_ns then + return end - vim.api.nvim_create_autocmd("OptionSet", { group = augroup, pattern = "winbar", callback = sync }) - vim.api.nvim_create_autocmd("BufWinEnter", { group = augroup, callback = sync }) + provider_ns = vim.api.nvim_create_namespace("DiffBanditWinbar") + local checked = {} + vim.api.nvim_set_decoration_provider(provider_ns, { + on_start = function() + checked = {} + for host in pairs(watched) do + if host.disposed then + watched[host] = nil + end + end + return next(watched) ~= nil + end, + on_win = function(_, win) + for host, entry in pairs(watched) do + if not checked[host] and not entry.pending then + local ok, spec = pcall(entry.get_spec) + if ok and spec and spec_has(spec, win) then + checked[host] = true + local ok_plan, aligned = pcall(M.aligned, spec) + if ok_plan and not aligned then + sync(host, entry) + end + end + end + end + return false + end, + }) +end + +--- Keep a host aligned for its lifetime: whenever one of its windows is +--- redrawn with bar rows out of step (a bar added, changed to/from empty, +--- a global bar toggled, a buffer swap resetting window-local options), +--- realign and call `on_change()`. `get_spec()` returns the align spec. +--- The host stops being watched once `host.disposed` is set. +function M.watch(host, get_spec, on_change) + watched[host] = { get_spec = get_spec, on_change = on_change } + ensure_provider() end return M diff --git a/tests/specs/30_session_snap.lua b/tests/specs/30_session_snap.lua index 10a916d..7cc1788 100644 --- a/tests/specs/30_session_snap.lua +++ b/tests/specs/30_session_snap.lua @@ -607,6 +607,32 @@ do winbar.align(session_layout_mod.winbar_spec(session)) assert_rows_aligned(session, "bar removed") assert_eq(vim.wo[session.connector_win].winbar, "", "blank bars are withdrawn with the last real one") + + -- The reported case (PR #16 feedback): a plugin sets the bar from inside + -- its own autocmd after the session rendered, so no OptionSet fires and + -- nothing calls align. The next redraw must catch it on its own. + local group = vim.api.nvim_create_augroup("DiffBanditWinbarSpec", { clear = true }) + vim.api.nvim_create_autocmd("User", { + group = group, + pattern = "WinbarSpecCrumbs", + callback = function(args) + vim.wo[session.right_win].winbar = args.data + end, + }) + local function nested_set(value) + vim.api.nvim_exec_autocmds("User", { pattern = "WinbarSpecCrumbs", data = value }) + vim.cmd("redraw") + return vim.wait(1000, function() + vim.cmd("redraw") + return winbar.aligned(session_layout_mod.winbar_spec(session)) + end, 10) + end + assert_eq(nested_set("lazy crumbs"), true, "a bar set inside another autocmd is caught on redraw") + assert_rows_aligned(session, "lazily set pane winbar") + assert_eq(nested_set(""), true, "clearing the bar inside an autocmd is caught on redraw") + assert_rows_aligned(session, "lazily cleared pane winbar") + assert_eq(vim.wo[session.connector_win].winbar, "", "blank bars withdrawn after a lazy clear") + vim.api.nvim_del_augroup_by_id(group) session:close() -- Global bar: kept on the panes, blanked on gutters and headers. From 7eb0b1e4b85de1aa223c11e0c2f6c3c84286e1a2 Mon Sep 17 00:00:00 2001 From: Corey Kaylor Date: Wed, 4 Nov 2026 11:08:11 -0500 Subject: [PATCH 3/3] Replace the Kitty placement when its cell grid changes Re-sending an image id with a new c/r does not resize its existing virtual placement in Ghostty: the old grid is kept and the new image is stretched over it. A winbar arriving after the first paint takes one row from the connector, so the ribbons drifted further off the further down the screen they were, until a later same-grid transmit happened to fix it. Delete the old image and placement before transmitting a different grid; same-grid repaints still replace the image in place. --- lua/diffbandit/kitty/canvas.lua | 10 ++++++++++ tests/specs/45_kitty.lua | 21 +++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/lua/diffbandit/kitty/canvas.lua b/lua/diffbandit/kitty/canvas.lua index d779d8b..da53ed5 100644 --- a/lua/diffbandit/kitty/canvas.lua +++ b/lua/diffbandit/kitty/canvas.lua @@ -208,6 +208,16 @@ local function transmit(host, st, bytes, width, height, layout) medium = "d", data = payload, compressed = compressed, quiet = 1, }) end + -- Re-sending an image id with a new cell grid (c/r) does not resize its + -- existing virtual placement in every terminal: Ghostty keeps the old + -- grid and stretches the new image over it, so the ribbons drift further + -- off the further down they are (a winbar or resize taking one row). Drop + -- the old image and placement first whenever the grid changes. + local grid = layout.cols .. "x" .. layout.rows + if st.placed_grid and st.placed_grid ~= grid then + table.insert(chunks, 1, protocol.delete(st.id)) + end + st.placed_grid = grid st.transmits = st.transmits + 1 local ok = detect.send(chunks) detect.note_transmit() diff --git a/tests/specs/45_kitty.lua b/tests/specs/45_kitty.lua index ef9a9f7..55b7133 100644 --- a/tests/specs/45_kitty.lua +++ b/tests/specs/45_kitty.lua @@ -804,6 +804,27 @@ do assert_eq(vim.api.nvim_win_get_height(st.float_win), vim.fn.winheight(cwin), "canvas spans the text rows only") session:close() vim.go.winbar = saved + + -- PR #16 feedback: a bar arriving after the first paint shrinks the grid + -- by a row. Ghostty keeps an image id's old virtual-placement grid when + -- the id is re-sent with new c/r, stretching the ribbons, so a grid + -- change must delete the old image first; same-grid repaints must not. + session = start_session(kitty_config()) + session:render() + st = session.kitty_state + local deletes = count_matching(protocol.delete(st.id)) + vim.wo[session.right_win].winbar = "lazy crumbs" + require("diffbandit.util.winbar").align(require("diffbandit.session.layout").winbar_spec(session)) + session:render() + assert_eq(st.last_layout.rows, vim.fn.winheight(session.connector_win), "repaint uses the shrunken grid") + assert_eq(count_matching(protocol.delete(st.id)), deletes + 1, "a grid change deletes the old placement first") + for _, w in ipairs({ session.left_win, session.left_num_win, session.connector_win, session.right_num_win, session.right_win }) do + nvim_util.set_win_view_topline(w, 40) + end + session.last_viewport_render_key = nil + session:rerender_for_viewport() + assert_eq(count_matching(protocol.delete(st.id)), deletes + 1, "same-grid repaints keep the placement") + session:close() reset_detect() end