---@class snacks.image.doc local M = {} ---@alias TSMatch {node:TSNode, meta:vim.treesitter.query.TSMetadata} ---@alias snacks.image.transform fun(match: snacks.image.match, ctx: snacks.image.ctx) ---@alias snacks.image.find fun(matches: snacks.image.match[]) ---@class snacks.image.Hover ---@field img snacks.image.Placement ---@field win snacks.win ---@field buf number ---@class snacks.image.ctx ---@field buf number ---@field lang string ---@field meta vim.treesitter.query.TSMetadata ---@field pos? TSMatch ---@field src? TSMatch ---@field content? TSMatch ---@class snacks.image.match ---@field id string ---@field pos snacks.image.Pos ---@field src? string ---@field content? string ---@field ext? string ---@field range? Range4 ---@field lang string ---@field type snacks.image.Type local META_EXT = "image.ext" local META_SRC = "image.src" local META_TYPE = "image.type" local META_IGNORE = "image.ignore" local META_LANG = "image.lang" ---@type table M.transforms = { norg = function(img, ctx) local row, col = ctx.src.node:start() local line = vim.api.nvim_buf_get_lines(ctx.buf, row, row + 1, false)[1] img.src = line:sub(col + 1) end, typst = function(img, ctx) if not img.content then return end img.content = Snacks.picker.util.tpl(Snacks.image.config.math.typst.tpl, { color = Snacks.util.color("SnacksImageMath") or "#000000", header = M.get_header(ctx.buf), content = img.content, }, { indent = true, prefix = "$" }) end, latex = function(img, ctx) if not (img.content and img.ext == "math.tex") then return end local fg = Snacks.util.color("SnacksImageMath") or "#000000" local content = vim.trim(img.content or "") content = content:gsub("^%$+`?", ""):gsub("`?%$+$", "") content = content:gsub("^\\[%[%(]", ""):gsub("\\[%]%)]$", "") if not content:find("^\\begin") then content = ("\\[%s\\]"):format(content) end local packages = { "xcolor" } vim.list_extend(packages, Snacks.image.config.math.latex.packages) vim.list_extend(packages, M.get_packages(ctx.buf)) table.sort(packages) local seen = {} ---@type table packages = vim.tbl_filter(function(p) if seen[p] then return false end seen[p] = true return true end, packages) img.content = Snacks.picker.util.tpl(Snacks.image.config.math.latex.tpl, { font_size = Snacks.image.config.math.latex.font_size or "large", packages = table.concat(packages, ", "), header = M.get_header(ctx.buf), color = fg:upper():sub(2), content = content, }, { indent = true, prefix = "$" }) end, } local hover ---@type snacks.image.Hover? local uv = vim.uv or vim.loop local dir_cache = {} ---@type table local buf_cache = {} ---@type table ---@param buf number ---@param key string ---@param fn fun():any function M._cache(buf, key, fn) if buf_cache[buf] and buf_cache[buf].tick ~= vim.api.nvim_buf_get_changedtick(buf) then buf_cache[buf] = nil end buf_cache[buf] = buf_cache[buf] or { tick = vim.api.nvim_buf_get_changedtick(buf) } if buf_cache[buf][key] == nil then buf_cache[buf][key] = fn() end return buf_cache[buf][key] end ---@param buf number function M.get_packages(buf) if vim.bo[buf].filetype ~= "tex" then return {} end return M._cache(buf, "packages", function() local ret = {} ---@type string[] for _, line in ipairs(vim.api.nvim_buf_get_lines(buf, 0, -1, false)) do if line:find("\\usepackage", 1, true) then for _, p in ipairs(vim.split(line:match("{(.-)}") or "", ",%s*")) do if not vim.tbl_contains(ret, p) then ret[#ret + 1] = p end end end end return ret end) end ---@param buf number function M.get_header(buf) return M._cache(buf, "header", function() local header = {} ---@type string[] local in_header = false for _, line in ipairs(vim.api.nvim_buf_get_lines(buf, 0, -1, false)) do if line:find("snacks:%s*header%s*start") then in_header = true elseif line:find("snacks:%s*header%s*end") then in_header = false elseif in_header then header[#header + 1] = line end end return table.concat(header, "\n") end) end ---@param str string function M.url_decode(str) return str:gsub("+", " "):gsub("%%(%x%x)", function(hex) return string.char(tonumber(hex, 16)) end) end ---@param dir string function M.is_dir(dir) if dir_cache[dir] == nil then dir_cache[dir] = vim.fn.isdirectory(dir) == 1 end return dir_cache[dir] end ---@param buf number ---@param src string function M.resolve(buf, src) src = M.url_decode(src) local file = svim.fs.normalize(vim.api.nvim_buf_get_name(buf)) local s = Snacks.image.config.resolve and Snacks.image.config.resolve(file, src) or nil if s then return s end if not src:find("^%w%w+://") then local cwd = uv.cwd() or "." local checks = { [src] = true } for _, root in ipairs({ cwd, vim.fs.dirname(file) }) do checks[root .. "/" .. src] = true for _, dir in ipairs(Snacks.image.config.img_dirs) do dir = root .. "/" .. dir if M.is_dir(dir) then checks[dir .. "/" .. src] = true end end end for f in pairs(checks) do if vim.fn.filereadable(f) == 1 then src = uv.fs_realpath(f) or f break end end src = svim.fs.normalize(src) end return src end ---@param buf number ---@param cb snacks.image.find function M.find_visible(buf, cb) local ret = {} ---@type table local wins = vim.fn.win_findbuf(buf) local count = #wins for _, win in ipairs(wins) do local info = vim.fn.getwininfo(win)[1] M.find(buf, function(mathes) for _, i in ipairs(mathes) do ret[i.id] = i end count = count - 1 if count == 0 and cb then cb(vim.tbl_values(ret)) end end, { from = math.max(info.topline - 1, 1), to = info.botline }) end end ---@param buf number ---@param cb snacks.image.find ---@param opts? {from?: number, to?: number} function M.find(buf, cb, opts) local ok, parser = pcall(vim.treesitter.get_parser, buf) if not ok or not parser then return cb({}) end opts = opts or {} local from, to = opts.from, opts.to Snacks.util.parse(parser, from and to and { from, to } or true, function() local ret = {} ---@type snacks.image.match[] parser:for_each_tree(function(tstree, tree) if not tstree then return end local query = vim.treesitter.query.get(tree:lang(), "images") if not query then return end for _, match, meta in query:iter_matches(tstree:root(), buf, from and from - 1 or nil, to) do if not meta[META_IGNORE] then ---@type snacks.image.ctx local ctx = { buf = buf, lang = tostring(meta[META_LANG] or meta["injection.language"] or tree:lang()), meta = meta, } for id, nodes in pairs(match) do nodes = type(nodes) == "userdata" and { nodes } or nodes local name = query.captures[id] local field = name == "image" and "pos" or name:match("^image%.(.*)$") if field then ---@diagnostic disable-next-line: assign-type-mismatch ctx[field] = { node = nodes[1], meta = meta[id] or {} } end end ret[#ret + 1] = M._img(ctx) end end end) cb(ret) end) end ---@param ctx snacks.image.ctx function M._img(ctx) ctx.pos = ctx.pos or ctx.src or ctx.content assert(ctx.pos, "no image node") local range6 = vim.treesitter.get_range(ctx.pos.node, ctx.buf, ctx.pos.meta) local range = { range6[1], range6[2], range6[4], range6[5] } ---@type Range4 if range[3] > 0 and range[4] == 0 then range[3] = range[3] - 1 local line = vim.api.nvim_buf_get_lines(ctx.buf, range[3], range[3] + 1, false)[1] range[4] = #line end ---@type snacks.image.match local img = { ext = ctx.meta[META_EXT], src = ctx.meta[META_SRC], lang = ctx.lang, id = ctx.pos.node:id(), range = { range[1] + 1, range[2], range[3] + 1, range[4] }, pos = { range[1] + 1, range[2] }, type = "image", } if ctx.meta[META_TYPE] then img.type = ctx.meta[META_TYPE] elseif img.ext then img.type = img.ext:match("^(%w+)%.") or img.type end if not Snacks.image.config.math.enabled and img.type == "math" then return end if ctx.src then img.src = vim.treesitter.get_node_text(ctx.src.node, ctx.buf, { metadata = ctx.src.meta }) end if ctx.content then img.content = vim.treesitter.get_node_text(ctx.content.node, ctx.buf, { metadata = ctx.content.meta }) end assert(img.src or img.content, "no image src or content") local transform = M.transforms[ctx.lang] if transform then transform(img, ctx) end if img.src then img.src = M.resolve(ctx.buf, img.src) end if img.content and not img.src then local root = Snacks.image.config.cache vim.fn.mkdir(root, "p") img.src = root .. "/" .. vim.fn.sha256(img.content):sub(1, 8) .. "-content." .. (img.ext or "png") if vim.fn.filereadable(img.src) == 0 then local fd = assert(io.open(img.src, "w"), "failed to open " .. img.src) fd:write(img.content) fd:close() end end return img end function M.hover_close() if hover then hover.win:close() hover.img:close() hover = nil end end --- Get the image at the cursor (if any) ---@param cb fun(image_src?:string, image_pos?: snacks.image.Pos) function M.at_cursor(cb) local cursor = vim.api.nvim_win_get_cursor(0) M.find(vim.api.nvim_get_current_buf(), function(imgs) for _, img in ipairs(imgs) do local range = img.range if range then if (range[1] == range[3] and cursor[2] >= range[2] and cursor[2] <= range[4]) or (range[1] ~= range[3] and cursor[1] >= range[1] and cursor[1] <= range[3]) then return cb(img.src, img.pos) end end end cb() end, { from = cursor[1], to = cursor[1] + 1 }) end function M.hover() local current_win = vim.api.nvim_get_current_win() local current_buf = vim.api.nvim_get_current_buf() if hover and hover.win.win == current_win and hover.win:valid() then return end if hover and (not hover.win:valid() or hover.buf ~= current_buf or vim.fn.mode() ~= "n") then M.hover_close() end M.at_cursor(function(src) if not src then return M.hover_close() end if hover and hover.img.img.src ~= src then M.hover_close() elseif hover then hover.img:update() return end local win = Snacks.win(Snacks.win.resolve(Snacks.image.config.doc, "snacks_image", { show = false, enter = false, })) win:open_buf() local updated = false local o = Snacks.config.merge({}, Snacks.image.config.doc, { on_update_pre = function() if hover and not updated then updated = true local loc = hover.img:state().loc win.opts.width = loc.width win.opts.height = loc.height win:show() end end, inline = false, }) hover = { win = win, buf = current_buf, img = Snacks.image.placement.new(win.buf, src, o), } vim.api.nvim_create_autocmd({ "BufWritePost", "CursorMoved", "ModeChanged", "BufLeave" }, { group = vim.api.nvim_create_augroup("snacks.image.hover", { clear = true }), callback = function() if not hover then return true end M.hover() if not hover then return true end end, }) end) end ---@param buf number function M.attach(buf) if vim.b[buf].snacks_image_attached then return end vim.b[buf].snacks_image_attached = true local inline = Snacks.image.config.doc.inline and Snacks.image.terminal.env().placeholders local float = Snacks.image.config.doc.float and not inline if not inline and not float then return end if inline then Snacks.image.inline.new(buf) else local group = vim.api.nvim_create_augroup("snacks.image.doc." .. buf, { clear = true }) vim.api.nvim_create_autocmd({ "CursorMoved" }, { group = group, buffer = buf, callback = vim.schedule_wrap(M.hover), }) vim.schedule(M.hover) end end return M