From ef592b5c4c107ae99f5d71946da15fb1ed9bcf72 Mon Sep 17 00:00:00 2001 From: Kyle Chui <48545987+kylechui@users.noreply.github.com> Date: Sat, 8 Jun 2024 14:29:15 -0700 Subject: [PATCH] feat: Improve `move_cursor`. (#334) Adds "sticky" option for `move_cursor`, making the cursor "stick" to the text as the buffer gets modified. --- doc/nvim-surround.txt | 21 ++- lua/nvim-surround/annotations.lua | 4 +- lua/nvim-surround/buffer.lua | 39 ++++- lua/nvim-surround/init.lua | 14 ++ tests/configuration_spec.lua | 239 +++++++++++++++++++++++++++++- 5 files changed, 298 insertions(+), 19 deletions(-) diff --git a/doc/nvim-surround.txt b/doc/nvim-surround.txt index d894b83..8f4e246 100644 --- a/doc/nvim-surround.txt +++ b/doc/nvim-surround.txt @@ -613,16 +613,29 @@ configured separately. The default highlight group used is `Visual`: -------------------------------------------------------------------------------- 3.6. Cursor *nvim-surround.config.move_cursor* -By default, when a surround action is performed, the cursor moves to the -beginning of the action. +By default (or when `move_cursor = "begin"`), when a surround action is +performed, the cursor moves to the beginning of the action. Old text Command New text ~ some_t*ext ysiw[ *[ some_text ] another { sample *} ds{ another *sample (hello* world) csbB *{hello world} -This behavior can be disabled by setting `move_cursor = false` in one of the -setup functions. +If `move_cursor` is set to `"sticky"`, the cursor will "stick" to the current +character, and move with the text as the buffer changes. + + Old text Command New text ~ + some_t*ext ysiw[ [ some_t*ext ] + another { sample *} ds{ another sampl*e + (hello* world) csbffoo foo(hello* world) + +If `move_cursor` is set to `false`, the cursor won't move at all, regardless +of how the buffer changes. + + Old text Command New text ~ + some_t*ext ysiw[ [ some_*text ] + another { *sample } ds{ another sa*mple + (hello* world) csbffoo foo(he*llo world) -------------------------------------------------------------------------------- 3.7. Indentation *nvim-surround.config.indent_lines* diff --git a/lua/nvim-surround/annotations.lua b/lua/nvim-surround/annotations.lua index b8172c0..f8a0de0 100644 --- a/lua/nvim-surround/annotations.lua +++ b/lua/nvim-surround/annotations.lua @@ -35,7 +35,7 @@ ---@field surrounds table ---@field aliases table ---@field highlight { duration: integer } ----@field move_cursor false|"begin"|"end" +---@field move_cursor false|"begin"|"sticky" ---@field indent_lines function --[====================================================================================================================[ @@ -58,5 +58,5 @@ ---@field surrounds? table ---@field aliases? table ---@field highlight? { duration: false|integer } ----@field move_cursor? false|"begin"|"end" +---@field move_cursor? false|"begin"|"sticky" ---@field indent_lines? false|function diff --git a/lua/nvim-surround/buffer.lua b/lua/nvim-surround/buffer.lua index f591907..60df219 100644 --- a/lua/nvim-surround/buffer.lua +++ b/lua/nvim-surround/buffer.lua @@ -2,6 +2,11 @@ local config = require("nvim-surround.config") local M = {} +M.namespace = { + highlight = vim.api.nvim_create_namespace("nvim-surround-highlight"), + extmark = vim.api.nvim_create_namespace("nvim-surround-extmark"), +} + --[====================================================================================================================[ Cursor helper functions --]====================================================================================================================] @@ -24,11 +29,12 @@ M.set_curpos = function(pos) end -- Move the cursor to a location in the buffer, depending on the `move_cursor` setting. ----@param pos { first_pos: position, old_pos: position } Various positions in the buffer. +---@param pos { first_pos: position, sticky_pos: position, old_pos: position } Various positions in the buffer. M.restore_curpos = function(pos) - -- TODO: Add a `last_pos` field for if `move_cursor` is set to "end" if config.get_opts().move_cursor == "begin" then M.set_curpos(pos.first_pos) + elseif config.get_opts().move_cursor == "sticky" then + M.set_curpos(pos.sticky_pos) elseif not config.get_opts().move_cursor then M.set_curpos(pos.old_pos) end @@ -117,6 +123,29 @@ M.set_operator_marks = function(motion) M.set_mark(">", visual_marks[2]) end +-- Gets extmark position for the current buffer. +---@param extmark integer The extmark ID number. +---@return position @The position of the extmark in the buffer. +---@nodiscard +M.get_extmark = function(extmark) + local pos = vim.api.nvim_buf_get_extmark_by_id(0, M.namespace.extmark, extmark, {}) + return { pos[1] + 1, pos[2] + 1 } +end + +-- Creates an extmark for the given position. +---@param pos position The position in the buffer. +---@return integer @The extmark ID. +---@nodiscard +M.set_extmark = function(pos) + return vim.api.nvim_buf_set_extmark(0, M.namespace.extmark, pos[1] - 1, pos[2] - 1, {}) +end + +-- Deletes an extmark from the buffer. +---@param extmark integer The extmark ID number. +M.del_extmark = function(extmark) + vim.api.nvim_buf_del_extmark(0, M.namespace.extmark, extmark) +end + --[====================================================================================================================[ Byte indexing helper functions --]====================================================================================================================] @@ -257,11 +286,10 @@ M.highlight_selection = function(selection) if not selection then return end - local namespace = vim.api.nvim_create_namespace("NvimSurround") vim.highlight.range( 0, - namespace, + M.namespace.highlight, "NvimSurroundHighlight", { selection.first_pos[1] - 1, selection.first_pos[2] - 1 }, { selection.last_pos[1] - 1, selection.last_pos[2] - 1 }, @@ -273,8 +301,7 @@ end -- Clears all nvim-surround highlights for the buffer. M.clear_highlights = function() - local namespace = vim.api.nvim_create_namespace("NvimSurround") - vim.api.nvim_buf_clear_namespace(0, namespace, 0, -1) + vim.api.nvim_buf_clear_namespace(0, M.namespace.highlight, 0, -1) -- Force the screen to clear the highlight immediately vim.cmd.redraw() end diff --git a/lua/nvim-surround/init.lua b/lua/nvim-surround/init.lua index 58bc466..70654b6 100644 --- a/lua/nvim-surround/init.lua +++ b/lua/nvim-surround/init.lua @@ -64,12 +64,16 @@ M.normal_surround = function(args) local first_pos = args.selection.first_pos local last_pos = { args.selection.last_pos[1], args.selection.last_pos[2] + 1 } + local sticky_mark = buffer.set_extmark(M.normal_curpos) buffer.insert_text(last_pos, args.delimiters[2]) buffer.insert_text(first_pos, args.delimiters[1]) + buffer.restore_curpos({ first_pos = first_pos, + sticky_pos = buffer.get_extmark(sticky_mark), old_pos = M.normal_curpos, }) + buffer.del_extmark(sticky_mark) if args.line_mode then config.get_opts().indent_lines(first_pos[1], last_pos[1] + #args.delimiters[1] + #args.delimiters[2] - 2) @@ -92,6 +96,7 @@ M.visual_surround = function(args) return end + local sticky_mark = buffer.set_extmark(args.curpos) if vim.fn.visualmode() == "\22" then -- Visual block mode case (add delimiters to every line) if vim.o.selection == "exclusive" then last_pos[2] = last_pos[2] - 1 @@ -144,8 +149,10 @@ M.visual_surround = function(args) config.get_opts().indent_lines(first_pos[1], last_pos[1] + #delimiters[1] + #delimiters[2] - 2) buffer.restore_curpos({ first_pos = first_pos, + sticky_pos = buffer.get_extmark(sticky_mark), old_pos = args.curpos, }) + buffer.del_extmark(sticky_mark) end -- Delete a surrounding delimiter pair, if it exists. @@ -165,17 +172,21 @@ M.delete_surround = function(args) local selections = utils.get_nearest_selections(args.del_char, "delete") if selections then + local sticky_mark = buffer.set_extmark(args.curpos) -- Delete the right selection first to ensure selection positions are correct buffer.delete_selection(selections.right) buffer.delete_selection(selections.left) + config.get_opts().indent_lines( selections.left.first_pos[1], selections.left.first_pos[1] + selections.right.first_pos[1] - selections.left.last_pos[1] ) buffer.restore_curpos({ first_pos = selections.left.first_pos, + sticky_pos = buffer.get_extmark(sticky_mark), old_pos = args.curpos, }) + buffer.del_extmark(sticky_mark) end cache.set_callback("v:lua.require'nvim-surround'.delete_callback") @@ -221,13 +232,16 @@ M.change_surround = function(args) selections.right.first_pos[2] = space_end + 1 end + local sticky_mark = buffer.set_extmark(args.curpos) -- Change the right selection first to ensure selection positions are correct buffer.change_selection(selections.right, delimiters[2]) buffer.change_selection(selections.left, delimiters[1]) buffer.restore_curpos({ first_pos = selections.left.first_pos, + sticky_pos = buffer.get_extmark(sticky_mark), old_pos = args.curpos, }) + buffer.del_extmark(sticky_mark) if args.line_mode then local first_pos = selections.left.first_pos diff --git a/tests/configuration_spec.lua b/tests/configuration_spec.lua index 1e5c261..31b256f 100644 --- a/tests/configuration_spec.lua +++ b/tests/configuration_spec.lua @@ -1,5 +1,6 @@ local cr = vim.api.nvim_replace_termcodes("", true, false, true) local esc = vim.api.nvim_replace_termcodes("", true, false, true) +local ctrl_v = vim.api.nvim_replace_termcodes("", true, false, true) local get_curpos = function() local curpos = vim.api.nvim_win_get_cursor(0) return { curpos[1], curpos[2] + 1 } @@ -7,12 +8,18 @@ end local set_curpos = function(pos) vim.api.nvim_win_set_cursor(0, { pos[1], pos[2] - 1 }) end +local check_curpos = function(pos) + assert.are.same({ pos[1], pos[2] - 1 }, vim.api.nvim_win_get_cursor(0)) +end local set_lines = function(lines) vim.api.nvim_buf_set_lines(0, 0, -1, false, lines) end local check_lines = function(lines) assert.are.same(lines, vim.api.nvim_buf_get_lines(0, 0, -1, false)) end +local get_extmarks = function() + return vim.api.nvim_buf_get_extmarks(0, require("nvim-surround.buffer").namespace.extmark, 0, -1, {}) +end describe("configuration", function() before_each(function() @@ -21,7 +28,7 @@ describe("configuration", function() end) it("can define own add mappings", function() - require("nvim-surround").setup({ + require("nvim-surround").buffer_setup({ surrounds = { ["1"] = { add = { "1", "1" } }, ["2"] = { add = { "2", { "2" } } }, @@ -75,7 +82,7 @@ describe("configuration", function() end) it("can define and use 'interpreted' multi-byte mappings", function() - require("nvim-surround").setup({ + require("nvim-surround").buffer_setup({ surrounds = { -- interpreted multi-byte [""] = { @@ -95,7 +102,7 @@ describe("configuration", function() end) it("default deletes using invalid_key_behavior for an 'interpreted' multi-byte mapping", function() - require("nvim-surround").setup({ + require("nvim-surround").buffer_setup({ surrounds = { -- interpreted multi-byte [""] = { @@ -114,7 +121,7 @@ describe("configuration", function() end) it("can disable surrounds", function() - require("nvim-surround").setup({ + require("nvim-surround").buffer_setup({ surrounds = { ["("] = false, }, @@ -130,7 +137,7 @@ describe("configuration", function() end) it("can change invalid_key_behavior", function() - require("nvim-surround").setup({ + require("nvim-surround").buffer_setup({ surrounds = { invalid_key_behavior = { add = function(char) @@ -150,7 +157,7 @@ describe("configuration", function() end) it("can disable indent_lines", function() - require("nvim-surround").setup({ + require("nvim-surround").buffer_setup({ indent_lines = false, }) @@ -166,7 +173,7 @@ describe("configuration", function() end) it("can disable invalid_key_behavior", function() - require("nvim-surround").setup({ + require("nvim-surround").buffer_setup({ surrounds = { invalid_key_behavior = false, }, @@ -270,6 +277,224 @@ describe("configuration", function() }) end) + it("can make the cursor 'stick' to the text (normal)", function() + require("nvim-surround").buffer_setup({ + move_cursor = "sticky", + surrounds = { + ["c"] = { add = { "singleline", "surr" } }, + ["d"] = { add = { { "multiline", "f" }, "" } }, + ["e"] = { add = { { "multiline", "f" }, { "", "shouldbethislength" } } }, + ["f"] = { add = { "singleline", { "", "multilinehere" } } }, + }, + }) + + -- Sticks to the text if the cursor is inside the selection + set_lines({ + "this is a line", + }) + set_curpos({ 1, 9 }) + vim.cmd("normal ysiwc") + check_curpos({ 1, 19 }) + + set_lines({ + "this is a line", + }) + set_curpos({ 1, 4 }) + vim.cmd("normal ysiwd") + check_curpos({ 2, 5 }) + + set_lines({ + "this is another line", + }) + set_curpos({ 1, 14 }) + vim.cmd("normal ysiwe") + check_curpos({ 2, 7 }) + + set_lines({ + "this is a line", + }) + set_curpos({ 1, 9 }) + vim.cmd("normal ysiwf") + check_curpos({ 1, 19 }) + + -- Doesn't move if the cursor is before the selection + set_lines({ + "this 'is' a line", + }) + set_curpos({ 1, 2 }) + vim.cmd("normal ysa'c") + vim.cmd("normal ysa'd") + vim.cmd("normal ysa'e") + vim.cmd("normal ysa'f") + check_curpos({ 1, 2 }) + + assert.are.same(get_extmarks(), {}) + end) + + it("can make the cursor 'stick' to the text (visual)", function() + require("nvim-surround").buffer_setup({ + move_cursor = "sticky", + }) + + set_lines({ + "this is a line", + }) + set_curpos({ 1, 9 }) + vim.cmd("normal vllS'") + check_curpos({ 1, 12 }) + + set_lines({ + "this is a line", + "with some more text", + }) + set_curpos({ 1, 6 }) + vim.cmd("normal vjeSb") + check_curpos({ 2, 9 }) + + set_lines({ + "this is a line", + "with some more text", + }) + set_curpos({ 1, 6 }) + vim.cmd("normal vjeoSb") + check_curpos({ 1, 7 }) + + assert.are.same(get_extmarks(), {}) + end) + + it("can make the cursor 'stick' to the text (visual line)", function() + require("nvim-surround").buffer_setup({ + move_cursor = "sticky", + }) + + set_lines({ + "this is a line", + }) + set_curpos({ 1, 9 }) + vim.cmd("normal VSb") + check_curpos({ 2, 9 }) + + set_lines({ + "this is a line", + "with some more text", + }) + set_curpos({ 1, 6 }) + vim.cmd("normal VjStdiv" .. cr) + check_curpos({ 3, 6 }) + + assert.are.same(get_extmarks(), {}) + end) + + it("can make the cursor 'stick' to the text (visual block)", function() + require("nvim-surround").buffer_setup({ + move_cursor = "sticky", + surrounds = { + ["x"] = { + add = { { "|", "" }, { "", "|" } }, + }, + }, + }) + + set_lines({ + "this is a line", + "this is another line", + }) + set_curpos({ 1, 5 }) + vim.cmd("normal! " .. ctrl_v .. "jf ") + vim.cmd("normal Sb") + check_curpos({ 2, 9 }) + + set_lines({ + "this is a line", + "this is another line", + "some more random text", + }) + set_curpos({ 1, 4 }) + vim.cmd("normal! " .. ctrl_v .. "jjww") + vim.cmd("normal Sx") + set_curpos({ 8, 8 }) + + assert.are.same(get_extmarks(), {}) + end) + + it("can make the cursor 'stick' to the text (delete)", function() + require("nvim-surround").buffer_setup({ + move_cursor = "sticky", + }) + + set_lines({ + "func_name(foobar)", + }) + set_curpos({ 1, 14 }) + vim.cmd("normal dsf") + check_curpos({ 1, 4 }) + + set_lines({ + "
", + " hello", + "
", + }) + set_curpos({ 2, 7 }) + vim.cmd("normal dst") + check_curpos({ 2, 7 }) + + set_lines({ + "hello 'world'", + }) + set_curpos({ 1, 2 }) + vim.cmd("normal dsq") + check_curpos({ 1, 2 }) + + set_lines({ + "func(hello) world", + }) + set_curpos({ 1, 14 }) + vim.cmd("normal dsf") + check_curpos({ 1, 8 }) + + assert.are.same(get_extmarks(), {}) + end) + + it("can make the cursor 'stick' to the text (change)", function() + require("nvim-surround").buffer_setup({ + move_cursor = "sticky", + }) + + set_lines({ + "func_name(foobar)", + }) + set_curpos({ 1, 14 }) + vim.cmd("normal csff" .. cr) + check_curpos({ 1, 6 }) + + set_lines({ + "
", + " hello", + "
", + }) + set_curpos({ 2, 7 }) + vim.cmd("normal csth1" .. cr) + check_curpos({ 2, 7 }) + vim.cmd("normal csTbutton" .. cr) + check_curpos({ 2, 7 }) + + set_lines({ + "hello 'world'", + }) + set_curpos({ 1, 2 }) + vim.cmd("normal csqffoobar" .. cr) + check_curpos({ 1, 2 }) + + set_lines({ + "
hello
world", + }) + set_curpos({ 1, 41 }) + vim.cmd("normal csTb" .. cr) + check_curpos({ 1, 15 }) + + assert.are.same(get_extmarks(), {}) + end) + it("can partially define surrounds", function() require("nvim-surround").buffer_setup({ surrounds = {