feat(img): add vim.img.protocol() to detect preferred graphics protocol

Implement `vim.img.protocol()` that can be used to detect the preferred graphics protocol.

This is a reverse-engineered copy of how `timg` implements graphics protocol support, and relies on a couple of terminal queries, hence we implement `vim.img._terminal.query()` and `vim.img._terminal.graphics.detect()` to support figuring out if the terminal supports iterm2, kitty, or sixel protocols and mirrors the logic from `timg`.
This commit is contained in:
Chip Senkbeil 2024-12-01 14:23:39 -06:00
parent a56a5f0117
commit ce818a9a91
No known key found for this signature in database
GPG Key ID: 35EF1F8EC72A4131
3 changed files with 184 additions and 1 deletions

View File

@ -1,5 +1,6 @@
local img = vim._defer_require('vim.img', {
_backend = ..., --- @module 'vim.img._backend'
_detect = ..., --- @module 'vim.img._detect'
_image = ..., --- @module 'vim.img._image'
_terminal = ..., --- @module 'vim.img._terminal'
})
@ -13,7 +14,31 @@ function img.load(opts)
return img._image:new(opts)
end
---@class vim.img.Protocol 'iterm2'|'kitty'|'sixel'
img.protocol = (function()
---@class vim.img.Protocol 'iterm2'|'kitty'|'sixel'
---@type vim.img.Protocol|nil
local protocol = nil
local loaded = false
---Determines the preferred graphics protocol to use by default.
---
---@return vim.img.Protocol|nil
return function()
if not loaded then
local graphics = img._detect().graphics
---@diagnostic disable-next-line:cast-type-mismatch
---@cast graphics vim.img.Protocol|nil
protocol = graphics
loaded = true
end
return protocol
end
end)()
---@class vim.img.Opts: vim.img.Backend.RenderOpts
---@field backend? vim.img.Protocol|vim.img.Backend
@ -26,6 +51,14 @@ function img.show(image, opts)
local backend = opts.backend
-- If no graphics are explicitly defined, attempt to detect the
-- preferred graphics. If we still cannot figure out a backend,
-- throw an error early versus silently trying a protocol.
if not backend then
backend = img.protocol()
assert(backend, 'no graphics backend available')
end
-- For named protocols, grab the appropriate backend, failing
-- if there is not a default backend for the specified protocol.
if type(backend) == 'string' then

View File

@ -0,0 +1,88 @@
local terminal = require('vim.img._terminal')
local TERM_QUERY = {
-- Request device attributes (DA2).
--
-- It typically returns information about the terminal type and supported features.
--
-- Response format is typically something like '\033[>...;...;...c'
DEVICE_ATTRIBUTES = terminal.code.ESC .. '[>q',
-- Request device status report (DSR), checking if terminal is okay.
--
-- Response indicates its current state.
DEVICE_STATUS_REPORT = terminal.code.ESC .. '[5n',
}
local TERM_RESPONSE = {
-- Indicates that the terminal is functioning normally (no error).
--
-- 0 means 'OK'; other values indicate different states or errors.
OK = terminal.code.ESC .. '[0n',
}
---Detects supported graphics of the terminal.
---@return {graphics:'iterm2'|'kitty'|'sixel'|nil, tmux:boolean, broken_sixel_cursor_placement:boolean}
return function()
local results = { graphics = nil, tmux = false, broken_sixel_cursor_placement = false }
local term = os.getenv('TERM')
if term == 'xterm-kitty' or term == 'xterm-ghostty' or term == 'ghostty' then
results.graphics = 'kitty'
end
local term_program = os.getenv('TERM_PROGRAM')
if term_program == 'vscode' then
results.graphics = 'iterm2'
results.broken_sixel_cursor_placement = true
end
local _, err = terminal.query({
query = table.concat({
TERM_QUERY.DEVICE_ATTRIBUTES,
TERM_QUERY.DEVICE_STATUS_REPORT,
}),
handler = function(buffer)
local function has(s)
return string.find(buffer, s, 1, true) ~= nil
end
if has('iTerm2') or has('Konsole 2') then
results.graphics = 'iterm2'
end
if has('WezTerm') then
results.graphics = 'iterm2'
results.broken_sixel_cursor_placement = true
end
if has('kitty') or has('ghostty') then
results.graphics = 'kitty'
end
if has('mlterm') then
results.graphics = 'sixel'
end
if has('XTerm') or has('foot') then
results.graphics = 'sixel'
results.broken_sixel_cursor_placement = true
end
if has('tmux') then
results.tmux = true
end
-- Check if we have received the ok terminal response
local start = string.find(buffer, TERM_RESPONSE.OK, 1, true)
if start then
return string.sub(buffer, start)
end
end,
timeout = 250,
})
assert(not err, err)
return results
end

View File

@ -75,4 +75,66 @@ end
---Terminal escape codes.
M.code = TERM_CODE
---@param opts {query:string, handler:(fun(buffer:string):string|nil), timeout?:integer}
---@return string|nil result, string|nil err
function M.query(opts)
local uv = vim.uv
opts = opts or {}
local query = opts.query
local handler = opts.handler
local timeout = opts.timeout or 250
local tty_fd, err
local function cleanup()
if tty_fd then
uv.fs_close(tty_fd)
tty_fd = nil
end
end
-- Identify the path to the editor's tty
-- NOTE: This only works on Unix-like systems!
local ok, tty_path = pcall(M.tty_name)
if not ok then
return nil, tty_path
end
-- Open the tty so we can write our query
tty_fd, err = uv.fs_open(tty_path, 'r+', 438)
if not tty_fd then
return nil, err
end
-- Write query to terminal.
local success, write_err = uv.fs_write(tty_fd, query, -1)
if not success then
cleanup()
return nil, write_err
end
-- Read response with timeout.
local buffer = ''
local start_time = uv.now()
while uv.now() - start_time < timeout do
local data, read_err = uv.fs_read(tty_fd, 512, -1)
if data then
buffer = buffer .. data
local result = handler(buffer)
if result then
cleanup()
return result
end
elseif read_err ~= 'EAGAIN' then
cleanup()
return nil, read_err
end
uv.sleep(1)
end
cleanup()
return nil, 'Timeout'
end
return M