Files
neovim/runtime/lua/vim/ui/img/_kitty.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

151 lines
3.8 KiB
Lua

---Kitty graphics protocol implementation for vim.ui.img.
local M = {}
local generate_id = (function()
local bit = require('bit')
local NVIM_PID_BITS = 10
local nvim_pid = 0
local cnt = 30
---@return integer
return function()
if nvim_pid == 0 then
local pid = vim.fn.getpid()
nvim_pid = bit.band(bit.bxor(pid, bit.rshift(pid, 5), bit.rshift(pid, NVIM_PID_BITS)), 0x3FF)
end
cnt = cnt + 1
return bit.bor(bit.lshift(nvim_pid, 24 - NVIM_PID_BITS), cnt)
end
end)()
---Build a Kitty graphics protocol escape sequence.
---@param control table<string, string|number>
---@param payload? string
---@return string
local function seq(control, payload)
local parts = { '\027_G' }
local tmp = {}
for k, v in pairs(control) do
table.insert(tmp, k .. '=' .. v)
end
if #tmp > 0 then
table.insert(parts, table.concat(tmp, ','))
end
if payload and payload ~= '' then
table.insert(parts, ';')
table.insert(parts, payload)
end
table.insert(parts, '\027\\')
return table.concat(parts)
end
---Transmit image bytes to kitty in base64 chunks using direct transmission.
---
---Large images may cause the terminal to hang or the escape sequence to get
---interrupted mid-write. A future filepath option (t=f) could let the
---terminal read the file directly, avoiding this issue for local sessions.
---@param id integer kitty image id
---@param data string raw image bytes
local function transmit(id, data)
local chunk_size = 4096
local base64_data = vim.base64.encode(data)
local pos = 1
local len = #base64_data
while pos <= len do
local end_pos = math.min(pos + chunk_size - 1, len)
local chunk = base64_data:sub(pos, end_pos)
local is_last = end_pos >= len
local control = {}
if pos == 1 then
control.f = '100' -- PNG format
control.a = 't' -- Transmit without displaying
control.t = 'd' -- Direct transmission
control.i = id
control.q = '2' -- Suppress responses
end
control.m = is_last and '0' or '1'
vim.api.nvim_ui_send(seq(control, chunk))
pos = end_pos + 1
end
end
---Send a kitty place/display command with cursor management.
---@param img_id integer kitty image id
---@param placement_id integer kitty placement id
---@param opts vim.ui.img.Opts
local function place(img_id, placement_id, opts)
local cursor_save = '\0277'
local cursor_hide = '\027[?25l'
local cursor_move = string.format('\027[%d;%dH', opts.row or 1, opts.col or 1)
local cursor_restore = '\0278'
local cursor_show = '\027[?25h'
---@type table<string, string|number>
local control = {
a = 'p',
i = img_id,
p = placement_id,
C = '1', -- Don't move the cursor at all
q = '2', -- Suppress responses
}
if opts.width then
control.c = opts.width
end
if opts.height then
control.r = opts.height
end
if opts.zindex then
control.z = opts.zindex
end
vim.api.nvim_ui_send(
cursor_save .. cursor_hide .. cursor_move .. seq(control) .. cursor_restore .. cursor_show
)
end
---Transmit image bytes and place the image. Returns both IDs.
---@param data string raw image bytes
---@param opts vim.ui.img.Opts
---@return integer img_id
---@return integer placement_id
function M.set(data, opts)
local img_id = generate_id()
local placement_id = generate_id()
transmit(img_id, data)
place(img_id, placement_id, opts)
return img_id, placement_id
end
---Update an existing placement (flicker-free, reuses same IDs).
---@param img_id integer
---@param placement_id integer
---@param opts vim.ui.img.Opts
function M.update(img_id, placement_id, opts)
place(img_id, placement_id, opts)
end
---Delete an image and all its placements from the terminal.
---@param img_id integer
function M.delete(img_id)
vim.api.nvim_ui_send(seq({
a = 'd',
d = 'i',
i = img_id,
q = '2', -- Suppress responses
}))
end
return M