mirror of
https://github.com/ethersync/ethersync.git
synced 2025-08-02 04:07:29 +03:00
329 lines
13 KiB
Lua
329 lines
13 KiB
Lua
-- SPDX-FileCopyrightText: 2024 blinry <mail@blinry.org>
|
||
-- SPDX-FileCopyrightText: 2024 zormit <nt4u@kpvn.de>
|
||
--
|
||
-- SPDX-License-Identifier: AGPL-3.0-or-later
|
||
|
||
local M = {}
|
||
|
||
-- This variable is a table that maps user IDs to a list of cursors.
|
||
-- Each cursor has an URI, an LSP range, an optional name,
|
||
-- and, if we already created it, an optional extmark information,
|
||
-- (including the buffer number and the extmark id).
|
||
-- { user_id => {
|
||
-- name: string,
|
||
-- cursors: { uri, range, extmark: { bufnr: int, id: int}?}}
|
||
-- }
|
||
-- }
|
||
local user_cursors = {}
|
||
local cursor_namespace = vim.api.nvim_create_namespace("Ethersync")
|
||
local offset_encoding = "utf-32"
|
||
local cursor_timeout_ms = 300 * 1000
|
||
|
||
local function show_cursor_information(name, cursor)
|
||
return name .. " @ " .. vim.uri_to_fname(cursor.uri) .. ":" .. cursor.range.start.line + 1
|
||
end
|
||
|
||
-- https://www.reddit.com/r/neovim/comments/152bs5t/unable_to_render_comments_in_the_color_id_like/
|
||
vim.api.nvim_create_autocmd("ColorScheme", {
|
||
pattern = "*",
|
||
callback = function()
|
||
vim.api.nvim_set_hl(0, "EthersyncUsername", { fg = "#808080", ctermfg = 12 })
|
||
end,
|
||
})
|
||
vim.api.nvim_exec_autocmds("ColorScheme", {})
|
||
|
||
local function is_forward(start_row, end_row, start_col, end_col)
|
||
return (start_row < end_row) or (start_row == end_row and start_col <= end_col)
|
||
end
|
||
|
||
local function jump(location)
|
||
-- In Neovim 0.11, jump_to_location was deprecated.
|
||
if vim.version().api_level < 13 then
|
||
vim.lsp.util.jump_to_location(location, offset_encoding, true)
|
||
else
|
||
vim.lsp.util.show_document(location, offset_encoding)
|
||
end
|
||
end
|
||
|
||
-- A new set of ranges means, we delete all existing ones for that user.
|
||
function M.set_cursor(uri, user_id, name, ranges)
|
||
-- Find correct buffer to apply edits to.
|
||
local bufnr = vim.uri_to_bufnr(uri)
|
||
|
||
if user_cursors[user_id] then
|
||
for _, user_cursor in ipairs(user_cursors[user_id].cursors) do
|
||
if user_cursor.extmark then
|
||
local old_id = user_cursor.extmark.id
|
||
local old_bufnr = user_cursor.extmark.bufnr
|
||
vim.api.nvim_buf_del_extmark(old_bufnr, cursor_namespace, old_id)
|
||
end
|
||
end
|
||
end
|
||
user_cursors[user_id] = { name = name, cursors = {} }
|
||
|
||
if not vim.api.nvim_buf_is_loaded(bufnr) then
|
||
-- TODO: Should we also implement a timeout here?
|
||
for _, range in ipairs(ranges) do
|
||
table.insert(user_cursors[user_id].cursors, { uri = uri, range = range, extmark = nil })
|
||
end
|
||
return
|
||
end
|
||
|
||
for i, range in ipairs(ranges) do
|
||
-- Convert from LSP style ranges to Neovim style ranges.
|
||
local start_row = range.start.line
|
||
local start_col = vim.lsp.util._get_line_byte_from_position(bufnr, range.start, offset_encoding)
|
||
local end_row = range["end"].line
|
||
local end_col = vim.lsp.util._get_line_byte_from_position(bufnr, range["end"], offset_encoding)
|
||
|
||
-- If the range is backwards, swap the start and end positions.
|
||
if not is_forward(start_row, end_row, start_col, end_col) then
|
||
start_row, end_row = end_row, start_row
|
||
start_col, end_col = end_col, start_col
|
||
end
|
||
|
||
-- If the range is empty, expand the highlighted range by 1 to make it visible.
|
||
if start_row == end_row and start_col == end_col then
|
||
local bytes_in_end_row = vim.fn.strlen(vim.fn.getline(end_row + 1))
|
||
if bytes_in_end_row > end_col then
|
||
-- Note: Instead of 1, we should actually add the byte length of the character at this position.
|
||
end_col = end_col + 1
|
||
elseif bytes_in_end_row > 0 then
|
||
-- This highlights the last character in the row.
|
||
start_col = start_col - 1
|
||
end
|
||
end
|
||
|
||
local e = {
|
||
start_row = start_row,
|
||
start_col = start_col,
|
||
end_row = end_row,
|
||
end_col = end_col,
|
||
}
|
||
|
||
local virt_text = {}
|
||
if i == 1 and name then
|
||
virt_text = { { name, "EthersyncUsername" } }
|
||
end
|
||
|
||
-- Try setting the extmark, ignore errors (which can happen at end of lines/buffers).
|
||
pcall(function()
|
||
local extmark_id = vim.api.nvim_buf_set_extmark(bufnr, cursor_namespace, e.start_row, e.start_col, {
|
||
hl_mode = "combine",
|
||
hl_group = "TermCursor",
|
||
end_col = e.end_col,
|
||
end_row = e.end_row,
|
||
virt_text = virt_text,
|
||
})
|
||
vim.defer_fn(function()
|
||
vim.api.nvim_buf_del_extmark(bufnr, cursor_namespace, extmark_id)
|
||
for _, user_cursor in ipairs(user_cursors[user_id].cursors) do
|
||
if user_cursor.extmark ~= nil and user_cursor.extmark.id == extmark_id then
|
||
-- If we find our own extmark_id, we can remove all ranges,
|
||
-- because they were all created at the same time.
|
||
user_cursors[user_id].cursors = {}
|
||
break
|
||
end
|
||
end
|
||
end, cursor_timeout_ms)
|
||
|
||
table.insert(
|
||
user_cursors[user_id].cursors,
|
||
{ uri = uri, range = range, extmark = { id = extmark_id, bufnr = bufnr } }
|
||
)
|
||
end)
|
||
end
|
||
end
|
||
|
||
function M.track_cursor(bufnr, callback)
|
||
vim.api.nvim_create_autocmd({ "CursorMoved", "CursorMovedI", "ModeChanged" }, {
|
||
buffer = bufnr,
|
||
callback = function()
|
||
local ranges = {}
|
||
|
||
-- TODO: Split this code into multiple functions.
|
||
local visualSelection = vim.fn.mode() == "v" or vim.fn.mode() == "V" or vim.fn.mode() == ""
|
||
if visualSelection then
|
||
-- This is the "active end" that the protocol talks about.
|
||
local end_row, end_col = unpack(vim.api.nvim_win_get_cursor(0))
|
||
-- Whereas this corresponds the the "anchor" in other range data structures.
|
||
local _, start_row, start_col = unpack(vim.fn.getpos("v"))
|
||
|
||
-- When using getpos(), the column is 1-indexed, but we want it to be 0-indexed.
|
||
start_col = start_col - 1
|
||
|
||
if vim.fn.mode() == "v" then
|
||
-- TODO: This is not in "screen columns", but in "bytes"!
|
||
if not is_forward(start_row, end_row, start_col, end_col) then
|
||
-- If the selection is backwards, we need to extend it on both ends for some reason.
|
||
end_col = end_col - 1
|
||
start_col = start_col + 1
|
||
end
|
||
elseif vim.fn.mode() == "V" then
|
||
-- If we're in linewise visual mode, expand the range to include the entire line(s).
|
||
if is_forward(start_row, end_row, start_col, end_col) then
|
||
start_col = 0
|
||
end_col = vim.fn.strlen(vim.fn.getline(end_row)) - 1
|
||
else
|
||
start_col = vim.fn.strlen(vim.fn.getline(start_row))
|
||
end_col = -1
|
||
end
|
||
end
|
||
|
||
if vim.fn.mode() == "v" or vim.fn.mode() == "V" then
|
||
local range = vim.lsp.util.make_given_range_params(
|
||
{ start_row, start_col },
|
||
{ end_row, end_col },
|
||
bufnr,
|
||
offset_encoding
|
||
).range
|
||
ranges = { range }
|
||
elseif vim.fn.mode() == "" then
|
||
-- We are in blockwise visual mode. Calculate the individual pieces.
|
||
|
||
-- This calculation is a bit more involved, because Neovim forms a blockwise range visually, going by the
|
||
-- "display cells", so that the range is always rectangular. We need to perform our own calculations with
|
||
-- these display cells to make sure that we send out the same ranges.
|
||
|
||
-- TODO: There are still some inconsistencies; when the cursor is inside a multi-column character, other lines might be too short.
|
||
|
||
-- At this point, start_col and end_col are zero-indexed, in bytes, and related to the position in front of the cursor.
|
||
|
||
local start_line = vim.fn.getline(start_row)
|
||
local end_line = vim.fn.getline(end_row)
|
||
|
||
local string_to_start = string.sub(start_line, 0, start_col)
|
||
local string_to_end = string.sub(end_line, 0, end_col)
|
||
|
||
-- These are the widths of the strings in "display cells".
|
||
local cells_to_start = vim.fn.strdisplaywidth(string_to_start)
|
||
local cells_to_end = vim.fn.strdisplaywidth(string_to_end)
|
||
|
||
local smaller_cell = math.min(cells_to_start, cells_to_end)
|
||
local larger_cell = math.max(cells_to_start, cells_to_end)
|
||
|
||
local smaller_row = math.min(start_row, end_row)
|
||
local larger_row = math.max(start_row, end_row)
|
||
|
||
for row = smaller_row, larger_row do
|
||
local bytes_start = vim.fn.virtcol2col(bufnr, row, smaller_cell + 1) - 1
|
||
local bytes_end = vim.fn.virtcol2col(bufnr, row, larger_cell + 1) - 1
|
||
|
||
if bytes_start ~= -1 then
|
||
-- The line is not empty, add a range.
|
||
local range = vim.lsp.util.make_given_range_params(
|
||
{ row, bytes_start },
|
||
{ row, bytes_end },
|
||
bufnr,
|
||
offset_encoding
|
||
).range
|
||
table.insert(ranges, range)
|
||
end
|
||
end
|
||
end
|
||
else
|
||
local range = vim.lsp.util.make_range_params(0, offset_encoding).range
|
||
ranges = { range }
|
||
end
|
||
|
||
callback(ranges)
|
||
end,
|
||
})
|
||
-- Trigger the callback above once, to send initial cursor position.
|
||
vim.api.nvim_exec_autocmds("CursorMoved", {})
|
||
end
|
||
|
||
function M.jump_to_cursor()
|
||
local descriptions = {}
|
||
local locations = {}
|
||
local max_width = 10
|
||
for _, data in pairs(user_cursors) do
|
||
local _, cursor = next(data.cursors)
|
||
if cursor then
|
||
local description = show_cursor_information(data.name, cursor)
|
||
local desc_width = vim.fn.strdisplaywidth(description)
|
||
if desc_width > max_width then
|
||
max_width = desc_width
|
||
end
|
||
|
||
table.insert(descriptions, description)
|
||
|
||
table.insert(locations, {
|
||
targetUri = cursor.uri,
|
||
targetRange = cursor.range,
|
||
targetSelectionRange = cursor.range,
|
||
})
|
||
end
|
||
end
|
||
|
||
if #locations == 0 then
|
||
print("No cursors to jump to.")
|
||
return
|
||
elseif #locations == 1 then
|
||
-- Jump immediately.
|
||
jump(locations[1])
|
||
return
|
||
end
|
||
|
||
local buf = vim.api.nvim_create_buf(false, true)
|
||
vim.api.nvim_buf_set_lines(buf, 0, -1, true, descriptions)
|
||
vim.bo[buf].modifiable = false
|
||
|
||
local opts = {
|
||
relative = "cursor",
|
||
width = max_width,
|
||
height = #descriptions,
|
||
col = 0,
|
||
row = 1,
|
||
anchor = "NW",
|
||
style = "minimal",
|
||
border = "single",
|
||
}
|
||
local win = vim.api.nvim_open_win(buf, true, opts)
|
||
|
||
vim.api.nvim_buf_set_keymap(buf, "n", "<CR>", "", {
|
||
callback = function()
|
||
local line_number = vim.fn.line(".")
|
||
local location = locations[line_number]
|
||
vim.api.nvim_win_close(win, true)
|
||
jump(location)
|
||
end,
|
||
})
|
||
vim.api.nvim_buf_set_keymap(buf, "n", "<Esc>", "", {
|
||
callback = function()
|
||
vim.api.nvim_win_close(win, true)
|
||
end,
|
||
})
|
||
vim.api.nvim_create_autocmd("BufLeave", {
|
||
buffer = buf,
|
||
callback = function()
|
||
vim.api.nvim_win_close(win, true)
|
||
end,
|
||
})
|
||
end
|
||
|
||
function M.list_cursors()
|
||
local message = ""
|
||
|
||
if next(user_cursors) == nil then
|
||
message = "No cursors."
|
||
else
|
||
for _, data in pairs(user_cursors) do
|
||
local name = data.name
|
||
local cursors = data.cursors
|
||
message = message .. name .. ":"
|
||
if #cursors == 0 then
|
||
message = message .. " No cursors"
|
||
elseif #cursors == 1 then
|
||
message = show_cursor_information(name, cursors[1])
|
||
else
|
||
message = message .. " Multiple cursors in " .. vim.uri_to_fname(cursors[1].uri)
|
||
end
|
||
end
|
||
end
|
||
|
||
return message
|
||
end
|
||
|
||
return M
|