feat(image): added support for remote image viewing. Closes #1156

This commit is contained in:
Folke Lemaitre 2025-02-14 10:50:51 +01:00
parent 7014b91b92
commit 2ed5fc36cc
No known key found for this signature in database
GPG key ID: 41F8B1FBACAE2040

View file

@ -1,19 +1,5 @@
---@class snacks.image
---@field id number
---@field ns number
---@field buf number
---@field opts snacks.image.Opts
---@field file string
---@field src string
---@field augroup number
---@field closed? boolean
---@field _loc? snacks.image.Loc
---@field _state? snacks.image.State
---@field _convert uv.uv_process_t?
---@field inline? boolean render the image inline in the buffer
---@field extmark_id? number
local M = {}
M.__index = M
M.meta = {
desc = "Image viewer using Kitty Graphics Protocol, supported by `kitty`, `wezterm` and `ghostty`",
@ -32,8 +18,10 @@ M.meta = {
---@field setup? fun(): boolean?
---@field transform? fun(data: string): string
---@field detected? boolean
---@field remote? boolean this is a remote client, so full transfer of the image data is required
---@class snacks.image.Config
---@field enabled? boolean enable image viewer
---@field wo? vim.wo|{} options for windows showing the image
---@field bo? vim.bo|{} options for the image buffer
---@field formats? string[]
@ -65,6 +53,7 @@ local defaults = {
statuscolumn = "",
},
debug = false,
env = {},
}
local config = Snacks.config.get("image", defaults)
@ -93,7 +82,13 @@ local environments = {
},
{
name = "wezterm",
env = { TERM = "wezterm", WEZTERM_PANE = true, WEZTERM_EXECUTABLE = true, WEZTERM_CONFIG_FILE = true },
env = {
TERM = "wezterm",
WEZTERM_PANE = true,
WEZTERM_EXECUTABLE = true,
WEZTERM_CONFIG_FILE = true,
SNACKS_WEZTERM = true,
},
supported = true,
placeholders = false,
},
@ -101,40 +96,201 @@ local environments = {
name = "tmux",
env = { TERM = "tmux", TMUX = true },
setup = function()
local ok, out = pcall(vim.fn.system, { "tmux", "set", "-p", "allow-passthrough", "on" })
if not ok or vim.v.shell_error ~= 0 then
Snacks.notify.error(
{ "Failed to enable `allow-passthrough` for `tmux`:", out },
{ title = "Image", once = true }
)
return false
end
pcall(vim.fn.system, { "tmux", "set", "-p", "allow-passthrough", "on" })
end,
transform = function(data)
return ("\027Ptmux;" .. data:gsub("\027", "\027\027")) .. "\027\\"
end,
},
{ name = "zellij", env = { TERM = "zellij", ZELLIJ = true }, supported = false, placeholders = false },
{ name = "ssh", env = { SSH_CLIENT = true, SSH_CONNECTION = true }, remote = true },
}
M._env = nil ---@type snacks.image.Env?
local NVIM_ID_BITS = 10
local PLACEHOLDER = vim.fn.nr2char(0x10EEEE)
local CHUNK_SIZE = 4096
-- stylua: ignore
local diacritics = vim.split( "0305,030D,030E,0310,0312,033D,033E,033F,0346,034A,034B,034C,0350,0351,0352,0357,035B,0363,0364,0365,0366,0367,0368,0369,036A,036B,036C,036D,036E,036F,0483,0484,0485,0486,0487,0592,0593,0594,0595,0597,0598,0599,059C,059D,059E,059F,05A0,05A1,05A8,05A9,05AB,05AC,05AF,05C4,0610,0611,0612,0613,0614,0615,0616,0617,0657,0658,0659,065A,065B,065D,065E,06D6,06D7,06D8,06D9,06DA,06DB,06DC,06DF,06E0,06E1,06E2,06E4,06E7,06E8,06EB,06EC,0730,0732,0733,0735,0736,073A,073D,073F,0740,0741,0743,0745,0747,0749,074A,07EB,07EC,07ED,07EE,07EF,07F0,07F1,07F3,0816,0817,0818,0819,081B,081C,081D,081E,081F,0820,0821,0822,0823,0825,0826,0827,0829,082A,082B,082C,082D,0951,0953,0954,0F82,0F83,0F86,0F87,135D,135E,135F,17DD,193A,1A17,1A75,1A76,1A77,1A78,1A79,1A7A,1A7B,1A7C,1B6B,1B6D,1B6E,1B6F,1B70,1B71,1B72,1B73,1CD0,1CD1,1CD2,1CDA,1CDB,1CE0,1DC0,1DC1,1DC3,1DC4,1DC5,1DC6,1DC7,1DC8,1DC9,1DCB,1DCC,1DD1,1DD2,1DD3,1DD4,1DD5,1DD6,1DD7,1DD8,1DD9,1DDA,1DDB,1DDC,1DDD,1DDE,1DDF,1DE0,1DE1,1DE2,1DE3,1DE4,1DE5,1DE6,1DFE,20D0,20D1,20D4,20D5,20D6,20D7,20DB,20DC,20E1,20E7,20E9,20F0,2CEF,2CF0,2CF1,2DE0,2DE1,2DE2,2DE3,2DE4,2DE5,2DE6,2DE7,2DE8,2DE9,2DEA,2DEB,2DEC,2DED,2DEE,2DEF,2DF0,2DF1,2DF2,2DF3,2DF4,2DF5,2DF6,2DF7,2DF8,2DF9,2DFA,2DFB,2DFC,2DFD,2DFE,2DFF,A66F,A67C,A67D,A6F0,A6F1,A8E0,A8E1,A8E2,A8E3,A8E4,A8E5,A8E6,A8E7,A8E8,A8E9,A8EA,A8EB,A8EC,A8ED,A8EE,A8EF,A8F0,A8F1,AAB0,AAB2,AAB3,AAB7,AAB8,AABE,AABF,AAC1,FE20,FE21,FE22,FE23,FE24,FE25,FE26,10A0F,10A38,1D185,1D186,1D187,1D188,1D189,1D1AA,1D1AB,1D1AC,1D1AD,1D242,1D243,1D244", ",")
local did_setup = false
local dims = {} ---@type table<string, snacks.image.Size>
local id = 30
local _id = 30
local _pid = 0
local nvim_id = 0
local uv = vim.uv or vim.loop
local images = {} ---@type table<string, snacks.Image>
---@type table<number, string>
local positions = setmetatable({}, {
__index = function(t, k)
t[k] = vim.fn.nr2char(tonumber(diacritics[k], 16))
return t[k]
local positions = {}
setmetatable(positions, {
__index = function(_, k)
positions[k] = vim.fn.nr2char(tonumber(diacritics[k], 16))
return positions[k]
end,
})
---@class snacks.Image
---@field src string
---@field file string
---@field id number image id. unique per nvim instance and file
---@field sent? boolean image data is sent
---@field placements table<number, snacks.image.Placement> image placements
---@field _convert uv.uv_process_t?
local Image = {}
Image.__index = Image
---@param src string
function Image.new(src)
local self = setmetatable({}, Image)
self.src = src
self.file = self:convert()
if images[self.file] then
return images[self.file]
end
images[self.file] = self
_id = _id + 1
local bit = require("bit")
-- generate a unique id for this nvim instance (10 bits)
if nvim_id == 0 then
local pid = vim.fn.getpid()
nvim_id = bit.band(bit.bxor(pid, bit.rshift(pid, 5), bit.rshift(pid, NVIM_ID_BITS)), 0x3FF)
end
-- interleave the nvim id and the image id
self.id = bit.bor(bit.lshift(nvim_id, 24 - NVIM_ID_BITS), _id)
self.placements = {}
if self:ready() then
vim.schedule(function()
self:on_ready()
end)
end
vim.api.nvim_create_autocmd({ "ExitPre" }, {
group = vim.api.nvim_create_augroup("snacks.image." .. self.id, { clear = true }),
once = true,
callback = function()
self:del()
end,
})
return self
end
function Image:on_ready()
self:send()
end
function Image:on_send()
for _, placement in pairs(self.placements) do
placement:update()
end
end
function Image:ready()
return (not self._convert or self._convert:is_closing()) and (self.file and vim.fn.filereadable(self.file) == 1)
end
function Image:convert()
if self.src:find("^file://") then
self.src = vim.uri_to_fname(self.src)
end
-- convert urls and non-png files to png
if not self.src:find("^https?://") and self.src:find("%.png$") then
return self.src
end
if not self.src:find("^%w%w+://") then
self.src = vim.fs.normalize(self.src)
end
local fin = self.src .. "[0]"
local root = vim.fn.stdpath("cache") .. "/snacks/image"
vim.fn.mkdir(root, "p")
self.src = root .. "/" .. Snacks.util.file_encode(fin) .. ".png"
if vim.fn.filereadable(self.src) == 1 then
return self.src
end
local opts = { args = { fin, self.src } }
self._convert = uv.spawn("magick", opts, function(code)
self._convert:close()
if code ~= 0 then
return -- silently fail
end
vim.schedule(function()
self:on_ready()
end)
end)
return self.src
end
-- create the image
function Image:send()
assert(not self.sent, "Image already sent")
self.sent = true
-- local image
if not M.env().remote then
M.request({
t = "f",
i = self.id,
f = 100,
data = Snacks.util.base64(self.file),
})
else
-- remote image
local fd = assert(io.open(self.file, "rb"), "Failed to open file: " .. self.file)
local data = fd:read("*a")
fd:close()
data = Snacks.util.base64(data) -- encode the data
local offset = 1
while offset <= #data do
local chunk = data:sub(offset, offset + CHUNK_SIZE - 1)
local first = offset == 1
offset = offset + CHUNK_SIZE
local last = offset > #data
if first then
M.request({
t = "d",
i = self.id,
f = 100,
m = last and 0 or 1,
data = chunk,
})
else
M.request({
m = last and 0 or 1,
data = chunk,
})
end
uv.sleep(1)
end
end
self:on_send()
end
---@param placement snacks.image.Placement
function Image:place(placement)
for pid, p in pairs(self.placements) do
if p == placement then
placement.id = pid
return pid
end
end
_pid = _pid + 1
placement.id = _pid
self.placements[_pid] = placement
end
---@param pid? number
function Image:del(pid)
for id, p in ipairs(pid and { pid } or vim.tbl_keys(self.placements)) do
if self.placements[p] then
M.request({ a = "d", d = "i", i = self.id, p = id })
self.placements[p] = nil
end
end
if not next(self.placements) then
M.request({ a = "d", d = "i", i = self.id })
self.sent = false
end
end
function M.env()
if M._env then
return M._env
@ -144,11 +300,16 @@ function M.env()
env = {},
}
for _, e in ipairs(environments) do
for k, v in pairs(e.env) do
local val = os.getenv(k)
if val and (v == true or val:find(v)) then
e.detected = true
break
local override = os.getenv("SNACKS_" .. e.name:upper())
if override then
e.detected = override ~= "0" and override ~= "false"
else
for k, v in pairs(e.env) do
local val = os.getenv(k)
if val and (v == true or val:find(v)) then
e.detected = true
break
end
end
end
if e.detected then
@ -159,16 +320,13 @@ function M.env()
if e.placeholders ~= nil then
M._env.placeholders = e.placeholders
end
M._env.transform = e.transform
M._env.transform = e.transform or M._env.transform
M._env.remote = e.remote or M._env.remote
if e.setup then
e.setup()
end
end
end
if M._env.supported then
-- delete all images on startup
M.request({ a = "d", d = "a" })
end
M._env.name = M._env.name:gsub("^/", "")
return M._env
end
@ -177,30 +335,31 @@ local function minmax(value, min, max)
return math.max(min or 1, math.min(value, max or value))
end
local function nextid()
id = id + 1
local bit = require("bit")
-- generate a unique id for this nvim instance (10 bits)
if nvim_id == 0 then
local pid = vim.fn.getpid()
nvim_id = bit.band(bit.bxor(pid, bit.rshift(pid, 5), bit.rshift(pid, NVIM_ID_BITS)), 0x3FF)
end
-- interleave the nvim id and the image id
return bit.bor(bit.lshift(nvim_id, 24 - NVIM_ID_BITS), id)
end
---@class snacks.image.Placement
---@field img snacks.Image
---@field id number image placement id
---@field ns number
---@field buf number
---@field opts snacks.image.Opts
---@field augroup number
---@field closed? boolean
---@field inline? boolean render the image inline in the buffer
---@field extmark_id? number
---@field _loc? snacks.image.Loc
---@field _state? snacks.image.State
local Placement = {}
Placement.__index = Placement
---@param buf number
---@param opts? snacks.image.Opts
function M.new(buf, src, opts)
function Placement.new(buf, src, opts)
assert(type(buf) == "number", "`Image.new`: buf should be a number")
assert(type(src) == "string", "`Image.new`: src should be a string")
M.setup() -- always setup so that images/videos can be opened
local self = setmetatable({}, M)
local self = setmetatable({}, Placement)
-- convert to PNG if needed
self.src = src
self.file = self:convert(src)
self.id = nextid()
self.img = Image.new(src)
self.img:place(self)
self.opts = opts or {}
self.buf = buf
self.inline = true
@ -215,7 +374,7 @@ function M.new(buf, src, opts)
buffer = self.buf,
callback = function()
vim.schedule(function()
-- self:update()
self:update()
end)
end,
})
@ -223,7 +382,7 @@ function M.new(buf, src, opts)
group = self.augroup,
callback = function()
vim.schedule(function()
-- self:update()
self:update()
end)
end,
})
@ -239,22 +398,13 @@ function M.new(buf, src, opts)
end,
})
vim.api.nvim_create_autocmd({ "ExitPre" }, {
group = self.augroup,
once = true,
callback = function()
self:close()
end,
})
local update = self.update
if self:ready() then
vim.schedule(function()
self:create()
self:update()
end)
end
local update = self.update
self.update = Snacks.util.debounce(function()
update(self)
end, { ms = 10 })
@ -262,13 +412,14 @@ function M.new(buf, src, opts)
end
---@return number[]
function M:wins()
function Placement:wins()
---@param win number
return vim.tbl_filter(function(win)
return vim.api.nvim_win_get_buf(win) == self.buf
end, vim.api.nvim_tabpage_list_wins(0))
end
function M:close()
function Placement:close()
if self.closed then
return
end
@ -280,9 +431,9 @@ end
--- Renders the unicode placeholder grid in the buffer
---@param loc snacks.image.Loc
function M:render_grid(loc)
function Placement:render_grid(loc)
local hl = "SnacksImage" .. self.id -- image id is encoded in the foreground color
vim.api.nvim_set_hl(0, hl, { fg = self.id })
vim.api.nvim_set_hl(0, hl, { fg = self.img.id, sp = self.id })
local lines = {} ---@type string[]
for r = 1, loc.height do
local line = {} ---@type string[]
@ -299,6 +450,7 @@ function M:render_grid(loc)
vim.api.nvim_buf_clear_namespace(self.buf, self.ns, 0, -1)
self.extmark_id = vim.api.nvim_buf_set_extmark(self.buf, self.ns, loc[1] - 1, loc[2], {
id = self.extmark_id,
---@param l string
virt_lines = vim.tbl_map(function(l)
return { { l, hl } }
end, lines),
@ -319,31 +471,24 @@ function M:render_grid(loc)
end
end
function M:hide()
function Placement:hide()
if vim.api.nvim_buf_is_valid(self.buf) then
vim.api.nvim_buf_clear_namespace(self.buf, self.ns, 0, -1)
end
self.request({ a = "d", d = "i", i = self.id })
end
---@param pos {[1]: number, [2]: number}
function M:set_cursor(pos)
io.stdout:write("\27[" .. pos[1] .. ";" .. (pos[2] + 1) .. "H")
self.img:del(self.id)
end
---@param state snacks.image.State
function M:render_fallback(state)
self:hide()
self:create()
function Placement:render_fallback(state)
for _, win in ipairs(state.wins) do
self:debug("render_fallback", win)
local border = setmetatable({ opts = vim.api.nvim_win_get_config(win) }, { __index = Snacks.win }):border_size()
local pos = vim.api.nvim_win_get_position(win)
self:set_cursor({ pos[1] + 1 + border.top, pos[2] + border.left })
self.request({
M.set_cursor({ pos[1] + 1 + border.top, pos[2] + border.left })
M.request({
a = "p",
i = self.id,
p = win,
i = self.img.id,
p = self.id,
C = 1,
c = state.loc.width,
r = state.loc.height,
@ -351,14 +496,14 @@ function M:render_fallback(state)
end
end
function M:debug(...)
function Placement:debug(...)
if not config.debug then
return
end
Snacks.debug.inspect({ ... }, self.src, self.id)
Snacks.debug.inspect({ ... }, self.img.src, self.img.id, self.id)
end
function M:state()
function Placement:state()
local width, height = vim.o.columns, vim.o.lines
local wins = {} ---@type number[]
local is_fallback = not M.env().placeholders
@ -378,7 +523,7 @@ function M:state()
end
width = minmax(self.opts.width or width, self.opts.min_width, self.opts.max_width)
height = minmax(self.opts.height or height, self.opts.min_height, self.opts.max_height)
local w, h = M.dim(self.file)
local w, h = M.dim(self.img.file)
h = h * 0.5 -- adjust for cell height
local scale = math.min(width / w, height / h)
local c, r = math.floor(w * scale), math.floor(h * scale)
@ -392,7 +537,7 @@ function M:state()
}
end
function M:update()
function Placement:update()
if not self:ready() then
return
end
@ -407,6 +552,7 @@ function M:update()
self:hide()
return
end
self.img:place(self)
self:debug("update")
@ -417,10 +563,11 @@ function M:update()
end
if M.env().placeholders then
self.request({
M.request({
a = "p",
U = 1,
i = self.id,
i = self.img.id,
p = self.id,
C = 1,
c = state.loc.width,
r = state.loc.height,
@ -439,52 +586,8 @@ function M:update()
end
end
function M:ready()
return not self.closed
and self.buf
and vim.api.nvim_buf_is_valid(self.buf)
and (not self._convert or self._convert:is_closing())
and (self.file and vim.fn.filereadable(self.file) == 1)
end
-- create the image
function M:create()
self.request({
f = 100,
t = "f",
i = self.id,
data = self.file,
})
end
---@param file string
function M:convert(file)
if file:find("^file://") then
file = vim.uri_to_fname(file)
end
-- convert urls and non-png files to png
if not file:find("^https?://") and file:find("%.png$") then
return file
end
if not file:find("^%w%w+://") then
file = vim.fs.normalize(file)
end
local fin = file .. "[0]"
local root = vim.fn.stdpath("cache") .. "/snacks/image"
vim.fn.mkdir(root, "p")
file = root .. "/" .. Snacks.util.file_encode(fin) .. ".png"
if vim.fn.filereadable(file) == 1 then
return file
end
local opts = { args = { fin, file } }
self._convert = uv.spawn("magick", opts, function()
self._convert:close()
vim.schedule(function()
self:create()
self:update()
end)
end)
return file
function Placement:ready()
return not self.closed and self.buf and vim.api.nvim_buf_is_valid(self.buf) and self.img:ready()
end
---@param opts table<string, string|number>|{data?: string}
@ -499,16 +602,24 @@ function M.request(opts)
msg = { table.concat(msg, ",") }
if opts.data then
msg[#msg + 1] = ";"
msg[#msg + 1] = Snacks.util.base64(tostring(opts.data))
msg[#msg + 1] = tostring(opts.data)
end
local data = "\27_G" .. table.concat(msg) .. "\27\\"
local env = M.env()
if env.transform then
data = env.transform(data)
end
if config.debug and opts.m ~= 1 then
Snacks.debug.inspect(opts)
end
io.stdout:write(data)
end
---@param pos {[1]: number, [2]: number}
function M.set_cursor(pos)
io.stdout:write("\27[" .. pos[1] .. ";" .. (pos[2] + 1) .. "H")
end
--- Check if the file format is supported
---@param file string
function M.supports_file(file)
@ -608,7 +719,7 @@ function M.setup(ev)
end,
})
end
if config.markdown.enabled and M.env().placeholders then
if config.enabled and config.markdown.enabled and M.env().placeholders then
vim.api.nvim_create_autocmd("FileType", {
group = group,
callback = function(e)
@ -654,7 +765,7 @@ function M.attach(buf, opts)
modified = false,
swapfile = false,
})
M.new(buf, file, opts)
Placement.new(buf, file, opts)
end
end
@ -662,10 +773,14 @@ end
function M.markdown(buf)
buf = buf or 0
buf = buf == 0 and vim.api.nvim_get_current_buf() or buf
if vim.b[buf].snacks_image_attached then
return
end
vim.b[buf].snacks_image_attached = true
local file = vim.api.nvim_buf_get_name(buf)
local dir = vim.fs.dirname(file)
assert(vim.bo[buf].filetype == "markdown", "`Image.markdown`: buf should be a markdown buffer")
local images = {} ---@type table<string, snacks.image>
local imgs = {} ---@type table<string, snacks.image.Placement>
local parser = vim.treesitter.get_parser(buf)
assert(parser, "`Image.markdown`: treesitter parser not found")
parser:parse(true)
@ -690,29 +805,29 @@ function M.markdown(buf)
local range = { node:range() }
local pos = { range[1] + 1, range[2] }
local nid = node:id()
if not images[nid] then
if not imgs[nid] then
src = config.resolve and config.resolve(file, src) or resolve(src)
images[nid] = M.new(buf, src, { pos = pos, max_width = 80 })
imgs[nid] = Placement.new(buf, src, { pos = pos, max_width = 80 })
else
images[nid]:update()
imgs[nid]:update()
end
found[nid] = true
end
end)
for nid, img in pairs(images) do
for nid, img in pairs(imgs) do
if not found[nid] then
img:close()
images[nid] = nil
imgs[nid] = nil
end
end
end
update()
vim.schedule(update)
local group = vim.api.nvim_create_augroup("snacks.image.markdown." .. buf, { clear = true })
vim.api.nvim_create_autocmd("BufWritePost", {
group = group,
buffer = buf,
callback = function(ev)
callback = function()
update()
end,
})