Files
neovim/runtime/lua/vim/ui/img.lua
Chip Senkbeil 5f9e828008 feat(ui): vim.ui.img api #37914
Problem:
No builtin api to load and display images.

Solution:
Introduce vim.ui.img. Only supports kitty graphics protocol, currently.
2026-04-26 18:07:05 -04:00

128 lines
3.4 KiB
Lua

local M = {}
---@brief
---
---EXPERIMENTAL: This API may change in the future. Its semantics are not yet finalized.
---
---This provides a functional API for displaying images in Nvim.
---Currently supports PNG images via the Kitty graphics protocol.
---
---To override the image backend, replace `vim.ui.img` with your own
---implementation providing set/get/del.
---
---Examples:
---
---```lua
----- Load image bytes from disk and display at row 5, column 10
---local id = vim.ui.img.set(
--- vim.fn.readblob('/path/to/img.png'),
--- { row = 5, col = 10, width = 40, height = 20, zindex = 50 }
---)
---
----- Update the image position
---vim.ui.img.set(id, { row = 8, col = 12 })
---
----- Retrieve the current image opts
---local opts = vim.ui.img.get(id)
---
----- Remove the image
---vim.ui.img.del(id)
---```
---@class vim.ui.img.Opts
---@inlinedoc
---@field row? integer starting row (1-indexed)
---@field col? integer starting column (1-indexed)
---@field width? integer width in cells
---@field height? integer height in cells
---@field zindex? integer stacking order (higher = on top)
--- Maps user-facing ID to internal tracking info.
---@type table<integer, { img_id: integer, opts: vim.ui.img.Opts }>
local state = {}
---Display an image or update an existing one.
---
---When {data_or_id} is a string, displays the image bytes at the position
---given by {opts}. Returns an integer id for later use.
---
---When {data_or_id} is an integer (a previously returned id), updates
---the image with new {opts}.
---
---@param data_or_id string|integer image bytes (string) or existing id (integer)
---@param opts? vim.ui.img.Opts
---@return integer id
function M.set(data_or_id, opts)
opts = opts or {}
vim.validate('data_or_id', data_or_id, { 'string', 'number' })
vim.validate('opts', opts, 'table')
local kitty = require('vim.ui.img._kitty')
-- If given a string, this should be the bytes of a new image to display
if type(data_or_id) == 'string' then
local img_id, placement_id = kitty.set(data_or_id, opts)
state[placement_id] = { img_id = img_id, opts = vim.deepcopy(opts) }
return placement_id
end
-- Otherwise, we update an existing image that is actively displayed
local id = data_or_id
local entry = state[id]
assert(entry, 'invalid image id: ' .. tostring(id))
-- We always want to have a full set of options when passing to kitty
local merged = vim.tbl_extend('force', entry.opts, opts)
kitty.update(entry.img_id, id, merged)
entry.opts = merged
return id
end
---Get the opts for an image.
---
---@param id integer
---@return vim.ui.img.Opts? opts copy of image opts, or nil if not found
function M.get(id)
vim.validate('id', id, 'number')
-- Grab a copy of the most recent opts used for the image
local entry = state[id]
if not entry then
return nil
end
return vim.deepcopy(entry.opts)
end
---Delete an image, removing it from display.
---
---@param id integer
---@return boolean found true if the image existed
function M.del(id)
vim.validate('id', id, 'number')
-- Skip performing the deletion if we don't have an active image with the id
local entry = state[id]
if not entry then
return false
end
local kitty = require('vim.ui.img._kitty')
kitty.delete(entry.img_id)
state[id] = nil
return true
end
vim.api.nvim_create_autocmd('VimLeavePre', {
callback = function()
---@type integer[]
local ids = vim.tbl_keys(state)
for _, id in ipairs(ids) do
M.del(id)
end
end,
})
return M