diff --git a/runtime/lua/vim/img.lua b/runtime/lua/vim/img.lua index 84eb2f5aea..4c5f4aeac4 100644 --- a/runtime/lua/vim/img.lua +++ b/runtime/lua/vim/img.lua @@ -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 diff --git a/runtime/lua/vim/img/_detect.lua b/runtime/lua/vim/img/_detect.lua new file mode 100644 index 0000000000..69377c6e37 --- /dev/null +++ b/runtime/lua/vim/img/_detect.lua @@ -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 diff --git a/runtime/lua/vim/img/_terminal.lua b/runtime/lua/vim/img/_terminal.lua index 7419c71f2d..ba7147eb80 100644 --- a/runtime/lua/vim/img/_terminal.lua +++ b/runtime/lua/vim/img/_terminal.lua @@ -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