diff --git a/lua/u/range.lua b/lua/u/range.lua index 04d5eb7..b345d45 100644 --- a/lua/u/range.lua +++ b/lua/u/range.lua @@ -100,13 +100,25 @@ function Range.from_extmark(bufnr, extmark) local start = Pos.new(bufnr, start_row0 + 1, start_col0 + 1) local stop = details and Pos.new(bufnr, details.end_row + 1, details.end_col) - -- Check for invalid extmark range: - if stop and stop < start then return Range.new(stop) end + if stop ~= nil then + -- Check for invalid extmark range: + if stop < start then return Range.new(stop) end - if stop and stop.col == 0 then - mode = 'V' - stop = stop:must_next(-1) - stop.col = Pos.MAX_COL + -- Check for stop-mark past the end of the buffer: + local buf_max_lines = vim.api.nvim_buf_line_count(bufnr) + if stop.lnum > buf_max_lines then + mode = 'V' + stop.lnum = buf_max_lines + stop.col = Pos.MAX_COL + end + + -- A stop mark at position 0 means it is at the end of the last line. + -- Move it back. + if stop.col == 0 then + mode = 'V' + stop = stop:must_next(-1) + stop.col = Pos.MAX_COL + end end return Range.new(start, stop, mode) diff --git a/lua/u/renderer.lua b/lua/u/renderer.lua index 7af561f..9d3e862 100644 --- a/lua/u/renderer.lua +++ b/lua/u/renderer.lua @@ -394,14 +394,28 @@ function Renderer:_on_text_changed() local start_row0, start_col0, details = unpack(extmark) local end_row0, end_col0 = details.end_row, details.end_col + local buf_max_line0 = math.max(1, vim.api.nvim_buf_line_count(self.bufnr) - 1) + if end_row0 > buf_max_line0 then + end_row0 = buf_max_line0 + local last_line = vim.api.nvim_buf_get_lines(self.bufnr, end_row0, end_row0 + 1, false)[1] or '' + end_col0 = last_line:len() + end + if end_col0 == 0 then + end_row0 = end_row0 - 1 + local last_line = vim.api.nvim_buf_get_lines(self.bufnr, end_row0, end_row0 + 1, false)[1] or '' + end_col0 = last_line:len() + end + if start_row0 == end_row0 and start_col0 == end_col0 then on_change '' else - local lines = vim.fn.getregion( - { self.bufnr, start_row0 + 1, start_col0 + 1 }, - { self.bufnr, end_row0 + 1, end_col0 }, - { type = 'v' } - ) + local pos1 = { self.bufnr, start_row0 + 1, start_col0 + 1 } + local pos2 = { self.bufnr, end_row0 + 1, end_col0 } + local ok, lines = pcall(vim.fn.getregion, pos1, pos2, { type = 'v' }) + if not ok then + vim.print { pos1 = pos1, pos2 = pos2 } + error(lines) + end local text = table.concat(lines, '\n') on_change(text) end @@ -409,6 +423,65 @@ function Renderer:_on_text_changed() end end +function Renderer:_debug() + local prev_w = vim.api.nvim_get_current_win() + vim.cmd.vnew() + local info_bufnr = vim.api.nvim_get_current_buf() + vim.bo.bufhidden = 'delete' + vim.bo.buflisted = false + vim.bo.buftype = 'nowrite' + + local ids = {} + local function cleanup() + for _, id in ipairs(ids) do + vim.api.nvim_del_autocmd(id) + end + vim.api.nvim_buf_delete(info_bufnr, { force = true }) + end + + table.insert( + ids, + vim.api.nvim_create_autocmd({ 'CursorMoved', 'CursorMovedI' }, { + callback = function() + local l, c = unpack(vim.api.nvim_win_get_cursor(0)) + l = l - 1 -- make it actually 0-based + + local info = { + cursor = { + pos = { l, c }, + tags = self:get_pos_infos { l, c }, + }, + extmarks = self.curr.extmarks, + raw_intersecting = vim.api.nvim_buf_get_extmarks( + self.bufnr, + self.ns, + { l, c }, + { l, c }, + { details = true, overlap = true } + ), + } + vim.api.nvim_buf_set_lines(info_bufnr, 0, -1, true, vim.split(vim.inspect(info), '\n')) + end, + }) + ) + table.insert( + ids, + vim.api.nvim_create_autocmd('WinClosed', { + pattern = tostring(vim.api.nvim_get_current_win()), + callback = cleanup, + }) + ) + table.insert( + ids, + vim.api.nvim_create_autocmd('WinClosed', { + pattern = tostring(prev_w), + callback = cleanup, + }) + ) + + vim.api.nvim_set_current_win(prev_w) +end + --- Returns pairs of extmarks and tags associate with said extmarks. The --- returned tags/extmarks are sorted smallest (innermost) to largest --- (outermost). @@ -425,7 +498,7 @@ function Renderer:get_pos_infos(pos0, mode) -- {{{ -- edge, and one for it's right. We need to do our own intersection test, -- because the NeoVim API is over-inclusive in what it returns: --- @type u.renderer.RendererExtmark[] - local intersecting_extmarks = vim + local mapped_extmarks = vim .iter( vim.api.nvim_buf_get_extmarks( self.bufnr, @@ -446,26 +519,43 @@ function Renderer:get_pos_infos(pos0, mode) -- {{{ end return { id = id, start = start, stop = stop, opts = details } end) + :totable() + + local intersecting_extmarks = vim + .iter(mapped_extmarks) --- @param ext u.renderer.RendererExtmark :filter(function(ext) if ext.stop[1] ~= nil and ext.stop[2] ~= nil then -- If we've "ciw" and "collapsed" an extmark onto the cursor, -- the cursor pos will equal the exmark's start AND end. In this -- case, we want to include the extmark. - return ( + if cursor_line0 == ext.start[1] and cursor_col0 == ext.start[2] and cursor_line0 == ext.stop[1] and cursor_col0 == ext.stop[2] - ) - or ( - cursor_line0 >= ext.start[1] - and cursor_col0 >= ext.start[2] + then + return true + end + + return + -- START: line check + cursor_line0 >= ext.start[1] + -- START: column check + and (cursor_line0 ~= ext.start[1] or cursor_col0 >= ext.start[2]) + -- STOP: line check and cursor_line0 <= ext.stop[1] - -- In insert mode, the cursor is "thin", so <= to compensate: - -- In normal mode, the cursor is "wide", so < to compensate: - and (mode == 'i' and cursor_col0 <= ext.stop[2] or cursor_col0 < ext.stop[2]) - ) + -- STOP: column check + and ( + cursor_line0 ~= ext.stop[1] + or ( + mode == 'i' + -- In insert mode, the cursor is "thin", so <= to compensate: + and cursor_col0 <= ext.stop[2] + -- In normal mode, the cursor is "wide", so < to compensate: + or cursor_col0 < ext.stop[2] + ) + ) else return true end diff --git a/shell.nix b/shell.nix index e47a675..d172772 100644 --- a/shell.nix +++ b/shell.nix @@ -1,10 +1,10 @@ { pkgs ? import - # nixpkgs-unstable (neovim@0.11.2): + # nixos-25.05 (neovim@0.11.2): (fetchTarball { - url = "https://github.com/nixos/nixpkgs/archive/f72be405a10668b8b00937b452f2145244103ebc.tar.gz"; - sha256 = "0m1vnvngpxrawsgg306c9sdhbzsiigjgb03yfbdpa2fsb1fs0zm9"; + url = "https://github.com/nixos/nixpkgs/archive/32a4e87942101f1c9f9865e04dc3ddb175f5f32e.tar.gz"; + sha256 = "1jvflnbrxa8gjxkwjq6kdpdzgwp0hs59h9l3xjasksv0v7xlwykz"; }) { }, }: diff --git a/spec/range_spec.lua b/spec/range_spec.lua index e60f8a1..cbe7754 100644 --- a/spec/range_spec.lua +++ b/spec/range_spec.lua @@ -738,4 +738,36 @@ describe('Range', function() assert.are.same({ 3, 0 }, { details.end_row, details.end_col }) end) end) + + it('discerns range bounds from extmarks beyond the end of the buffer', function() + local Buffer = require 'u.buffer' + + vim.cmd.vnew() + local left = Buffer.current() + left:set_tmp_options() + vim.cmd.vnew() + local right = Buffer.current() + right:set_tmp_options() + + left:all():replace { + 'one', + 'two', + 'three', + } + local left_all_ext = left:all():save_to_extmark() + + right:all():replace { + 'foo', + 'bar', + } + + vim.api.nvim_set_current_buf(right.bufnr) + vim.cmd [[normal! ggyG]] + vim.api.nvim_set_current_buf(left.bufnr) + vim.cmd [[normal! ggVGp]] + + assert.are.same({ 'foo', 'bar' }, left_all_ext:range():lines()) + vim.api.nvim_buf_delete(left.bufnr, { force = true }) + vim.api.nvim_buf_delete(right.bufnr, { force = true }) + end) end) diff --git a/spec/renderer_spec.lua b/spec/renderer_spec.lua index dee5da6..59c5ed6 100644 --- a/spec/renderer_spec.lua +++ b/spec/renderer_spec.lua @@ -148,4 +148,52 @@ describe('Renderer', function() assert.are.same(pos_infos[2].tag.attributes.hl, 'HighlightGroup1') end) end) + + it('repeated patch_lines calls should not change the buffer content', function() + local lines = { + [[{ {]], + [[ bounds = {]], + [[ start1 = { 1, 1 },]], + [[ stop1 = { 4, 1 }]], + [[ },]], + [[ end_right_gravity = true,]], + [[ id = 1,]], + [[ ns_id = 623,]], + [[ ns_name = "my.renderer:91",]], + [[ right_gravity = false]], + [[ } }]], + [[]], + } + withbuf(lines, function() + local Buffer = require 'u.buffer' + R.Renderer.patch_lines(0, nil, lines) + assert.are.same(Buffer.current():all():lines(), lines) + + R.Renderer.patch_lines(0, lines, lines) + assert.are.same(Buffer.current():all():lines(), lines) + + R.Renderer.patch_lines(0, lines, lines) + assert.are.same(Buffer.current():all():lines(), lines) + end) + end) + + it('should fire text-changed events', function() + withbuf({}, function() + local r = R.Renderer.new(0) + local captured_changed_text = '' + r:render { + R.h('text', { + on_change = function(txt) captured_changed_text = txt end, + }, { + 'one\n', + 'two\n', + 'three\n', + }), + } + + vim.fn.setreg('"', 'bleh') + vim.cmd [[normal! ggVGp]] + assert.are.same(captured_changed_text, 'bleh') + end) + end) end)