diff --git a/doc/snacks.nvim-image.txt b/doc/snacks.nvim-image.txt index a75308f6f..33245688c 100644 --- a/doc/snacks.nvim-image.txt +++ b/doc/snacks.nvim-image.txt @@ -27,6 +27,8 @@ Table of Contents *snacks.nvim-image-table-of-contents* - Supports inline image rendering in: `markdown`, `html`, `norg`, `tsx`, `javascript`, `css`, `vue`, `svelte`, `scss`, `latex`, `typst` - LaTex math expressions in `markdown` and `latex` documents +- Markdown wikilinks support per-image width with `[[image.png|150]]` + and `[[image.png|150px]]` Terminal support: @@ -123,12 +125,15 @@ In case of issues, make sure to run `:checkhealth snacks`. -- takes precedence over `opts.float` on supported terminals inline = true, -- render the image in a floating window - -- only used if `opts.inline` is disabled - float = true, - max_width = 80, - max_height = 40, - -- Set to `true`, to conceal the image text when rendering inline. - -- (experimental) + -- only used if `opts.inline` is disabled + float = true, + max_width = 80, + max_height = 40, + -- markdown wikilinks can request a per-image width with + -- `[[image.png|150]]` or `[[image.png|150px]]` + -- the requested width is still clamped by max_width/max_height + -- Set to `true`, to conceal the image text when rendering inline. + -- (experimental) ---@param lang string tree-sitter language ---@param type snacks.image.Type image type conceal = function(lang, type) diff --git a/docs/image.md b/docs/image.md index bd5ee3ce7..5bfefceeb 100644 --- a/docs/image.md +++ b/docs/image.md @@ -10,6 +10,7 @@ - Supports inline image rendering in: `markdown`, `html`, `norg`, `tsx`, `javascript`, `css`, `vue`, `svelte`, `scss`, `latex`, `typst` - LaTex math expressions in `markdown` and `latex` documents +- Markdown wikilinks support per-image width with `[[image.png|150]]` and `[[image.png|150px]]` Terminal support: @@ -106,6 +107,9 @@ In case of issues, make sure to run `:checkhealth snacks`. float = true, max_width = 80, max_height = 40, + -- markdown wikilinks can request a per-image width with + -- `[[image.png|150]]` or `[[image.png|150px]]` + -- the requested width is still clamped by max_width/max_height -- Set to `true`, to conceal the image text when rendering inline. -- (experimental) ---@param lang string tree-sitter language diff --git a/lua/snacks/image/doc.lua b/lua/snacks/image/doc.lua index 0f72863b9..6706aa929 100644 --- a/lua/snacks/image/doc.lua +++ b/lua/snacks/image/doc.lua @@ -22,6 +22,7 @@ local M = {} ---@field id string ---@field pos snacks.image.Pos ---@field src? string +---@field width_px? number ---@field content? string ---@field content_id? string ---@field ext? string @@ -168,6 +169,46 @@ function M.url_decode(str) end) end +---@param width_px? number +function M.width(width_px) + if not width_px then + return + end + local cell_width = Snacks.image.terminal.size().cell_width + return math.max(1, math.floor(width_px / cell_width + 0.5)) +end + +---@param src string +---@param kind? string +---@return {src: string, width_px?: number} +function M.parse(src, kind) + local ret = { src = src } + if kind == "link_text" then + local parts = vim.split(src, "|", { plain = true }) + ret.src = vim.trim(table.remove(parts, 1) or src) + for _, part in ipairs(parts) do + part = vim.trim(part) + local width = part:match("^(%d+)$") or part:match("^(%d+)px$") + if width then + ret.width_px = tonumber(width) + break + end + end + else + ret.src = src:gsub("^<", ""):gsub(">$", "") + end + return ret +end + +---@param img snacks.image.match +---@param opts snacks.image.Opts +function M.opts(img, opts) + opts = Snacks.config.merge({}, opts, { + width = M.width(img.width_px), + }) + return opts +end + ---@param dir string function M.is_dir(dir) if dir_cache[dir] == nil then @@ -305,6 +346,11 @@ function M._img(ctx) end if ctx.src then img.src = vim.treesitter.get_node_text(ctx.src.node, ctx.buf, { metadata = ctx.src.meta }) + if ctx.lang == "markdown_inline" then + local ref = M.parse(img.src, ctx.src.node:type()) + img.src = ref.src + img.width_px = ref.width_px + end end if ctx.content then img.content = vim.treesitter.get_node_text(ctx.content.node, ctx.buf, { metadata = ctx.content.meta }) @@ -347,7 +393,7 @@ function M.hover_close() end --- Get the image at the cursor (if any) ----@param cb fun(image_src?:string, image_pos?: snacks.image.Pos) +---@param cb fun(image?:snacks.image.match) function M.at_cursor(cb) local cursor = vim.api.nvim_win_get_cursor(0) M.find(vim.api.nvim_get_current_buf(), function(imgs) @@ -358,7 +404,7 @@ function M.at_cursor(cb) (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) + return cb(img) end end end @@ -382,14 +428,15 @@ function M.hover() M.hover_close() end - M.at_cursor(function(src) - if not src then + M.at_cursor(function(img) + if not img or not img.src then return M.hover_close() end - if hover and hover.img.img.src ~= src then + if hover and hover.img.img.src ~= img.src then M.hover_close() elseif hover then + hover.img.opts.width = M.width(img.width_px) or Snacks.image.config.doc.width hover.img:update() return end @@ -401,7 +448,8 @@ function M.hover() })) win:open_buf() local updated = false - local o = Snacks.config.merge({}, Snacks.image.config.doc, { + local o = M.opts(img, Snacks.config.merge({}, Snacks.image.config.doc, { + match_id = img.id, on_update_pre = function() if hover and not updated then updated = true @@ -412,11 +460,11 @@ function M.hover() end end, inline = false, - }) + })) hover = { win = win, buf = current_buf, - img = Snacks.image.placement.new(win.buf, src, o), + img = Snacks.image.placement.new(win.buf, img.src, o), } vim.api.nvim_create_autocmd({ "BufWritePost", "CursorMoved", "ModeChanged", "BufLeave" }, { group = vim.api.nvim_create_augroup("snacks.image.hover", { clear = true }), diff --git a/lua/snacks/image/image.lua b/lua/snacks/image/image.lua index b51036398..ead10af55 100644 --- a/lua/snacks/image/image.lua +++ b/lua/snacks/image/image.lua @@ -199,7 +199,7 @@ end function M:del(pid) for id, p in ipairs(pid and { pid } or vim.tbl_keys(self.placements)) do if self.placements[p] then - terminal.request({ a = "d", d = "i", i = self.id, p = id }) + terminal.request({ a = "d", d = "i", i = self.id, p = p }) self.placements[p] = nil end end diff --git a/lua/snacks/image/init.lua b/lua/snacks/image/init.lua index 038016e1c..ea0584ceb 100644 --- a/lua/snacks/image/init.lua +++ b/lua/snacks/image/init.lua @@ -81,6 +81,9 @@ local defaults = { float = true, max_width = 80, max_height = 40, + -- markdown wikilinks can request a per-image width with + -- `[[image.png|150]]` or `[[image.png|150px]]` + -- the requested width is still clamped by max_width/max_height -- Set to `true`, to conceal the image text when rendering inline. -- (experimental) ---@param lang string tree-sitter language diff --git a/lua/snacks/image/inline.lua b/lua/snacks/image/inline.lua index 12a59ee6c..b7a01196d 100644 --- a/lua/snacks/image/inline.lua +++ b/lua/snacks/image/inline.lua @@ -98,7 +98,7 @@ function M:update() 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 + if o.opts.match_id == i.id then img = o visible[v] = nil break @@ -109,7 +109,8 @@ function M:update() img = Snacks.image.placement.new( self.buf, i.src, - Snacks.config.merge({}, Snacks.image.config.doc, { + Snacks.image.doc.opts(i, Snacks.config.merge({}, Snacks.image.config.doc, { + match_id = i.id, pos = i.pos, range = i.range, inline = true, @@ -121,7 +122,7 @@ function M:update() self.idx[eid] = p end end, - }) + })) ) for _, eid in ipairs(img.eids) do self.idx[eid] = img @@ -131,6 +132,7 @@ function M:update() stats.update = stats.update + 1 img.opts.pos = i.pos img.opts.range = i.range + img.opts.width = Snacks.image.doc.width(i.width_px) or Snacks.image.config.doc.width img:update() end end diff --git a/lua/snacks/image/placement.lua b/lua/snacks/image/placement.lua index 059585e72..0f7f7e316 100644 --- a/lua/snacks/image/placement.lua +++ b/lua/snacks/image/placement.lua @@ -451,10 +451,17 @@ function M:state() local wins = {} ---@type number[] local is_fallback = not terminal.env().placeholders local zindex = vim.api.nvim_win_get_config(0).zindex or 0 + local pos = self.opts.pos or { 1, 0 } + local range = self.opts.range or { pos[1], pos[2], pos[1], pos[2] } + local offset = range[2] 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 self.opts.inline then + local info = vim.fn.getwininfo(win)[1] + width = math.min(width, math.max(1, info.width - info.textoff - offset)) + end 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 @@ -473,10 +480,7 @@ function M:state() height = minmax(self.opts.height or height, self.opts.min_height, self.opts.max_height) 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 diff --git a/queries/markdown_inline/images.scm b/queries/markdown_inline/images.scm index 3f27cb2a1..2fa044e13 100644 --- a/queries/markdown_inline/images.scm +++ b/queries/markdown_inline/images.scm @@ -4,7 +4,4 @@ (link_destination) @image.src (image_description (shortcut_link ((link_text) @image.src))) ] - (#gsub! @image.src "|.*" "") ; remove wikilink image options - (#gsub! @image.src "^<" "") ; remove bracket link - (#gsub! @image.src ">$" "") ) @image diff --git a/tests/image/test.md b/tests/image/test.md index 937bd11e4..d2e22965e 100644 --- a/tests/image/test.md +++ b/tests/image/test.md @@ -10,6 +10,10 @@ !![[test.png|options]] +!![[test.png|150]] + +!![[test.png|150px]] + ## Injected HTML png diff --git a/tests/image_spec.lua b/tests/image_spec.lua new file mode 100644 index 000000000..478100990 --- /dev/null +++ b/tests/image_spec.lua @@ -0,0 +1,59 @@ +---@module "luassert" + +describe("image doc parser", function() + local doc = require("snacks.image.doc") + + it("parses wikilink width from bare numbers", function() + assert.are.same({ src = "test.png", width_px = 150 }, doc.parse("test.png|150", "link_text")) + end) + + it("parses wikilink width from px suffix", function() + assert.are.same({ src = "test.png", width_px = 150 }, doc.parse("test.png|150px", "link_text")) + end) + + it("uses the first supported width option", function() + assert.are.same({ src = "test.png", width_px = 150 }, doc.parse("test.png|caption|150px|300", "link_text")) + end) + + it("ignores unsupported wikilink options", function() + assert.are.same({ src = "test.png" }, doc.parse("test.png|right|50%", "link_text")) + end) + + it("strips angle brackets for regular markdown links", function() + assert.are.same({ src = "test.png" }, doc.parse("", "link_destination")) + end) + + it("converts pixel widths to terminal cells", function() + local size = Snacks.image.terminal.size + Snacks.image.terminal.size = function() + return { cell_width = 10 } + end + assert.are.equal(15, doc.width(150)) + Snacks.image.terminal.size = size + end) +end) + +describe("image placement deletion", function() + local image = require("snacks.image.image") + + it("deletes the requested placement id", function() + local requests = {} + local request = Snacks.image.terminal.request + Snacks.image.terminal.request = function(opts) + requests[#requests + 1] = opts + end + + image.del({ + id = 42, + placements = { + [7] = true, + }, + }, 7) + + Snacks.image.terminal.request = request + assert.are.same({ + { a = "d", d = "i", i = 42, p = 7 }, + { a = "d", d = "i", i = 42 }, + }, requests) + end) +end)