mirror of
https://github.com/folke/snacks.nvim
synced 2025-08-04 10:49:08 +00:00
feat(image): markdown inline image preview. opts.image
must be enabled and terminal needs support
This commit is contained in:
parent
a7dd055bed
commit
001f3002ca
3 changed files with 428 additions and 198 deletions
|
@ -1,10 +1,17 @@
|
|||
---@class snacks.image
|
||||
---@field id number
|
||||
---@field ns number
|
||||
---@field buf number
|
||||
---@field opts snacks.image.Config
|
||||
---@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
|
||||
|
||||
|
@ -13,6 +20,10 @@ M.meta = {
|
|||
needs_setup = true,
|
||||
}
|
||||
|
||||
---@alias snacks.image.Size {width: number, height: number}
|
||||
---@alias snacks.image.Pos {[1]: number, [2]: number}
|
||||
---@alias snacks.image.Loc snacks.image.Pos|snacks.image.Size|{zindex?: number}
|
||||
|
||||
---@class snacks.image.Env
|
||||
---@field name string
|
||||
---@field env table<string, string|true>
|
||||
|
@ -22,6 +33,46 @@ M.meta = {
|
|||
---@field transform? fun(data: string): string
|
||||
---@field detected? boolean
|
||||
|
||||
---@class snacks.image.Config
|
||||
---@field wo? vim.wo|{} options for windows showing the image
|
||||
---@field bo? vim.bo|{} options for the image buffer
|
||||
---@field formats? string[]
|
||||
local defaults = {
|
||||
formats = { "png", "jpg", "jpeg", "gif", "bmp", "webp", "tiff", "heic", "avif", "mp4", "mov", "avi", "mkv", "webm" },
|
||||
force = false, -- try displaying the image, even if the terminal does not support it
|
||||
markdown = {
|
||||
-- enable image viewer for markdown files
|
||||
-- if your env doesn't support unicode placeholders, this will be disabled
|
||||
enabled = true,
|
||||
max_width = 80,
|
||||
max_height = 40,
|
||||
},
|
||||
-- window options applied to windows displaying image buffers
|
||||
-- an image buffer is a buffer with `filetype=image`
|
||||
wo = {
|
||||
wrap = false,
|
||||
number = false,
|
||||
relativenumber = false,
|
||||
cursorcolumn = false,
|
||||
signcolumn = "no",
|
||||
foldcolumn = "0",
|
||||
list = false,
|
||||
spell = false,
|
||||
statuscolumn = "",
|
||||
},
|
||||
debug = false,
|
||||
}
|
||||
local config = Snacks.config.get("image", defaults)
|
||||
|
||||
---@class snacks.image.Opts
|
||||
---@field pos? snacks.image.Pos (row, col) (1,0)-indexed. defaults to the top-left corner
|
||||
---@field width? number
|
||||
---@field min_width? number
|
||||
---@field max_width? number
|
||||
---@field height? number
|
||||
---@field min_height? number
|
||||
---@field max_height? number
|
||||
|
||||
---@type snacks.image.Env[]
|
||||
local environments = {
|
||||
{
|
||||
|
@ -63,37 +114,22 @@ local environments = {
|
|||
}
|
||||
|
||||
M._env = nil ---@type snacks.image.Env?
|
||||
|
||||
---@class snacks.image.Config
|
||||
---@field file? string
|
||||
---@field wo? vim.wo|{} options for windows showing the image
|
||||
local defaults = {
|
||||
force = false, -- try displaying the image, even if the terminal does not support it
|
||||
wo = {
|
||||
wrap = false,
|
||||
number = false,
|
||||
relativenumber = false,
|
||||
cursorcolumn = false,
|
||||
signcolumn = "no",
|
||||
foldcolumn = "0",
|
||||
list = false,
|
||||
spell = false,
|
||||
statuscolumn = "",
|
||||
},
|
||||
}
|
||||
local uv = vim.uv or vim.loop
|
||||
local ns = vim.api.nvim_create_namespace("snacks.image")
|
||||
|
||||
local NVIM_ID_BITS = 10
|
||||
local images = {} ---@type table<number, snacks.image>
|
||||
local PLACEHOLDER = vim.fn.nr2char(0x10EEEE)
|
||||
-- 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 nvim_id = 0
|
||||
local ids = {} ---@type table<string, number>
|
||||
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 supported_formats = { "png", "jpg", "jpeg", "gif", "bmp", "webp" }
|
||||
local uv = vim.uv or vim.loop
|
||||
---@type table<number, string>
|
||||
local positions = setmetatable({}, {
|
||||
__index = function(t, k)
|
||||
t[k] = vim.fn.nr2char(tonumber(diacritics[k], 16))
|
||||
return t[k]
|
||||
end,
|
||||
})
|
||||
|
||||
function M.env()
|
||||
if M._env then
|
||||
|
@ -103,10 +139,6 @@ function M.env()
|
|||
name = "",
|
||||
env = {},
|
||||
}
|
||||
if not vim.base64 then
|
||||
M._env.supported = false
|
||||
return M._env
|
||||
end
|
||||
for _, e in ipairs(environments) do
|
||||
for k, v in pairs(e.env) do
|
||||
local val = os.getenv(k)
|
||||
|
@ -129,84 +161,83 @@ function M.env()
|
|||
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
|
||||
|
||||
---@param buf number
|
||||
---@param opts? snacks.image.Config
|
||||
function M.new(buf, opts)
|
||||
if images[buf] then
|
||||
return images[buf]
|
||||
end
|
||||
local file = opts and opts.file or vim.api.nvim_buf_get_name(buf)
|
||||
if not M.supports(file) then
|
||||
local lines = {} ---@type string[]
|
||||
lines[#lines + 1] = "# Image viewer"
|
||||
lines[#lines + 1] = "- **file**: `" .. file .. "`"
|
||||
if not M.supports_file(file) then
|
||||
lines[#lines + 1] = "- unsupported image format"
|
||||
end
|
||||
local ok, err = M.supports_terminal()
|
||||
if not ok then
|
||||
lines[#lines + 1] = "- " .. err
|
||||
end
|
||||
vim.bo[buf].modifiable = true
|
||||
vim.bo[buf].filetype = "markdown"
|
||||
vim.api.nvim_buf_set_lines(buf, 0, -1, false, vim.split(table.concat(lines, "\n"), "\n"))
|
||||
vim.bo[buf].modifiable = false
|
||||
vim.bo[buf].modified = false
|
||||
return
|
||||
end
|
||||
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
|
||||
|
||||
---@param buf number
|
||||
---@param opts? snacks.image.Opts
|
||||
function M.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)
|
||||
images[buf] = self
|
||||
|
||||
-- convert to PNG if needed
|
||||
self.file = self:convert(file)
|
||||
|
||||
-- re-use image ids for the same file
|
||||
if not ids[self.file] then
|
||||
id = id + 1
|
||||
ids[self.file] = id
|
||||
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)
|
||||
end
|
||||
self.id = ids[self.file]
|
||||
|
||||
self.opts = Snacks.config.get("image", defaults, opts or {})
|
||||
|
||||
Snacks.util.bo(buf, {
|
||||
filetype = "image",
|
||||
modifiable = false,
|
||||
modified = false,
|
||||
swapfile = false,
|
||||
})
|
||||
self.src = src
|
||||
self.file = self:convert(src)
|
||||
self.id = nextid()
|
||||
self.opts = opts or {}
|
||||
self.buf = buf
|
||||
|
||||
self.inline = true
|
||||
if vim.bo[buf].filetype == "image" then
|
||||
self.inline = false
|
||||
end
|
||||
self.ns = vim.api.nvim_create_namespace("snacks.image." .. self.id)
|
||||
self.augroup = vim.api.nvim_create_augroup("snacks.image." .. self.id, { clear = true })
|
||||
vim.api.nvim_create_autocmd(
|
||||
{ "VimResized", "BufWinEnter", "WinClosed", "BufWinLeave", "WinNew", "BufEnter", "BufLeave", "WinResized" },
|
||||
{
|
||||
group = self.augroup,
|
||||
buffer = self.buf,
|
||||
callback = function()
|
||||
vim.schedule(function()
|
||||
self:update()
|
||||
end)
|
||||
end,
|
||||
}
|
||||
)
|
||||
|
||||
vim.api.nvim_create_autocmd({ "BufWipeout", "ExitPre" }, {
|
||||
vim.api.nvim_create_autocmd({ "BufWinEnter", "WinEnter", "BufWinLeave", "BufEnter" }, {
|
||||
group = self.augroup,
|
||||
buffer = self.buf,
|
||||
callback = function()
|
||||
vim.schedule(function()
|
||||
-- self:update()
|
||||
end)
|
||||
end,
|
||||
})
|
||||
vim.api.nvim_create_autocmd({ "WinClosed", "WinNew", "WinEnter", "WinResized" }, {
|
||||
group = self.augroup,
|
||||
callback = function()
|
||||
vim.schedule(function()
|
||||
-- self:update()
|
||||
end)
|
||||
end,
|
||||
})
|
||||
|
||||
vim.api.nvim_create_autocmd({ "BufWipeout", "BufDelete" }, {
|
||||
group = self.augroup,
|
||||
buffer = self.buf,
|
||||
once = true,
|
||||
callback = function()
|
||||
vim.schedule(function()
|
||||
self:close()
|
||||
end)
|
||||
end,
|
||||
})
|
||||
|
||||
vim.api.nvim_create_autocmd({ "ExitPre" }, {
|
||||
group = self.augroup,
|
||||
once = true,
|
||||
callback = function()
|
||||
self:close()
|
||||
end,
|
||||
|
@ -222,7 +253,7 @@ function M.new(buf, opts)
|
|||
|
||||
self.update = Snacks.util.debounce(function()
|
||||
update(self)
|
||||
end, { ms = 50 })
|
||||
end, { ms = 10 })
|
||||
return self
|
||||
end
|
||||
|
||||
|
@ -230,125 +261,190 @@ end
|
|||
function M:wins()
|
||||
return vim.tbl_filter(function(win)
|
||||
return vim.api.nvim_win_get_buf(win) == self.buf
|
||||
end, vim.api.nvim_list_wins())
|
||||
end
|
||||
|
||||
function M:grid_size()
|
||||
local width, height = vim.o.columns, vim.o.lines
|
||||
for _, win in pairs(self:wins()) do
|
||||
width = math.min(width, vim.api.nvim_win_get_width(win))
|
||||
height = math.min(height, vim.api.nvim_win_get_height(win))
|
||||
end
|
||||
return width, height
|
||||
end, vim.api.nvim_tabpage_list_wins(0))
|
||||
end
|
||||
|
||||
function M:close()
|
||||
if self.closed then
|
||||
return
|
||||
end
|
||||
self.closed = true
|
||||
self:debug("close")
|
||||
self:hide()
|
||||
pcall(vim.api.nvim_del_augroup_by_id, self.augroup)
|
||||
end
|
||||
|
||||
--- Renders the unicode placeholder grid in the buffer
|
||||
---@param width number
|
||||
---@param height number
|
||||
function M:render(width, height)
|
||||
if not self:ready() then
|
||||
return
|
||||
end
|
||||
local hl = "SnacksImage" .. self.id
|
||||
-- image id is coded in the foreground color
|
||||
---@param loc snacks.image.Loc
|
||||
function M: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 })
|
||||
local lines = {} ---@type string[]
|
||||
for r = 1, height do
|
||||
for r = 1, loc.height do
|
||||
local line = {} ---@type string[]
|
||||
for c = 1, width do
|
||||
for c = 1, loc.width do
|
||||
-- cell positions are encoded as diacritics for the placeholder unicode character
|
||||
line[#line + 1] = vim.fn.nr2char(0x10EEEE)
|
||||
line[#line + 1] = vim.fn.nr2char(tonumber(diacritics[r], 16))
|
||||
line[#line + 1] = vim.fn.nr2char(tonumber(diacritics[c], 16))
|
||||
line[#line + 1] = PLACEHOLDER
|
||||
line[#line + 1] = positions[r]
|
||||
line[#line + 1] = positions[c]
|
||||
end
|
||||
lines[#lines + 1] = table.concat(line)
|
||||
end
|
||||
vim.bo[self.buf].modifiable = true
|
||||
vim.api.nvim_buf_set_lines(self.buf, 0, -1, false, lines)
|
||||
vim.bo[self.buf].modifiable = false
|
||||
vim.bo[self.buf].modified = false
|
||||
for r = 1, height do
|
||||
vim.api.nvim_buf_set_extmark(self.buf, ns, r - 1, 0, {
|
||||
end_col = #lines[r],
|
||||
hl_group = hl,
|
||||
|
||||
if self.inline then
|
||||
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,
|
||||
virt_lines = vim.tbl_map(function(l)
|
||||
return { { l, hl } }
|
||||
end, lines),
|
||||
strict = false,
|
||||
invalidate = vim.fn.has("nvim-0.10") == 1 and true or nil,
|
||||
})
|
||||
else
|
||||
vim.bo[self.buf].modifiable = true
|
||||
vim.api.nvim_buf_set_lines(self.buf, 0, -1, false, lines)
|
||||
vim.bo[self.buf].modifiable = false
|
||||
vim.bo[self.buf].modified = false
|
||||
for r = 1, loc.height do
|
||||
vim.api.nvim_buf_set_extmark(self.buf, self.ns, r - 1, 0, {
|
||||
end_col = #lines[r],
|
||||
hl_group = hl,
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function M:hide()
|
||||
self:request({ a = "d", i = self.id })
|
||||
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] .. "H")
|
||||
io.stdout:write("\27[" .. pos[1] .. ";" .. (pos[2] + 1) .. "H")
|
||||
end
|
||||
|
||||
function M:render_fallback()
|
||||
---@param state snacks.image.State
|
||||
function M:render_fallback(state)
|
||||
self:hide()
|
||||
self:create()
|
||||
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({
|
||||
a = "p",
|
||||
i = self.id,
|
||||
p = win,
|
||||
C = 1,
|
||||
c = state.loc.width,
|
||||
r = state.loc.height,
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
function M:debug(...)
|
||||
if not config.debug then
|
||||
return
|
||||
end
|
||||
Snacks.debug.inspect({ ... }, self.src, self.id)
|
||||
end
|
||||
|
||||
function M:state()
|
||||
local width, height = vim.o.columns, vim.o.lines
|
||||
local wins = {} ---@type number[]
|
||||
local is_fallback = not M.env().placeholders
|
||||
local zindex = vim.api.nvim_win_get_config(0).zindex or 0
|
||||
|
||||
for _, win in ipairs(self:wins()) do
|
||||
width = math.min(width, vim.api.nvim_win_get_width(win))
|
||||
height = math.min(height, vim.api.nvim_win_get_height(win))
|
||||
if is_fallback then
|
||||
local z = vim.api.nvim_win_get_config(win).zindex or 0
|
||||
if z >= zindex or (zindex > 0 and z > 0) then
|
||||
wins[#wins + 1] = win -- use if higher z-index or both are floating
|
||||
end
|
||||
else
|
||||
wins[#wins + 1] = win
|
||||
end
|
||||
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)
|
||||
h = h * 0.5 -- adjust for cell height
|
||||
for _, win in ipairs(self:wins()) do
|
||||
local border = setmetatable({ opts = vim.api.nvim_win_get_config(win) }, { __index = Snacks.win }):border_size()
|
||||
local width = vim.api.nvim_win_get_width(win) - border.left - border.right
|
||||
local height = vim.api.nvim_win_get_height(win) - border.top - border.bottom
|
||||
local scale = math.min(width / w, height / h)
|
||||
width, height = math.floor(w * scale), math.floor(h * scale)
|
||||
vim.api.nvim_win_call(win, function()
|
||||
vim.fn.winrestview({ topline = 1, lnum = 1, col = 0, leftcol = 0 })
|
||||
local pos = vim.api.nvim_win_get_position(win)
|
||||
self:set_cursor({ pos[1] + 1 + border.left, pos[2] + 1 + border.top })
|
||||
self:request({
|
||||
a = "p",
|
||||
i = self.id,
|
||||
p = win,
|
||||
C = 1,
|
||||
c = math.floor(width + 0.5),
|
||||
r = math.floor(height + 0.5),
|
||||
})
|
||||
end)
|
||||
end
|
||||
local scale = math.min(width / w, height / h)
|
||||
local c, r = math.floor(w * scale), math.floor(h * scale)
|
||||
local pos = self.opts.pos or { 1, 0 }
|
||||
---@class snacks.image.State
|
||||
---@field loc snacks.image.Loc
|
||||
---@field wins number[]
|
||||
return {
|
||||
loc = { pos[1], pos[2], width = math.floor(c + 0.5), height = math.floor(r + 0.5) },
|
||||
wins = wins,
|
||||
}
|
||||
end
|
||||
|
||||
function M:update()
|
||||
if not self:ready() then
|
||||
return
|
||||
end
|
||||
for _, win in ipairs(self:wins()) do
|
||||
Snacks.util.wo(win, self.opts.wo or {})
|
||||
|
||||
local state = self:state()
|
||||
if vim.deep_equal(state, self._state) then
|
||||
return
|
||||
end
|
||||
if not M.env().placeholders then
|
||||
return self:render_fallback()
|
||||
self._state = state
|
||||
|
||||
if #state.wins == 0 then
|
||||
self:hide()
|
||||
return
|
||||
end
|
||||
local width, height = self:grid_size()
|
||||
self:render(width, height)
|
||||
for _, win in ipairs(self:wins()) do
|
||||
vim.api.nvim_win_call(win, function()
|
||||
vim.fn.winrestview({ topline = 1, lnum = 1, col = 0, leftcol = 0 })
|
||||
end)
|
||||
|
||||
self:debug("update")
|
||||
|
||||
if not self.inline then
|
||||
for _, win in ipairs(state.wins) do
|
||||
Snacks.util.wo(win, config.wo or {})
|
||||
end
|
||||
end
|
||||
|
||||
if M.env().placeholders then
|
||||
self.request({
|
||||
a = "p",
|
||||
U = 1,
|
||||
i = self.id,
|
||||
C = 1,
|
||||
c = state.loc.width,
|
||||
r = state.loc.height,
|
||||
})
|
||||
self:render_grid(state.loc)
|
||||
else
|
||||
self:render_fallback(state)
|
||||
end
|
||||
|
||||
if not self.inline then
|
||||
for _, win in ipairs(state.wins) do
|
||||
vim.api.nvim_win_call(win, function()
|
||||
vim.fn.winrestview({ topline = 1, lnum = 1, col = 0, leftcol = 0 })
|
||||
end)
|
||||
end
|
||||
end
|
||||
self:request({
|
||||
a = "p",
|
||||
U = 1,
|
||||
i = self.id,
|
||||
C = 1,
|
||||
c = width,
|
||||
r = height,
|
||||
})
|
||||
end
|
||||
|
||||
function M:ready()
|
||||
return vim.api.nvim_buf_is_valid(self.buf) and (not self._convert or self._convert:is_closing())
|
||||
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())
|
||||
end
|
||||
|
||||
-- create the image
|
||||
function M:create()
|
||||
-- create the image
|
||||
self:request({
|
||||
self.request({
|
||||
f = 100,
|
||||
t = "f",
|
||||
i = self.id,
|
||||
|
@ -358,23 +454,22 @@ end
|
|||
|
||||
---@param file string
|
||||
function M:convert(file)
|
||||
local ext = vim.fn.fnamemodify(file, ":e")
|
||||
if ext == "png" then
|
||||
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
|
||||
local fin = ext == "gif" and file .. "[0]" or file
|
||||
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
|
||||
self._convert = uv.spawn("magick", {
|
||||
args = {
|
||||
fin,
|
||||
file,
|
||||
},
|
||||
}, function()
|
||||
local opts = { args = { fin, file } }
|
||||
self._convert = uv.spawn("magick", opts, function()
|
||||
self._convert:close()
|
||||
vim.schedule(function()
|
||||
self:create()
|
||||
|
@ -385,7 +480,7 @@ function M:convert(file)
|
|||
end
|
||||
|
||||
---@param opts table<string, string|number>|{data?: string}
|
||||
function M:request(opts)
|
||||
function M.request(opts)
|
||||
opts.q = opts.q or 2 -- silence all
|
||||
local msg = {} ---@type string[]
|
||||
for k, v in pairs(opts) do
|
||||
|
@ -396,7 +491,7 @@ function M:request(opts)
|
|||
msg = { table.concat(msg, ",") }
|
||||
if opts.data then
|
||||
msg[#msg + 1] = ";"
|
||||
msg[#msg + 1] = vim.base64.encode(tostring(opts.data))
|
||||
msg[#msg + 1] = Snacks.util.base64(tostring(opts.data))
|
||||
end
|
||||
local data = "\27_G" .. table.concat(msg) .. "\27\\"
|
||||
local env = M.env()
|
||||
|
@ -409,7 +504,7 @@ end
|
|||
--- Check if the file format is supported
|
||||
---@param file string
|
||||
function M.supports_file(file)
|
||||
return vim.tbl_contains(supported_formats, vim.fn.fnamemodify(file, ":e"))
|
||||
return vim.tbl_contains(config.formats or {}, vim.fn.fnamemodify(file, ":e"))
|
||||
end
|
||||
|
||||
--- Check if the file format is supported and the terminal supports the kitty graphics protocol
|
||||
|
@ -420,14 +515,16 @@ end
|
|||
|
||||
-- Check if the terminal supports the kitty graphics protocol
|
||||
function M.supports_terminal()
|
||||
local opts = Snacks.config.get("image", defaults)
|
||||
return M.env().supported or opts.force or false
|
||||
return M.env().supported or config.force or false
|
||||
end
|
||||
|
||||
--- Get the dimensions of a PNG file
|
||||
---@param file string
|
||||
---@return number width, number height
|
||||
function M.dim(file)
|
||||
if dims[file] then
|
||||
return dims[file].width, dims[file].height
|
||||
end
|
||||
-- extract header with IHDR chunk
|
||||
local fd = assert(io.open(vim.fs.normalize(file), "rb"), "Failed to open file: " .. file)
|
||||
local header = fd:read(24) ---@type string
|
||||
|
@ -439,23 +536,23 @@ function M.dim(file)
|
|||
-- Extract width and height from the IHDR chunk
|
||||
local width = header:byte(17) * 16777216 + header:byte(18) * 65536 + header:byte(19) * 256 + header:byte(20)
|
||||
local height = header:byte(21) * 16777216 + header:byte(22) * 65536 + header:byte(23) * 256 + header:byte(24)
|
||||
|
||||
dims[file] = { width = width, height = height }
|
||||
return width, height
|
||||
end
|
||||
|
||||
---@private
|
||||
function M.health()
|
||||
Snacks.health.have_tool({ "kitty", "wezterm", "ghostty" })
|
||||
if not Snacks.health.have_tool("magick") then
|
||||
Snacks.health.error("`magick` is required to convert images. Only PNG files will be displayed.")
|
||||
end
|
||||
local opts = Snacks.config.get("image", defaults)
|
||||
local env = M.env()
|
||||
for _, e in ipairs(environments) do
|
||||
if e.detected then
|
||||
if e.supported == false then
|
||||
Snacks.health.error("`" .. e.name .. "` is not supported")
|
||||
else
|
||||
Snacks.health.ok("`" .. e.name .. "` is supported")
|
||||
Snacks.health.ok("`" .. e.name .. "` detected and supported")
|
||||
if e.placeholders == false then
|
||||
Snacks.health.warn("`" .. e.name .. "` does not support placeholders. Fallback rendering will be used")
|
||||
elseif e.placeholders == true then
|
||||
|
@ -466,7 +563,7 @@ function M.health()
|
|||
end
|
||||
if env.supported then
|
||||
Snacks.health.ok("your terminal supports the kitty graphics protocol")
|
||||
elseif opts.force then
|
||||
elseif config.force then
|
||||
Snacks.health.warn("image viewer is enabled with `opts.force = true`. Use at your own risk")
|
||||
else
|
||||
Snacks.health.error("your terminal does not support the kitty graphics protocol")
|
||||
|
@ -474,4 +571,131 @@ function M.health()
|
|||
end
|
||||
end
|
||||
|
||||
---@private
|
||||
---@param ev? vim.api.keyset.create_autocmd.callback_args
|
||||
function M.setup(ev)
|
||||
if did_setup then
|
||||
return
|
||||
end
|
||||
did_setup = true
|
||||
local group = vim.api.nvim_create_augroup("snacks.image", { clear = true })
|
||||
|
||||
if config.formats and #config.formats > 0 then
|
||||
vim.api.nvim_create_autocmd("BufReadCmd", {
|
||||
pattern = "*." .. table.concat(config.formats, ",*."),
|
||||
group = group,
|
||||
callback = function(e)
|
||||
M.attach(e.buf)
|
||||
end,
|
||||
})
|
||||
-- prevent altering the original image file
|
||||
vim.api.nvim_create_autocmd("BufWriteCmd", {
|
||||
pattern = "*." .. table.concat(config.formats, ",*."),
|
||||
group = group,
|
||||
callback = function(e)
|
||||
-- vim.api.nvim_exec_autocmds("BufWritePre", { buffer = e.buf })
|
||||
vim.bo[e.buf].modified = false
|
||||
-- vim.api.nvim_exec_autocmds("BufWritePost", { buffer = e.buf })
|
||||
end,
|
||||
})
|
||||
end
|
||||
if config.markdown.enabled and M.env().placeholders then
|
||||
vim.api.nvim_create_autocmd("FileType", {
|
||||
group = group,
|
||||
callback = function(e)
|
||||
local ft = vim.bo[e.buf].filetype
|
||||
local lang = vim.treesitter.language.get_lang(ft)
|
||||
if lang == "markdown" then
|
||||
vim.schedule(function()
|
||||
M.markdown(e.buf)
|
||||
end)
|
||||
end
|
||||
end,
|
||||
})
|
||||
end
|
||||
if ev and ev.event == "BufReadCmd" then
|
||||
M.attach(ev.buf)
|
||||
end
|
||||
end
|
||||
|
||||
---@param buf number
|
||||
---@param opts? snacks.image.Opts|{src?: string}
|
||||
function M.attach(buf, opts)
|
||||
local file = opts and opts.src or vim.api.nvim_buf_get_name(buf)
|
||||
if not M.supports(file) then
|
||||
local lines = {} ---@type string[]
|
||||
lines[#lines + 1] = "# Image viewer"
|
||||
lines[#lines + 1] = "- **file**: `" .. file .. "`"
|
||||
if not M.supports_file(file) then
|
||||
lines[#lines + 1] = "- unsupported image format"
|
||||
end
|
||||
if not M.supports_terminal() then
|
||||
lines[#lines + 1] = "- terminal does not support the kitty graphics protocol."
|
||||
lines[#lines + 1] = " See `:checkhealth snacks` for more info."
|
||||
end
|
||||
vim.bo[buf].modifiable = true
|
||||
vim.bo[buf].filetype = "markdown"
|
||||
vim.api.nvim_buf_set_lines(buf, 0, -1, false, vim.split(table.concat(lines, "\n"), "\n"))
|
||||
vim.bo[buf].modifiable = false
|
||||
vim.bo[buf].modified = false
|
||||
else
|
||||
Snacks.util.bo(buf, {
|
||||
filetype = "image",
|
||||
modifiable = false,
|
||||
modified = false,
|
||||
swapfile = false,
|
||||
})
|
||||
M.new(buf, file, opts)
|
||||
end
|
||||
end
|
||||
|
||||
---@param buf? number
|
||||
function M.markdown(buf)
|
||||
buf = buf or 0
|
||||
buf = buf == 0 and vim.api.nvim_get_current_buf() or buf
|
||||
assert(vim.bo[buf].filetype == "markdown", "`Image.markdown`: buf should be a markdown buffer")
|
||||
local images = {} ---@type table<string, snacks.image>
|
||||
local parser = vim.treesitter.get_parser(buf)
|
||||
assert(parser, "`Image.markdown`: treesitter parser not found")
|
||||
parser:parse(true)
|
||||
local query = vim.treesitter.query.parse("markdown_inline", "(image (link_destination) @image)")
|
||||
|
||||
local function update()
|
||||
local found = {} ---@type table<string, boolean>
|
||||
parser:for_each_tree(function(tstree)
|
||||
if not tstree then
|
||||
return
|
||||
end
|
||||
for _, node, _ in query:iter_captures(tstree:root(), buf) do
|
||||
local src = vim.treesitter.get_node_text(node, buf)
|
||||
local range = { node:range() }
|
||||
local pos = { range[1] + 1, range[2] }
|
||||
local nid = node:id()
|
||||
if not images[nid] then
|
||||
images[nid] = M.new(buf, src, { pos = pos, max_width = 80 })
|
||||
else
|
||||
images[nid]:update()
|
||||
end
|
||||
found[nid] = true
|
||||
end
|
||||
end)
|
||||
for nid, img in pairs(images) do
|
||||
if not found[nid] then
|
||||
img:close()
|
||||
images[nid] = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
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)
|
||||
update()
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
||||
return M
|
||||
|
|
|
@ -17,7 +17,12 @@ _G.Snacks = M
|
|||
|
||||
---@class snacks.Config: snacks.plugins.Config
|
||||
---@field styles? table<string, snacks.win.Config>
|
||||
local config = {}
|
||||
local config = {
|
||||
image = {
|
||||
-- define these here, so that we don't need to load the image module
|
||||
formats = { "png", "jpg", "jpeg", "gif", "bmp", "webp", "tiff", "heic", "avif", "mp4", "mov", "avi", "mkv", "webm" },
|
||||
},
|
||||
}
|
||||
config.styles = {}
|
||||
|
||||
---@class snacks.config: snacks.Config
|
||||
|
@ -129,7 +134,7 @@ function M.setup(opts)
|
|||
config = vim.tbl_deep_extend("force", config, opts or {})
|
||||
|
||||
local events = {
|
||||
BufReadPre = { "bigfile" },
|
||||
BufReadPre = { "bigfile", "image" },
|
||||
BufReadPost = { "quickfile", "indent" },
|
||||
BufEnter = { "explorer" },
|
||||
LspAttach = { "words" },
|
||||
|
@ -167,12 +172,13 @@ function M.setup(opts)
|
|||
end,
|
||||
})
|
||||
|
||||
if M.config.image.enabled then
|
||||
if M.config.image.enabled and #M.config.image.formats > 0 then
|
||||
vim.api.nvim_create_autocmd("BufReadCmd", {
|
||||
pattern = "*.png,*.jpg,*.jpeg,*.gif,*.bmp,*.webp",
|
||||
once = true,
|
||||
pattern = "*." .. table.concat(M.config.image.formats, ",*."),
|
||||
group = group,
|
||||
callback = function(e)
|
||||
require("snacks.image").new(e.buf)
|
||||
require("snacks.image").setup(e)
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
|
|
@ -38,7 +38,7 @@ end
|
|||
function M.image(ctx)
|
||||
local buf = ctx.preview:scratch()
|
||||
ctx.preview:set_title(ctx.item.title or vim.fn.fnamemodify(ctx.item.file, ":t"))
|
||||
Snacks.image.new(buf, { file = Snacks.picker.util.path(ctx.item) })
|
||||
Snacks.image.attach(buf, { src = Snacks.picker.util.path(ctx.item) })
|
||||
end
|
||||
|
||||
---@param ctx snacks.picker.preview.ctx
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue