diff --git a/lua/snacks/image/buf.lua b/lua/snacks/image/buf.lua index 157c303d..e47e3773 100644 --- a/lua/snacks/image/buf.lua +++ b/lua/snacks/image/buf.lua @@ -4,7 +4,8 @@ local M = {} ---@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) + opts = opts or {} + local file = opts.src or vim.api.nvim_buf_get_name(buf) if not Snacks.image.supports(file) then local lines = {} ---@type string[] lines[#lines + 1] = "# Image viewer" @@ -28,6 +29,8 @@ function M.attach(buf, opts) modified = false, swapfile = false, }) + opts.conceal = true + opts.auto_resize = true return Snacks.image.placement.new(buf, file, opts) end end diff --git a/lua/snacks/image/convert.lua b/lua/snacks/image/convert.lua index 5e24667e..ddc11792 100644 --- a/lua/snacks/image/convert.lua +++ b/lua/snacks/image/convert.lua @@ -12,6 +12,7 @@ local uv = vim.uv or vim.loop ---@class snacks.image.convert.Opts ---@field src string +---@field on_done? fun(convert: snacks.image.Convert) ---@class snacks.image.meta ---@field src string @@ -36,7 +37,7 @@ local uv = vim.uv or vim.loop ---@field proc? snacks.spawn.Proc ---@class snacks.image.cmd ----@field cmd (fun(step: snacks.image.step):(snacks.image.Proc|snacks.image.Proc[])?)|snacks.image.Proc|snacks.image.Proc[] +---@field cmd (fun(step: snacks.image.step):(snacks.image.Proc|snacks.image.Proc[]))|snacks.image.Proc|snacks.image.Proc[] ---@field ft? string ---@field file? fun(convert: snacks.image.Convert, meta: snacks.image.meta): string ---@field depends? string[] @@ -62,14 +63,6 @@ local commands = { return M.is_uri(src) and convert:tmpfile("data") or src end, }, - cache = { - file = function(convert, ctx) - return convert:tmpfile(convert:ft(ctx.src)) - end, - cmd = function(step) - uv.fs_copyfile(step.meta.src, step.file) - end, - }, typ = { ft = "pdf", cmd = { @@ -98,13 +91,13 @@ local commands = { }, }, on_done = function(step) - local pdf = assert(step.meta.pdf, "No pdf file") + local pdf = assert(step.meta.pdf, "No pdf file") --[[@as string]] if uv.fs_stat(pdf) then uv.fs_rename(pdf, step.file) end end, on_error = function(step) - local pdf = assert(step.meta.pdf, "No pdf file") + local pdf = assert(step.meta.pdf, "No pdf file") --[[@as string]] if step.meta.pdf and vim.fn.getfsize(pdf) > 0 then return true end @@ -191,6 +184,40 @@ local commands = { } local have = {} ---@type table +local proc_queue = {} ---@type snacks.spawn.Proc[] +local proc_running = 0 ---@type number +local MAX_PROCS = 3 + +---@param proc? snacks.spawn.Proc +local function schedule(proc) + if proc then + table.insert(proc_queue, proc) + else + proc_running = proc_running - 1 + end + -- Snacks.notify("proc_running: " .. proc_running .. "\nproc_queue: " .. #proc_queue, { id = "proc_running" }) + if proc_running < MAX_PROCS and #proc_queue > 0 then + proc_running = proc_running + 1 + proc = table.remove(proc_queue, 1) + proc:run() + end +end + +---@param step snacks.image.step +local function get_cmd(step) + local cmd = step.cmd.cmd + cmd = type(cmd) == "function" and cmd(step) or cmd + local cmds = cmd.cmd and { cmd } or cmd + ---@cast cmds snacks.image.Proc[] + for _, c in ipairs(cmds) do + if have[c.cmd] == nil then + have[c.cmd] = vim.fn.executable(c.cmd) == 1 + end + if have[c.cmd] then + return c + end + end +end ---@class snacks.image.Convert ---@field opts snacks.image.convert.Opts @@ -201,6 +228,7 @@ local have = {} ---@type table ---@field steps snacks.image.step[] ---@field _done? boolean ---@field _err? string +---@field _step number ---@field tpl_data table local Convert = {} Convert.__index = Convert @@ -212,6 +240,7 @@ function Convert.new(opts) opts.src = M.norm(opts.src) self.opts = opts self.src = opts.src + self._step = 0 local base = vim.fn.fnamemodify(opts.src, ":t:r") if M.is_uri(self.opts.src) then base = self.opts.src:gsub("%?.*", ""):match("^%w%w+://(.*)$") or base @@ -228,12 +257,9 @@ function Convert.new(opts) return self end +---@return snacks.image.step? function Convert:current() - for _, step in ipairs(self.steps) do - if not step.done then - return step - end - end + return self.steps[self._step] end function Convert:ready() @@ -298,113 +324,126 @@ function Convert:resolve() self.file = self.meta.src end ----@param cb fun(convert: snacks.image.Convert) -function Convert:run(cb) - if #self.steps == 0 then - self._done = true - return cb(self) +---@param err? string +function Convert:on_step(err) + local step = assert(self:current(), "No current step") + step.done = true + step.err = err + if self.aborted then + return self:on_done() + end + if step and err and step.cmd.on_error and step.cmd.on_error(step) then + -- keep going + elseif err then + self._err = err + return self:on_done() + end + if step and step.cmd.on_done then + step.cmd.on_done(step) end - local s = 0 - local next ---@type fun() + if self._step < #self.steps then + self:step() + else + self:on_done() + end +end - ---@param step? snacks.image.step - ---@param err? string - local function done(step, err) - if step then - step.done = true - step.err = err +-- Called when all steps are done or when an error occurs +function Convert:on_done() + local step = self:current() + self._done = true + if self._err and Snacks.image.config.convert.notify then + local title = step and ("Conversion failed at step `%s`"):format(step.name) or "Conversion failed" + if step and step.proc then + step.proc:debug({ title = title }) + else + Snacks.notify.error("# " .. title .. "\n" .. self._err, { title = "Snacks Image" }) end - if step and err and step.cmd.on_error and step.cmd.on_error(step) then - -- keep going - elseif err then - if Snacks.image.config.convert.notify then - local title = step and ("Conversion failed at step `%s`"):format(step.name) or "Conversion failed" - if step and step.proc then - step.proc:debug({ title = title }) - else - Snacks.notify.error("# " .. title .. "\n" .. err, { title = "Snacks Image" }) - end - end - self._err = err - self._done = true - return cb(self) + end + if self.opts.on_done then + self.opts.on_done(self) + end +end + +function Convert:abort() + if self.aborted then + return + end + if self:done() then + return + end + self.aborted = true + self._err = "Aborted" + for _, step in ipairs(self.steps) do + if step.proc then + step.proc:kill() end - if step and step.cmd.on_done then - step.cmd.on_done(step) + end +end + +function Convert:step() + self._step = self._step + 1 + assert(self._step <= #self.steps, "No more steps") + + local step = self.steps[self._step] + step.done = step.done or (uv.fs_stat(step.file) ~= nil) + if step.done then + return self:on_step() + end + + local cmd = get_cmd(step) + if not cmd then + return self:on_step("No command available") + end + + local args = type(cmd.args) == "function" and cmd.args() or cmd.args + ---@cast args (number|string)[] + args = vim.deepcopy(args) + + local data = vim.tbl_extend("keep", { + file = step.file, + basename = vim.fs.basename(step.file), + name = vim.fn.fnamemodify(step.file, ":t:r"), + dirname = vim.fs.dirname(step.meta.src), + src = step.meta.src, + }, self.tpl_data) + + for a, arg in ipairs(args) do + if type(arg) == "string" then + args[a] = Snacks.picker.util.tpl(arg, data) end - if s == #self.steps then - self._done = true - return cb(self) - end - next() + end + + step.proc = Spawn.new({ + run = false, + debug = Snacks.image.config.debug.convert, + cwd = cmd.cwd and Snacks.picker.util.tpl(cmd.cwd, data) or nil, + cmd = cmd.cmd, + args = args, + on_exit = function(proc, err) + schedule() + local out = vim.trim(proc:out() .. "\n" .. proc:err()) + vim.schedule(function() + self:on_step(err and out or nil) + end) + end, + }) + schedule(step.proc) +end + +function Convert:run() + if #self.steps == 0 then + return self:on_done() end if not M.is_uri(self.src) and vim.fn.filereadable(self.src) == 0 then local f = M.is_uri(self.src) and self.src or vim.fn.fnamemodify(self.src, ":p:~") - done(nil, ("File not found\n- `%s`"):format(f)) - return + self._err = ("File not found\n- `%s`"):format(f) + return self:on_done() end - next = function() - s = s + 1 - assert(s <= #self.steps, "No more steps") - local step = self.steps[s] - step.done = step.done or (uv.fs_stat(step.file) ~= nil) - if step.done then - return done(step) - end - - local cmd = step.cmd.cmd - if type(cmd) == "function" then - local ok, c = pcall(cmd, step) - if ok and c then - cmd = c - else - return done(step, not ok and (c or "error") or nil) - end - end - - local cmds = cmd.cmd and { cmd } or cmd - ---@cast cmds snacks.image.Proc[] - for _, c in ipairs(cmds) do - if have[c.cmd] == nil then - have[c.cmd] = vim.fn.executable(c.cmd) == 1 - end - if have[c.cmd] then - local args = type(c.args) == "function" and c.args() or c.args - ---@cast args (number|string)[] - args = vim.deepcopy(args) - local data = vim.tbl_extend("keep", { - file = step.file, - basename = vim.fs.basename(step.file), - name = vim.fn.fnamemodify(step.file, ":t:r"), - dirname = vim.fs.dirname(step.meta.src), - src = step.meta.src, - }, self.tpl_data) - for a, arg in ipairs(args) do - if type(arg) == "string" then - args[a] = Snacks.picker.util.tpl(arg, data) - end - end - step.proc = Spawn.new({ - debug = Snacks.image.config.debug.convert, - cwd = c.cwd and Snacks.picker.util.tpl(c.cwd, data) or nil, - cmd = c.cmd, - args = args, - on_exit = function(proc, err) - local out = vim.trim(proc:out() .. "\n" .. proc:err()) - vim.schedule(function() - done(step, err and out or nil) - end) - end, - }) - return - end - end - return done(step, "No command available") - end - next() + self:step() end ---@param src string diff --git a/lua/snacks/image/doc.lua b/lua/snacks/image/doc.lua index 874aae17..60284f95 100644 --- a/lua/snacks/image/doc.lua +++ b/lua/snacks/image/doc.lua @@ -3,6 +3,7 @@ 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 @@ -24,9 +25,12 @@ local M = {} ---@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" @@ -48,11 +52,10 @@ M.transforms = { }, { indent = true, prefix = "$" }) end, latex = function(img, ctx) - if not img.content then + if not (img.content and img.ext == "math.tex") then return end local fg = Snacks.util.color("SnacksImageMath") or "#000000" - img.ext = "math.tex" local content = vim.trim(img.content or "") content = content:gsub("^%$+`?", ""):gsub("`?%$+$", "") content = content:gsub("^\\[%[%(]", ""):gsub("\\[%]%)]$", "") @@ -61,16 +64,16 @@ M.transforms = { end local packages = { "xcolor" } vim.list_extend(packages, Snacks.image.config.math.latex.packages) - for _, line in ipairs(vim.api.nvim_buf_get_lines(ctx.buf, 0, -1, false)) do - if line:find("\\usepackage") then - for _, p in ipairs(vim.split(line:match("{(.-)}") or "", ",%s*")) do - if not vim.tbl_contains(packages, p) then - packages[#packages + 1] = p - end - end - end - end + 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, ", "), @@ -87,21 +90,58 @@ M.TS_ASYNC = (vim.treesitter.languagetree or {})._async_parse ~= nil 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) - 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 + 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 - end - return table.concat(header, "\n") + return table.concat(header, "\n") + end) end ---@param str string @@ -152,13 +192,35 @@ function M.resolve(buf, src) end ---@param buf number ----@param from? number ----@param to? number -function M.find(buf, from, to) +---@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 {} + return cb({}) end + opts = opts or {} + local from, to = opts.from, opts.to parser:parse(from and to and { from, to } or true) local ret = {} ---@type snacks.image.match[] parser:for_each_tree(function(tstree, tree) @@ -169,7 +231,7 @@ function M.find(buf, from, to) if not query then return end - for _, match, meta in query:iter_matches(tstree:root(), buf, from and from - 1 or nil, to and to - 1 or nil) do + 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 = { @@ -190,7 +252,7 @@ function M.find(buf, from, to) end end end) - return ret + cb(ret) end ---@param ctx snacks.image.ctx @@ -198,23 +260,31 @@ function M._img(ctx) ctx.pos = ctx.pos or ctx.src or ctx.content assert(ctx.pos, "no image node") - local range = vim.treesitter.get_range(ctx.pos.node, ctx.buf, ctx.pos.meta) - local lines = vim.api.nvim_buf_get_lines(ctx.buf, range[1], range[4] + 1, false) - while #lines > 0 and vim.trim(lines[#lines]) == "" do - table.remove(lines) + 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[4] + 1, range[5] }, - pos = { - range[1] + #lines, - math.min(range[2], range[5]), - }, + range = { range[1] + 1, range[2], range[3] + 1, range[4] }, + pos = { range[1] + 1, range[2] }, + type = "image", } - img.pos[1] = math.min(img.pos[1], vim.api.nvim_buf_line_count(ctx.buf)) + 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 @@ -230,9 +300,6 @@ function M._img(ctx) if img.src then img.src = M.resolve(ctx.buf, img.src) end - if not Snacks.image.config.math.enabled and img.ext and img.ext:find("math") then - return - end if img.content and not img.src then local root = Snacks.image.config.cache vim.fn.mkdir(root, "p") @@ -255,21 +322,23 @@ function M.hover_close() end --- Get the image at the cursor (if any) ----@return string? image_src, snacks.image.Pos? image_pos -function M.at_cursor() +---@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) - local imgs = M.find(vim.api.nvim_get_current_buf(), cursor[1], cursor[1] + 1) - 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 img.src, img.pos + 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 - end + cb() + end, { from = cursor[1], to = cursor[1] + 1 }) end function M.hover() @@ -284,90 +353,54 @@ function M.hover() M.hover_close() end - local src = M.at_cursor() - 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 - ----@param buf number -function M.inline(buf) - local imgs = {} ---@type table - return function() - local found = {} ---@type table - for _, i in ipairs(M.find(buf)) do - local img = imgs[i.id] ---@type snacks.image.Placement? - if img and img.img.src ~= i.src then - img:close() - img = nil - end - - if not img then - img = Snacks.image.placement.new( - buf, - i.src, - Snacks.config.merge({}, Snacks.image.config.doc, { - pos = i.pos, - range = i.range, - inline = true, - }) - ) - imgs[i.id] = img - else - img:update() - end - found[i.id] = true + M.at_cursor(function(src) + if not src then + return M.hover_close() end - for nid, img in pairs(imgs) do - if not found[nid] then - img:close() - imgs[nid] = nil - end + + if hover and hover.img.img.src ~= src then + M.hover_close() + elseif hover then + hover.img:update() + return end - 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 @@ -383,24 +416,17 @@ function M.attach(buf) return end - local group = vim.api.nvim_create_augroup("snacks.image.doc." .. buf, { clear = true }) - - local update = inline and M.inline(buf) or M.hover - if inline then - vim.api.nvim_create_autocmd("BufWritePost", { - group = group, - buffer = buf, - callback = vim.schedule_wrap(update), - }) + 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(update), + callback = vim.schedule_wrap(M.hover), }) + vim.schedule(M.hover) end - vim.schedule(update) end return M diff --git a/lua/snacks/image/image.lua b/lua/snacks/image/image.lua index 67c3712e..31d7cc6d 100644 --- a/lua/snacks/image/image.lua +++ b/lua/snacks/image/image.lua @@ -4,20 +4,47 @@ ---@field id number image id. unique per nvim instance and file ---@field sent? boolean image data is sent ---@field placements table image placements ----@field augroup number ---@field info? snacks.image.Info ---@field _convert? snacks.image.Convert +---@field fsize? number local M = {} M.__index = M local NVIM_ID_BITS = 10 local CHUNK_SIZE = 4096 +local MAX_FSIZE = 200 * 1024 * 1024 -- 200MB local _id = 30 -local _pid = 0 +local _pid = 10 local nvim_id = 0 local uv = vim.uv or vim.loop local images = {} ---@type table local terminal = Snacks.image.terminal +local lru = {} ---@type {img:snacks.Image, used:number}[] +local lru_fsize = 0 + +---@param img snacks.Image +local function use(img) + if img.fsize == 0 then + return + end + local now = os.time() + for _, v in ipairs(lru) do + if v.img == img then + v.used = now + return + end + end + table.sort(lru, function(a, b) + return a.used > b.used + end) + while lru_fsize >= MAX_FSIZE and #lru > 0 do + local i = table.remove(lru).img + i.sent = false + lru_fsize = lru_fsize - (i.fsize or 0) + end + lru_fsize = lru_fsize + (img.fsize or 0) + table.insert(lru, { img = img, used = now }) +end ---@param src string function M.new(src) @@ -38,7 +65,6 @@ function M.new(src) -- interleave the nvim id and the image id self.id = bit.bor(bit.lshift(nvim_id, 24 - NVIM_ID_BITS), _id) self.placements = {} - self.augroup = vim.api.nvim_create_augroup("snacks.image." .. self.id, { clear = true }) self:run() if self:ready() then @@ -50,12 +76,18 @@ end function M:on_ready() if not self.sent then + self.fsize = vim.fn.getfsize(self.file) self.info = self._convert and self._convert.meta.info or nil + if self.info and self.info.size then + -- ghostty uses the decoded rgba size to calculate the fsize + self.fsize = (self.info.size.width * 4 + 1) * self.info.size.height + end self:send() end end function M:on_send() + use(self) for _, placement in pairs(self.placements) do placement:update() end @@ -82,23 +114,26 @@ function M:run() if not self._convert then return end - self._convert:run(function(convert) - if convert:error() then - vim.schedule(function() - for _, p in pairs(self.placements) do - p:error() - end - end) - else - vim.schedule(function() - self:on_ready() - end) - end - end) + self._convert:run() end function M:convert() - self._convert = Snacks.image.convert.convert({ src = self.src }) + self._convert = Snacks.image.convert.convert({ + src = self.src, + on_done = function(convert) + if convert:error() then + vim.schedule(function() + for _, p in pairs(self.placements) do + p:error() + end + end) + else + vim.schedule(function() + self:on_ready() + end) + end + end, + }) return self._convert.file end @@ -148,15 +183,16 @@ end ---@param placement snacks.image.Placement function M:place(placement) - for pid, p in pairs(self.placements) do - if p == placement then - placement.id = pid - return pid - end + if not placement.id then + _pid = _pid + 1 + placement.id = _pid + end + self.placements[placement.id] = placement + if self.sent then + use(self) + elseif self:ready() then + self:send() end - _pid = _pid + 1 - placement.id = _pid - self.placements[_pid] = placement end ---@param pid? number @@ -170,8 +206,6 @@ function M:del(pid) if not next(self.placements) then terminal.request({ a = "d", d = "i", i = self.id }) - self.sent = false - pcall(vim.api.nvim_del_autocmd_by_id, self.augroup) end end diff --git a/lua/snacks/image/init.lua b/lua/snacks/image/init.lua index 6b8e070c..989da2d5 100644 --- a/lua/snacks/image/init.lua +++ b/lua/snacks/image/init.lua @@ -6,10 +6,11 @@ ---@field buf snacks.image.buf ---@field doc snacks.image.doc ---@field convert snacks.image.convert +---@field inline snacks.image.inline local M = setmetatable({}, { ---@param M snacks.image __index = function(M, k) - if vim.tbl_contains({ "terminal", "image", "placement", "util", "doc", "buf", "convert" }, k) then + if vim.tbl_contains({ "terminal", "image", "placement", "util", "doc", "buf", "convert", "inline" }, k) then M[k] = require("snacks.image." .. k) end return rawget(M, k) @@ -24,6 +25,7 @@ M.meta = { ---@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} +---@alias snacks.image.Type "image"|"math"|"chart" ---@class snacks.image.Env ---@field name string @@ -78,7 +80,13 @@ local defaults = { max_width = 80, max_height = 40, -- Set to `true`, to conceal the image text when rendering inline. - conceal = false, -- (experimental) + -- (experimental) + ---@param lang string tree-sitter language + ---@param type snacks.image.Type image type + conceal = function(lang, type) + -- only conceal math expressions + return type == "math" + end, }, img_dirs = { "img", "images", "assets", "static", "public", "media", "attachments" }, -- window options applied to windows displaying image buffers @@ -101,6 +109,13 @@ local defaults = { placement = false, }, env = {}, + -- icons used to show where an inline image is located that is + -- rendered below the text. + icons = { + math = "󰪚 ", + chart = "󰄧 ", + image = " ", + }, ---@class snacks.image.convert.Config convert = { notify = true, -- show a notification on error @@ -137,7 +152,7 @@ local defaults = { -- but you can add more packages here. Useful for markdown documents. packages = { "amsmath", "amssymb", "amsfonts", "amscd", "mathtools" }, tpl = [[ - \documentclass[preview,border=2pt,varwidth,12pt]{standalone} + \documentclass[preview,border=0pt,varwidth,12pt]{standalone} \usepackage{${packages}} \begin{document} ${header} @@ -162,6 +177,7 @@ Snacks.config.style("snacks_image", { Snacks.util.set_hl({ Spinner = "Special", + Anchor = "Special", Loading = "NonText", Math = { fg = Snacks.util.color({ "@markup.math.latex", "Special", "Normal" }) }, }, { prefix = "SnacksImage", default = true }) @@ -169,6 +185,7 @@ Snacks.util.set_hl({ ---@class snacks.image.Opts ---@field pos? snacks.image.Pos (row, col) (1,0)-indexed. defaults to the top-left corner ---@field range? Range4 +---@field conceal? boolean ---@field inline? boolean render the image inline in the buffer ---@field width? number ---@field min_width? number @@ -178,6 +195,8 @@ Snacks.util.set_hl({ ---@field max_height? number ---@field on_update? fun(placement: snacks.image.Placement) ---@field on_update_pre? fun(placement: snacks.image.Placement) +---@field type? snacks.image.Type +---@field auto_resize? boolean local did_setup = false @@ -220,6 +239,22 @@ function M.setup(ev) did_setup = true local group = vim.api.nvim_create_augroup("snacks.image", { clear = true }) + vim.api.nvim_create_autocmd({ "BufWipeout", "BufDelete" }, { + group = group, + callback = function(e) + vim.schedule(function() + Snacks.image.placement.clean(e.buf) + end) + end, + }) + vim.api.nvim_create_autocmd({ "ExitPre" }, { + group = group, + once = true, + callback = function() + Snacks.image.placement.clean() + end, + }) + if M.config.formats and #M.config.formats > 0 then vim.api.nvim_create_autocmd("BufReadCmd", { pattern = "*." .. table.concat(M.config.formats, ",*."), diff --git a/lua/snacks/image/inline.lua b/lua/snacks/image/inline.lua new file mode 100644 index 00000000..7bad060f --- /dev/null +++ b/lua/snacks/image/inline.lua @@ -0,0 +1,146 @@ +---@class snacks.image.inline +---@field buf number +---@field imgs table +---@field idx table +local M = {} +M.__index = M + +function M.new(buf) + local self = setmetatable({}, M) + self.buf = buf + self.imgs = {} + self.idx = {} + local group = vim.api.nvim_create_augroup("snacks.image.inline." .. buf, { clear = true }) + + local update = Snacks.util.debounce(function() + self:update() + end, { ms = 100 }) + + vim.api.nvim_create_autocmd({ "BufWritePost", "WinScrolled" }, { + group = group, + buffer = buf, + callback = vim.schedule_wrap(update), + }) + vim.api.nvim_create_autocmd({ "ModeChanged", "CursorMoved" }, { + group = group, + buffer = buf, + callback = function(ev) + if ev.buf == self.buf and ev.buf == vim.api.nvim_get_current_buf() then + self:conceal() + end + end, + }) + vim.schedule(update) + return self +end + +function M:conceal() + local mode = vim.fn.mode() + local cursor = vim.api.nvim_win_get_cursor(0) + local by_mode = not mode:find("[nc]") + local hide = by_mode and self:visible() or self:get(cursor[1], cursor[1]) + for _, img in pairs(self.imgs) do + img:show() + end + for _, img in pairs(hide) do + if by_mode or img.opts.conceal then + img:hide() + end + end +end + +function M:visible() + local ret = {} ---@type table + for _, win in ipairs(vim.fn.win_findbuf(self.buf)) do + local info = vim.fn.getwininfo(win)[1] + for k, v in pairs(self:get(math.max(info.topline - 1, 1), info.botline)) do + ret[k] = v + end + end + return ret +end + +---@param from number 1-indexed inclusive +---@param to number 1-indexed inclusive +function M:get(from, to) + local ret = {} ---@type table + local marks = vim.api.nvim_buf_get_extmarks(self.buf, Snacks.image.placement.ns, { from - 1, 0 }, { to - 1, -1 }, { + overlap = true, + hl_name = false, + }) + for _, m in ipairs(marks) do + local p = self.idx[m[1]] ---@type snacks.image.Placement? + if p and not self.imgs[p.id] then + self.idx[m[1]] = nil + p = nil + end + if p then + ret[p.id] = p + end + end + return ret +end + +function M:update() + local conceal = Snacks.image.config.doc.conceal + conceal = type(conceal) ~= "function" and function() + return conceal + end or conceal + Snacks.image.doc.find_visible(self.buf, function(imgs) + local visible = self:visible() + local stats = { new = 0, del = 0, update = 0 } + for _, i in ipairs(imgs) do + local img ---@type snacks.image.Placement? + for v, o in pairs(visible) do + if o.img.src == i.src then + img = o + visible[v] = nil + break + end + end + if not img then + stats.new = stats.new + 1 + img = Snacks.image.placement.new( + self.buf, + i.src, + Snacks.config.merge({}, Snacks.image.config.doc, { + pos = i.pos, + range = i.range, + inline = true, + conceal = conceal(i.lang, i.type), + type = i.type, + ---@param p snacks.image.Placement + on_update = function(p) + for _, eid in ipairs(p.eids) do + self.idx[eid] = p + end + end, + }) + ) + for _, eid in ipairs(img.eids) do + self.idx[eid] = img + end + self.imgs[img.id] = img + else + stats.update = stats.update + 1 + img.opts.pos = i.pos + img.opts.range = i.range + img:update() + end + end + for _, img in pairs(visible) do + stats.del = stats.del + 1 + img:close() + self.imgs[img.id] = nil + end + for k, v in pairs(stats) do + stats[k] = v > 0 and v or nil + end + Snacks.notify( + vim.inspect({ all = vim.tbl_count(self.imgs), stats = stats }), + { ft = "lua", id = "snacks.image.inline" } + ) + end) +end + +return M diff --git a/lua/snacks/image/placement.lua b/lua/snacks/image/placement.lua index 515b8846..7540c1e9 100644 --- a/lua/snacks/image/placement.lua +++ b/lua/snacks/image/placement.lua @@ -5,16 +5,24 @@ ---@field buf number ---@field opts snacks.image.Opts ---@field augroup number +---@field hidden? boolean ---@field closed? boolean ----@field extmark_id? number +---@field type? snacks.image.Type ---@field _loc? snacks.image.Loc ---@field _state? snacks.image.State +---@field eids number[] +---@field _extmarks? snacks.image.Extmark[] local M = {} M.__index = M +---@alias snacks.image.Extmark vim.api.keyset.set_extmark|{row:number, col:number} + local terminal = Snacks.image.terminal local uv = vim.uv or vim.loop +local ns = vim.api.nvim_create_namespace("snacks.image") +M.ns = ns local PLACEHOLDER = vim.fn.nr2char(0x10EEEE) +local placements = {} ---@type table> -- 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", ",") ---@type table @@ -26,6 +34,18 @@ setmetatable(positions, { end, }) +---@param buf? number +---@param id? number +function M.clean(buf, id) + for _, b in ipairs(buf and { buf } or vim.tbl_keys(placements)) do + for _, p in ipairs(id and { placements[b][id] } or vim.tbl_values(placements[b] or {})) do + if p then + p:close() + end + end + end +end + ---@param buf number ---@param opts? snacks.image.Opts function M.new(buf, src, opts) @@ -37,45 +57,32 @@ function M.new(buf, src, opts) self.img = Snacks.image.image.new(src) self.img:place(self) self.opts = opts or {} + self.opts.pos = self.opts.pos or { 1, 0 } self.buf = buf - self.ns = vim.api.nvim_create_namespace("snacks.image." .. self.id) self.augroup = vim.api.nvim_create_augroup("snacks.image." .. self.id, { clear = true }) + self.eids = {} - 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, - }) + if self.opts.auto_resize then + 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, + }) + end + placements[self.buf] = placements[self.buf] or {} + placements[self.buf][self.id] = self if self:ready() then vim.schedule(function() @@ -83,6 +90,14 @@ function M.new(buf, src, opts) end) elseif self.img:failed() then self:error() + elseif self.opts.inline then + -- temporary extmark so that we can keep track of unloaded images in the buffer + self:_render({ + { + row = self.opts.pos[1] - 1, + col = self.opts.pos[2], + }, + }) else self:progress() end @@ -145,8 +160,8 @@ function M:progress() end return end - vim.api.nvim_buf_clear_namespace(self.buf, self.ns, 0, -1) - vim.api.nvim_buf_set_extmark(self.buf, self.ns, 0, 0, { + vim.api.nvim_buf_clear_namespace(self.buf, ns, 0, -1) + vim.api.nvim_buf_set_extmark(self.buf, ns, 0, 0, { virt_text = { { Snacks.util.spinner(), "SnacksImageSpinner" }, { " " }, @@ -169,12 +184,22 @@ function M:close() if self.closed then return end + placements[self.buf][self.id] = nil self.closed = true + self:del() self:debug("close") - self:hide() pcall(vim.api.nvim_del_augroup_by_id, self.augroup) end +function M:del() + self.img:del(self.id) + if vim.api.nvim_buf_is_valid(self.buf) then + for _, eid in ipairs(self.eids) do + vim.api.nvim_buf_del_extmark(self.buf, ns, eid) + end + end +end + --- Renders the unicode placeholder grid in the buffer ---@param loc snacks.image.Loc function M:render_grid(loc) @@ -183,10 +208,11 @@ function M:render_grid(loc) [hl] = { fg = self.img.id, sp = self.id, - bg = Snacks.image.config.debug.placement and "#FF007C" or nil, + bg = Snacks.image.config.debug.placement and "#FF007C" or "none", + nocombine = true, }, }) - local lines = {} ---@type string[] + local img = {} ---@type string[] local height = math.min(#diacritics, loc.height) local width = math.min(#diacritics, loc.width) for r = 1, height do @@ -197,56 +223,158 @@ function M:render_grid(loc) line[#line + 1] = positions[r] line[#line + 1] = positions[c] end - lines[#lines + 1] = table.concat(line) + img[#img + 1] = table.concat(line) end - if self.opts.inline then - local padding = string.rep(" ", loc[2]) - vim.api.nvim_buf_clear_namespace(self.buf, self.ns, 0, -1) - local start_row, start_col = loc[1] - 1, loc[2] - local end_row, end_col ---@type number?, number? - local conceal = Snacks.image.config.doc.conceal and " " or nil - if self.opts.range and conceal then - start_row, start_col = self.opts.range[1] - 1, self.opts.range[2] - end_row, end_col = self.opts.range[3] - 1, self.opts.range[4] + local range = self.opts.range or { loc[1], loc[2], loc[1], loc[2] } + local lines = vim.api.nvim_buf_get_lines(self.buf, range[1] - 1, range[3], false) + local text_width = 0 + for _, line in ipairs(lines) do + text_width = math.max(text_width, vim.api.nvim_strwidth(line)) + end + local offset = range[2] + local has_after = lines[#lines]:sub(range[4] + 1):find("%S") ~= nil + local conceal = self.opts.conceal and "" or nil + local extmarks = {} ---@type snacks.image.Extmark[] + + -- we can overlay the image if the text is multiline, + -- or the text has nothing after the image + -- and the text is not wrapped or the text fits the window width + local can_overlay = (#lines > 1 or not has_after) + for _, win in ipairs(can_overlay and self:wins() or {}) do + if vim.wo[win].wrap then + local info = vim.fn.getwininfo(win)[1] + if info.width - info.textoff < text_width then + can_overlay = false + break + end end - self.extmark_id = vim.api.nvim_buf_set_extmark(self.buf, self.ns, start_row, start_col, { - end_row = end_row, - end_col = end_col, + end + -- can_overlay = false + + if height == 1 and #lines == 1 then + -- render inline + self:_render({ + { + row = range[1] - 1, + col = range[2], + end_row = range[3] - 1, + end_col = range[4], + conceal = conceal, + invalidate = vim.fn.has("nvim-0.10") == 1 and true or nil, + virt_text_pos = "inline", + virt_text = { { img[1], hl } }, + virt_text_hide = true, + }, + }) + elseif can_overlay then + if conceal then + -- conceal and overlay on the first line + extmarks[#extmarks + 1] = { + row = range[1] - 1, + col = range[2], + end_row = range[3] - 1, + end_col = range[4], + conceal = conceal, + virt_text_pos = "overlay", + virt_text = { { table.remove(img, 1), hl } }, + virt_text_hide = false, + virt_text_win_col = offset, + } + -- overlay over the other lines + for i = 1, math.min(#img, #lines - 1) do + extmarks[#extmarks + 1] = { + row = range[1] - 1 + i, + col = 0, + virt_text_pos = "overlay", + virt_text = { { table.remove(img, 1), hl } }, + virt_text_hide = false, + virt_text_win_col = offset, + } + end + end + if #img > 0 then + -- add additional virtual lines if there are more lines to render + local padding = string.rep(" ", offset) + extmarks[#extmarks + 1] = { + row = range[3] - 1, + col = 0, + ---@param l string + virt_lines = vim.tbl_map(function(l) + return { { padding }, { l, hl } } + end, img), + virt_text_hide = false, + } + end + self:_render(extmarks) + else + local icon = Snacks.image.config.icons[self.opts.type or "image"] or Snacks.image.config.icons.image + -- render below in virtual lines + extmarks[#extmarks + 1] = { + row = range[1] - 1, + col = range[2], + end_row = range[3] - 1, + end_col = range[4], conceal = conceal, - id = self.extmark_id, + virt_text = { { icon, "SnacksImageAnchor" } }, + virt_text_pos = "inline", + virt_text_hide = false, ---@param l string virt_lines = vim.tbl_map(function(l) - return { { padding }, { 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, #lines do - vim.api.nvim_buf_set_extmark(self.buf, self.ns, r - 1, 0, { - end_col = #lines[r], - hl_group = hl, - }) - end + return { { l, hl } } + end, img), + } + self:_render(extmarks) end end -function M:hide() - if vim.api.nvim_buf_is_valid(self.buf) then - vim.api.nvim_buf_clear_namespace(self.buf, self.ns, 0, -1) +---@param extmarks snacks.image.Extmark[] +function M:_render(extmarks) + for _, e in ipairs(extmarks) do + e.undo_restore = false + e.strict = false + if self.hidden then + e.virt_text = nil + e.conceal = nil + if e.virt_lines then + e.virt_lines = vim.tbl_map(function(l) + return { { "" } } + end, e.virt_lines) + end + end end - self.img:del(self.id) + local eids = {} ---@type number[] + for _, extmark in ipairs(extmarks) do + local row, col = extmark.row, extmark.col + extmark.row, extmark.col, extmark.id = nil, nil, table.remove(self.eids, 1) + table.insert(eids, vim.api.nvim_buf_set_extmark(self.buf, ns, row, col, extmark)) + end + for _, eid in ipairs(self.eids) do + vim.api.nvim_buf_del_extmark(self.buf, ns, eid) + end + self.eids = eids +end + +function M:hide() + if self.hidden or not self:ready() then + return + end + self.hidden = true + self:update() +end + +function M:show() + if not self.hidden or not self:ready() then + return + end + self.hidden = false + self:update() end ---@param state snacks.image.State function M:render_fallback(state) if not self.opts.inline then - vim.api.nvim_buf_clear_namespace(self.buf, self.ns, 0, -1) + vim.api.nvim_buf_clear_namespace(self.buf, ns, 0, -1) end for _, win in ipairs(state.wins) do self:debug("render_fallback", win) @@ -299,10 +427,29 @@ function M:state() local size = Snacks.image.util.fit(self.img.file, { width = width, height = height }, { info = self.img.info }) local pos = self.opts.pos or { 1, 0 } + + local function is_inline() + local range = self.opts.range or { pos[1], pos[2], pos[1], pos[2] } + if range[1] == range[3] then + local line = vim.api.nvim_buf_get_lines(self.buf, range[1] - 1, range[1], false)[1] or "" + local has_before = line:sub(1, range[2]):find("%S") ~= nil + local has_after = line:sub(range[4] + 1):find("%S") ~= nil + return has_before or has_after + end + end + + -- scale down to fit inline + if size.height <= 2 and is_inline() then + size.width = math.ceil(size.width / size.height) + 2 + size.height = 1 + end + ---@class snacks.image.State + ---@field hidden boolean ---@field loc snacks.image.Loc ---@field wins number[] return { + hidden = self.hidden or false, loc = { pos[1], pos[2], @@ -313,11 +460,23 @@ function M:state() } end +function M:valid() + return self.buf + and vim.api.nvim_buf_is_valid(self.buf) + and self:ready() + and self.opts.pos[1] <= vim.api.nvim_buf_line_count(self.buf) +end + function M:update() if not self:ready() then return end + if not self:valid() then + self:del() + return + end + if self.opts.on_update_pre then self.opts.on_update_pre(self) end