diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt index 447e607436..b1f0e6038a 100644 --- a/runtime/doc/news.txt +++ b/runtime/doc/news.txt @@ -50,6 +50,7 @@ UI • todo • `vim.ui.img` experimental module added to display images within neovim. +• `:checkhealth vim.ui.img` reports terminal graphics protocol support. VIMSCRIPT diff --git a/runtime/lua/vim/tty.lua b/runtime/lua/vim/tty.lua index 937fee1024..6f624309dd 100644 --- a/runtime/lua/vim/tty.lua +++ b/runtime/lua/vim/tty.lua @@ -91,4 +91,52 @@ function M.query(caps, cb) end) end +--- Send an APC sequence to the terminal and call {cb} for each APC response received. +--- Cleans up after {timeout} milliseconds if no response is received. +--- +--- {cb} receives the full APC sequence including the `\027_` prefix. +--- Return `true` from {cb} to stop listening. +--- +---@param payload string APC sequence to send (full escape sequence including prefix/suffix) +---@param opts {timeout?:integer} Options table (timeout in milliseconds, default 1000) +---@param cb fun(resp:string):boolean? Callback invoked for each APC TermResponse +---@overload fun(payload:string, cb:fun(resp:string):boolean?) +function M.query_apc(payload, opts, cb) + if type(opts) == 'function' then + cb = opts + opts = {} + end + + vim.validate('payload', payload, 'string') + vim.validate('opts', opts, 'table') + vim.validate('cb', cb, 'function') + + local timeout = opts and opts.timeout or 1000 + local timer = assert(vim.uv.new_timer()) + + local id = vim.api.nvim_create_autocmd('TermResponse', { + nested = true, + callback = function(ev) + local resp = ev.data.sequence ---@type string + if resp:match('^\027_') then + if not timer:is_closing() then + timer:close() + end + return cb(resp) + end + end, + }) + + vim.api.nvim_ui_send(payload) + + timer:start(timeout, 0, function() + vim.schedule(function() + pcall(vim.api.nvim_del_autocmd, id) + end) + if not timer:is_closing() then + timer:close() + end + end) +end + return M diff --git a/runtime/lua/vim/ui/img.lua b/runtime/lua/vim/ui/img.lua index 64e905e75b..23161f5bf9 100644 --- a/runtime/lua/vim/ui/img.lua +++ b/runtime/lua/vim/ui/img.lua @@ -113,6 +113,17 @@ function M.del(id) return true end +---@private +--- Query whether the host terminal supports displaying images. +--- Blocks until the terminal responds or times out. +--- +---@param opts? {timeout?: integer} timeout in milliseconds (default: 1000) +---@return boolean supported true if the terminal supports image display +---@return string? msg error detail if the terminal responded but not with OK +function M._supported(opts) + return require('vim.ui.img._kitty').supported(opts) +end + vim.api.nvim_create_autocmd('VimLeavePre', { callback = function() ---@type integer[] diff --git a/runtime/lua/vim/ui/img/_kitty.lua b/runtime/lua/vim/ui/img/_kitty.lua index 7bdbf66c38..c779489c61 100644 --- a/runtime/lua/vim/ui/img/_kitty.lua +++ b/runtime/lua/vim/ui/img/_kitty.lua @@ -147,4 +147,50 @@ function M.delete(img_id) })) end +--- Query whether this terminal supports the kitty graphics protocol. +--- Blocks until the terminal responds or times out. +--- +---@param opts? {timeout?: integer} timeout in milliseconds (default: 1000) +---@return boolean supported +---@return string? msg error detail if terminal responded but not with OK +function M.supported(opts) + local timeout = opts and opts.timeout or 1000 + + -- Do not use APC on terminals that echo unknown sequences + if vim.env.TERM_PROGRAM == 'Apple_Terminal' then + return false + end + + local query_id = generate_id() + + ---@type boolean? + local result + ---@type string? + local msg + + require('vim.tty').query_apc( + seq({ a = 'q', i = query_id, s = 1, v = 1 }), + { timeout = timeout }, + function(resp) + -- kitty APC response: \027_G[,]i=[,]; + -- status is "OK" or an error code+message like "ENODATA:Missing image data" + local id = resp:match('^\027_G[^;]*i=(%d+)') + local status = resp:match(';(.-)%s*$') + if id and tonumber(id) == query_id and status then + result = true + msg = status ~= 'OK' and status or nil + return true + end + end + ) + + -- Wait in a blocking fashion for the response, checking + -- at least every 200ms, or faster if the timeout is small + vim.wait(timeout + 100, function() + return result ~= nil + end, math.max(math.min(math.ceil(timeout / 10), 200), 1)) + + return result == true, msg +end + return M diff --git a/runtime/lua/vim/ui/img/health.lua b/runtime/lua/vim/ui/img/health.lua index 898ffa7d91..466e15953b 100644 --- a/runtime/lua/vim/ui/img/health.lua +++ b/runtime/lua/vim/ui/img/health.lua @@ -1,51 +1,23 @@ local M = {} local health = vim.health -local function system(cmd) - local result = vim.system(cmd, { text = true }):wait() - if not result then -- Workaround https://github.com/neovim/neovim/issues/37922 - return false, 'command failed' - end - return result.code == 0, vim.trim(('%s\n%s'):format(result.stdout, result.stderr)) -end - -local function get_tmux_option(option) - local cmd = { 'tmux', 'show-option', '-qvg', option } -- try global scope - local ok, out = system(cmd) - local val = vim.fn.substitute(out, [[\v(\s|\r|\n)]], '', 'g') - if not ok then - health.error(('command failed: %s\n%s'):format(vim.inspect(cmd), out)) - return 'error' - elseif val == '' then - cmd = { 'tmux', 'show-option', '-qvgs', option } -- try session scope - ok, out = system(cmd) - val = vim.fn.substitute(out, [[\v(\s|\r|\n)]], '', 'g') - if not ok then - health.error(('command failed: %s\n%s'):format(vim.inspect(cmd), out)) - return 'error' - end - end - return val -end - function M.check() health.start('vim.ui.img') - if not vim.env.TMUX or vim.fn.executable('tmux') == 0 then - health.ok('no terminal multiplexer detected') - return + local supported, msg = require('vim.ui.img')._supported() + + if supported then + if msg then + health.ok(('Graphics protocol: supported (%s)'):format(msg)) + else + health.ok('Graphics protocol: supported') + end + else + health.error('Graphics protocol: not supported by this terminal.') end - local passthrough = get_tmux_option('allow-passthrough') - if passthrough ~= 'error' then - if passthrough == 'on' or passthrough == 'all' then - health.ok('allow-passthrough: ' .. passthrough) - else - health.error( - '`allow-passthrough` is not enabled. Images will not be displayed.', - { 'Add to ~/.tmux.conf:\nset-option -g allow-passthrough on' } - ) - end + if vim.env.TMUX then + health.warn('tmux is detected. Images may not display correctly.') end end