fix(cursor): keep the visual selection whole over characters outside the BMP (#2683)

* fix(cursor): keep the visual selection whole over characters outside the BMP

The inclusive Nvim end column was turned into an exclusive VSCode one by
adding a single UTF-16 unit. A character outside the BMP is two of them, so
the selection ended in the middle of a surrogate pair and drifted away from
where Nvim had the cursor: moving right over it took two presses and the
selected region no longer matched.

Step over the whole character instead, using its byte length.

Closes #2535

* test(cursor): add visual selection tests for non-BMP characters

* test(cursor): expect the astral pair to be whole in the mixed-character selection

---------

Co-authored-by: wongxy <xiyao.wong@foxmail.com>
This commit is contained in:
dualfrozandwongxy committed 2026-09-23 18:39:53 +08:00
1 parent aacc554f30
commit 5cc37c9f0a
2 files changed
+65 -2

No files matched your search

+14 -2
View File
@@ -63,6 +63,17 @@ function M.utf16_col(text, byte_col)
return byte_col == 0 and 0 or str_utf16index(text, byte_col)
end
---@param text string the line the column belongs to
---@param byte_col integer zero-indexed byte column, clamped to `text`
---@return integer zero-indexed byte column just past the character byte_col points into
local function byte_after_char(text, byte_col)
byte_col = math.max(0, math.min(byte_col, #text))
if byte_col == #text then
return byte_col
end
return byte_col + vim.str_utf_end(text, byte_col + 1) + 1
end
---@param buf integer
---@param line integer one-indexed line
---@param byte_col integer zero-indexed byte column
@@ -84,10 +95,11 @@ end
---@return lsp.Range
function M.lsp_range(buf, start_line, start_col, end_line, end_col)
local end_text = M.get_line(buf, end_line - 1) or ""
local end_char = M.utf16_col(end_text, end_col)
if vim.o.selection ~= "exclusive" then
end_char = math.min(end_char + 1, M.utf16_col(end_text, #end_text))
-- step over the whole character: adding one UTF-16 unit splits a surrogate pair
end_col = byte_after_char(end_text, end_col)
end
local end_char = M.utf16_col(end_text, end_col)
return {
start = M.lsp_position(buf, start_line, start_col),
["end"] = { line = end_line - 1, character = end_char },
+51
View File
@@ -1,4 +1,5 @@
import { NeovimClient } from "neovim";
import vscode from "vscode";
import {
attachTestNvimClient,
@@ -286,4 +287,54 @@ describe("Multi-width characters", () => {
client,
);
});
it("Works - visual mode over chars outside the BMP", async () => {
// 𝕜 is U+1D55C: one Nvim character, but two UTF-16 units for VSCode
await openTextDocument({ content: ["a𝕜b"].join("\n") });
await assertContent({ content: ["a𝕜b"], cursor: [0, 0] }, client);
await sendVSCodeKeys("v");
await assertContent({ vsCodeSelections: [new vscode.Selection(0, 0, 0, 1)] }, client);
await sendVSCodeKeys("l");
await assertContent({ vsCodeSelections: [new vscode.Selection(0, 0, 0, 3)] }, client);
await sendVSCodeKeys("l");
await assertContent({ vsCodeSelections: [new vscode.Selection(0, 0, 0, 4)] }, client);
await sendEscapeKey();
});
it("Works - visual mode over consecutive non-BMP chars", async () => {
await openTextDocument({ content: ["a𝕜𝕝b"].join("\n") });
await sendVSCodeKeys("v");
await assertContent({ vsCodeSelections: [new vscode.Selection(0, 0, 0, 1)] }, client);
await sendVSCodeKeys("ll");
await assertContent({ vsCodeSelections: [new vscode.Selection(0, 0, 0, 5)] }, client);
await sendVSCodeKeys("l");
await assertContent({ vsCodeSelections: [new vscode.Selection(0, 0, 0, 6)] }, client);
await sendEscapeKey();
});
it("Works - visual mode across mixed BMP and non-BMP chars", async () => {
await openTextDocument({ content: ["aᵩ𝕜b"].join("\n") });
await sendVSCodeKeys("v");
await assertContent({ vsCodeSelections: [new vscode.Selection(0, 0, 0, 1)] }, client);
// the cursor sits on the astral char, whose two UTF-16 units both belong to
// the selection: a, the modifier letter and the pair take offsets 0 to 4
await sendVSCodeKeys("ll");
await assertContent({ vsCodeSelections: [new vscode.Selection(0, 0, 0, 4)] }, client);
await sendVSCodeKeys("l");
await assertContent({ vsCodeSelections: [new vscode.Selection(0, 0, 0, 5)] }, client);
await sendEscapeKey();
});
});