Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 11 additions & 6 deletions doc/snacks.nvim-image.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions docs/image.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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
Expand Down
64 changes: 56 additions & 8 deletions lua/snacks/image/doc.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 })
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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 }),
Expand Down
2 changes: 1 addition & 1 deletion lua/snacks/image/image.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions lua/snacks/image/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 5 additions & 3 deletions lua/snacks/image/inline.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -121,7 +122,7 @@ function M:update()
self.idx[eid] = p
end
end,
})
}))
)
for _, eid in ipairs(img.eids) do
self.idx[eid] = img
Expand All @@ -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
Expand Down
10 changes: 7 additions & 3 deletions lua/snacks/image/placement.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
3 changes: 0 additions & 3 deletions queries/markdown_inline/images.scm
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 4 additions & 0 deletions tests/image/test.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@

!![[test.png|options]]

!![[test.png|150]]

!![[test.png|150px]]

## Injected HTML

<img src="test.png" alt="png" width="200" height="30" />
Expand Down
59 changes: 59 additions & 0 deletions tests/image_spec.lua
Original file line number Diff line number Diff line change
@@ -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("<test.png>", "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)