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
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)