From 25820b5e24ca936d2ab6333bd1b8d45cb16aca63 Mon Sep 17 00:00:00 2001 From: Chip Senkbeil Date: Fri, 29 Nov 2024 20:56:54 -0600 Subject: [PATCH 1/7] feat(img): implement loading an image into memory Implement `vim.img.load()` to load from a file or wrap base64 encoded bytes as a `vim.img.Image` instance. --- runtime/lua/vim/_editor.lua | 1 + runtime/lua/vim/img.lua | 14 +++++ runtime/lua/vim/img/_image.lua | 107 +++++++++++++++++++++++++++++++++ 3 files changed, 122 insertions(+) create mode 100644 runtime/lua/vim/img.lua create mode 100644 runtime/lua/vim/img/_image.lua diff --git a/runtime/lua/vim/_editor.lua b/runtime/lua/vim/_editor.lua index 44f17b3f85..134349014d 100644 --- a/runtime/lua/vim/_editor.lua +++ b/runtime/lua/vim/_editor.lua @@ -31,6 +31,7 @@ for k, v in pairs({ loader = true, func = true, F = true, + img = true, lsp = true, hl = true, diagnostic = true, diff --git a/runtime/lua/vim/img.lua b/runtime/lua/vim/img.lua new file mode 100644 index 0000000000..c3961421b8 --- /dev/null +++ b/runtime/lua/vim/img.lua @@ -0,0 +1,14 @@ +local img = vim._defer_require('vim.img', { + _image = ..., --- @module 'vim.img._image' +}) + +---Loads an image into memory, returning a wrapper around the image. +--- +---Accepts `data` as base64-encoded bytes, or a `filename` that will be loaded. +---@param opts {data?:string, filename?:string} +---@return vim.img.Image +function img.load(opts) + return img._image:new(opts) +end + +return img diff --git a/runtime/lua/vim/img/_image.lua b/runtime/lua/vim/img/_image.lua new file mode 100644 index 0000000000..c7d8ba8b55 --- /dev/null +++ b/runtime/lua/vim/img/_image.lua @@ -0,0 +1,107 @@ +---@class vim.img.Image +---@field name string|nil name of the image if loaded from disk +---@field data string|nil base64 encoded data +local M = {} +M.__index = M + +---Creates a new image instance. +---@param opts? {data?:string, filename?:string} +---@return vim.img.Image +function M:new(opts) + opts = opts or {} + + local instance = {} + setmetatable(instance, M) + + instance.data = opts.data + if not instance.data and opts.filename then + instance:load_from_file(opts.filename) + end + + return instance +end + +---Returns true if the image is loaded into memory. +---@return boolean +function M:is_loaded() + return self.data ~= nil +end + +---Returns the size of the base64 encoded image. +---@return integer +function M:size() + return string.len(self.data or '') +end + +---Loads data for an image from a file, replacing any existing data. +---If a callback provided, will load asynchronously; otherwise, is blocking. +---@param filename string +---@param cb fun(err:string|nil, image:vim.img.Image|nil) +---@overload fun(filename:string):vim.img.Image +function M:load_from_file(filename, cb) + local name = vim.fn.fnamemodify(filename, ':t:r') + + if not cb then + local stat = vim.uv.fs_stat(filename) + assert(stat, 'unable to stat ' .. filename) + + local fd = vim.uv.fs_open(filename, 'r', 644) --[[ @type integer|nil ]] + assert(fd, 'unable to open ' .. filename) + + local data = vim.uv.fs_read(fd, stat.size, -1) --[[ @type string|nil ]] + assert(data, 'unable to read ' .. filename) + + self.name = name + self.data = vim.base64.encode(data) + return self + end + + ---@param err string|nil + ---@return boolean + local function report_err(err) + if err then + vim.schedule(function() + cb(err) + end) + end + + return err ~= nil + end + + vim.uv.fs_stat(filename, function(stat_err, stat) + if report_err(stat_err) then + return + end + if not stat then + report_err('missing stat') + return + end + + vim.uv.fs_open(filename, 'r', 644, function(open_err, fd) + if report_err(open_err) then + return + end + if not fd then + report_err('missing fd') + return + end + + vim.uv.fs_read(fd, stat.size, -1, function(read_err, data) + if report_err(read_err) then + return + end + + vim.uv.fs_close(fd, function() end) + + self.name = name + self.data = vim.base64.encode(data or '') + + vim.schedule(function() + cb(nil, self) + end) + end) + end) + end) +end + +return M From ce26f7c332c6e02723822fcf4292513d8ebea0ef Mon Sep 17 00:00:00 2001 From: Chip Senkbeil Date: Sat, 30 Nov 2024 13:55:51 -0600 Subject: [PATCH 2/7] feat(img): implement skeleton of `vim.img.show()` without backends Implement the skeleton of `vim.img.show()` without any backend implemented. --- runtime/lua/vim/img.lua | 30 ++++++++++++++++++++++++++++++ runtime/lua/vim/img/_backend.lua | 10 ++++++++++ runtime/lua/vim/img/_image.lua | 6 ++++++ 3 files changed, 46 insertions(+) create mode 100644 runtime/lua/vim/img/_backend.lua diff --git a/runtime/lua/vim/img.lua b/runtime/lua/vim/img.lua index c3961421b8..4110985f6b 100644 --- a/runtime/lua/vim/img.lua +++ b/runtime/lua/vim/img.lua @@ -1,4 +1,5 @@ local img = vim._defer_require('vim.img', { + _backend = ..., --- @module 'vim.img._backend' _image = ..., --- @module 'vim.img._image' }) @@ -11,4 +12,33 @@ function img.load(opts) return img._image:new(opts) end +---@class vim.img.Protocol 'iterm2'|'kitty'|'sixel' + +---@class vim.img.Opts: vim.img.Backend.RenderOpts +---@field backend? vim.img.Protocol|vim.img.Backend + +---Displays the image within the terminal used by neovim. +---@param image vim.img.Image +---@param opts? vim.img.Opts +function img.show(image, opts) + opts = opts or {} + + local backend = opts.backend + + -- For named protocols, grab the appropriate backend, failing + -- if there is not a default backend for the specified protocol. + if type(backend) == 'string' then + local protocol = backend + backend = img._backend[protocol] + assert(backend, 'unsupported backend: ' .. protocol) + end + + ---@cast backend vim.img.Backend + backend.render(image, { + pos = opts.pos, + size = opts.size, + crop = opts.crop, + }) +end + return img diff --git a/runtime/lua/vim/img/_backend.lua b/runtime/lua/vim/img/_backend.lua new file mode 100644 index 0000000000..1e85e54f6a --- /dev/null +++ b/runtime/lua/vim/img/_backend.lua @@ -0,0 +1,10 @@ +---@class vim.img.Backend +---@field render fun(image:vim.img.Image, opts?:vim.img.Backend.RenderOpts) + +---@class vim.img.Backend.RenderOpts +---@field crop? {x:integer, y:integer, width:integer, height:integer} units are pixels +---@field pos? {row:integer, col:integer} units are cells +---@field size? {width:integer, height:integer} units are cells + +return { +} diff --git a/runtime/lua/vim/img/_image.lua b/runtime/lua/vim/img/_image.lua index c7d8ba8b55..30df1b4a71 100644 --- a/runtime/lua/vim/img/_image.lua +++ b/runtime/lua/vim/img/_image.lua @@ -33,6 +33,12 @@ function M:size() return string.len(self.data or '') end +---Displays the image within the terminal used by neovim. +---@param opts? vim.img.Opts +function M:show(opts) + vim.img.show(self, opts) +end + ---Loads data for an image from a file, replacing any existing data. ---If a callback provided, will load asynchronously; otherwise, is blocking. ---@param filename string From c50d7747a5beb41570debed818c195691a2e276c Mon Sep 17 00:00:00 2001 From: Chip Senkbeil Date: Sat, 30 Nov 2024 14:24:05 -0600 Subject: [PATCH 3/7] feat(img): add vim.img._terminal utility for image backends Implement `vim.img._terminal` module that supports writing to the tty tied to neovim as well as basic operations to manipulate the cursor, needed for backend implementations. --- runtime/lua/vim/img.lua | 1 + runtime/lua/vim/img/_terminal.lua | 78 +++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 runtime/lua/vim/img/_terminal.lua diff --git a/runtime/lua/vim/img.lua b/runtime/lua/vim/img.lua index 4110985f6b..84eb2f5aea 100644 --- a/runtime/lua/vim/img.lua +++ b/runtime/lua/vim/img.lua @@ -1,6 +1,7 @@ local img = vim._defer_require('vim.img', { _backend = ..., --- @module 'vim.img._backend' _image = ..., --- @module 'vim.img._image' + _terminal = ..., --- @module 'vim.img._terminal' }) ---Loads an image into memory, returning a wrapper around the image. diff --git a/runtime/lua/vim/img/_terminal.lua b/runtime/lua/vim/img/_terminal.lua new file mode 100644 index 0000000000..7419c71f2d --- /dev/null +++ b/runtime/lua/vim/img/_terminal.lua @@ -0,0 +1,78 @@ +---@class vim.img.terminal +---@field private __tty_name string +local M = {} + +local TERM_CODE = { + BEL = '\x07', -- aka ^G + ESC = '\x1B', -- aka ^[ aka \033 +} + +---Retrieve the tty name used by the editor. +--- +---E.g. /dev/ttys008 +---@return string|nil +local function get_tty_name() + if vim.fn.has('win32') == 1 then + -- On windows, we use \\.\CON for reading and writing + return '\\\\.\\CON' + else + -- Linux/Mac: Use `tty` command, which reads the terminal name + -- in the form of something like /dev/ttys008 + local handle = io.popen('tty 2>/dev/null') + if not handle then + return nil + end + local result = handle:read('*a') + handle:close() + result = vim.fn.trim(result) + if result == '' then + return nil + end + return result + end +end + +---Returns the name of the tty associated with the terminal. +---@return string +function M.tty_name() + if not M.__tty_name then + M.__tty_name = assert(get_tty_name(), 'failed to read editor tty name') + end + + return M.__tty_name +end + +---Writes data to the editor tty. +---@param ... string|number +function M.write(...) + local handle = assert(io.open(M.tty_name(), 'w')) + handle:write(...) + handle:close() +end + +---@class vim.img.terminal.cursor +M.cursor = {} + +---@param x integer +---@param y integer +---@param save? boolean +function M.cursor.move(x, y, save) + if save then + M.cursor.save() + end + M.write(TERM_CODE.ESC .. '[' .. y .. ';' .. x .. 'H') + vim.uv.sleep(1) +end + +function M.cursor.save() + M.write(TERM_CODE.ESC .. '[s') +end + +function M.cursor.restore() + M.write(TERM_CODE.ESC .. '[u') +end + +---Terminal escape codes. +M.code = TERM_CODE + +return M From 2ad8324092007b51420d8d4e178210c8b1931509 Mon Sep 17 00:00:00 2001 From: Chip Senkbeil Date: Sat, 30 Nov 2024 14:50:08 -0600 Subject: [PATCH 4/7] feat(img): add `for_each_chunk` to `vim.image.Image` class Add `for_each_chunk()` for instances of `vim.img.Image`. This method streamlines chunked iteration of image bytes, which is important when working with ssh or tmux and a protocol that supports chunked image rendering such as `iterm2` or `kitty`. --- runtime/lua/vim/img/_image.lua | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/runtime/lua/vim/img/_image.lua b/runtime/lua/vim/img/_image.lua index 30df1b4a71..c8520c639d 100644 --- a/runtime/lua/vim/img/_image.lua +++ b/runtime/lua/vim/img/_image.lua @@ -33,6 +33,37 @@ function M:size() return string.len(self.data or '') end +---Iterates over the chunks of the image, invoking `f` per chunk. +---@param f fun(chunk:string, pos:integer, has_more:boolean) +---@param opts? {size?:integer} +function M:for_each_chunk(f, opts) + opts = opts or {} + + -- Chunk size, defaulting to 4k + local chunk_size = opts.size or 4096 + + local data = self.data + if not data then + return + end + + local pos = 1 + local len = string.len(data) + while pos <= len do + -- Get our next chunk from [pos, pos + chunk_size) + local end_pos = pos + chunk_size - 1 + local chunk = data:sub(pos, end_pos) + + -- If we have a chunk available, invoke our callback + if string.len(chunk) > 0 then + local has_more = end_pos + 1 <= len + pcall(f, chunk, pos, has_more) + end + + pos = end_pos + 1 + end +end + ---Displays the image within the terminal used by neovim. ---@param opts? vim.img.Opts function M:show(opts) From 021b8f6e5d0e850802cdc344d16b86e725cd82a0 Mon Sep 17 00:00:00 2001 From: Chip Senkbeil Date: Sat, 30 Nov 2024 14:50:08 -0600 Subject: [PATCH 5/7] feat(img): add iterm2 backend Implement the iterm2 backend, supporting both iTerm 3.5+ support for multipart images, and falling back to older protocol that sends the entire image at once, which is needed for support on other terminals such as WezTerm. --- runtime/lua/vim/img/_backend.lua | 5 +- runtime/lua/vim/img/_backend/iterm2.lua | 100 ++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 2 deletions(-) create mode 100644 runtime/lua/vim/img/_backend/iterm2.lua diff --git a/runtime/lua/vim/img/_backend.lua b/runtime/lua/vim/img/_backend.lua index 1e85e54f6a..058f0bdf84 100644 --- a/runtime/lua/vim/img/_backend.lua +++ b/runtime/lua/vim/img/_backend.lua @@ -6,5 +6,6 @@ ---@field pos? {row:integer, col:integer} units are cells ---@field size? {width:integer, height:integer} units are cells -return { -} +return vim._defer_require('vim.img._backend', { + iterm2 = ..., --- @module 'vim.img._backend.iterm2' +}) diff --git a/runtime/lua/vim/img/_backend/iterm2.lua b/runtime/lua/vim/img/_backend/iterm2.lua new file mode 100644 index 0000000000..e1a12d6e4c --- /dev/null +++ b/runtime/lua/vim/img/_backend/iterm2.lua @@ -0,0 +1,100 @@ +---@class vim.img.Iterm2Backend: vim.img.Backend +local M = {} + +---@param data string +local function write_seq(data) + local terminal = require('vim.img._terminal') + + terminal.write(terminal.code.ESC) -- Begin sequence + terminal.write(']1337;') + + terminal.write(data) -- Write primary message + + terminal.write(terminal.code.BEL) -- End sequence +end + +---@param image vim.img.Image +---@param args table +local function write_multipart_image(image, args) + -- Begin the transfer of the image file + write_seq('MultipartFile=' .. table.concat(args, ';')) + + -- Begin sending parts as chunks + image:for_each_chunk(function(chunk) + write_seq('FilePart=' .. chunk) + end) + + -- Conclude the image display + write_seq('FileEnd') +end + +---@param image vim.img.Image +---@param args table +local function write_image(image, args) + local data = image.data + if not data then + return + end + + write_seq('File=' .. table.concat(args, ';') .. ':' .. data) +end + +---@param image vim.img.Image +---@param opts? vim.img.Backend.RenderOpts +function M.render(image, opts) + local terminal = require('vim.img._terminal') + + if not image:is_loaded() then + return + end + + opts = opts or {} + if opts.pos then + terminal.cursor.move(opts.pos.col, opts.pos.row, true) + end + + local args = { + -- NOTE: We MUST mark as inline otherwise not rendered and put in a downloads folder + 'inline=1', + + -- This will show a progress indicator for a multipart image + 'size=' .. tostring(image:size()), + } + + -- Specify the name of the image, which iterm2 requires to be base64 encoded + if image.name then + table.insert(args, 'name=' .. vim.base64.encode(image.name)) + end + + -- If a size is provided (in cells), we add it as arguments + if opts.size then + table.insert(args, 'width=' .. tostring(opts.size.width)) + table.insert(args, 'height=' .. tostring(opts.size.height)) + + -- We need to disable aspect ratio preservation, otherwise + -- the desired width/height won't be respected + table.insert(args, 'preserveAspectRatio=0') + end + + -- Only iTerm2 3.5+ supports multipart images + -- + -- WezTerm and others are assumed to NOT support multipart images + -- + -- iTerm2 should have set TERM_PROGRAM and TERM_PROGRAM_VERSION, + -- otherwise we assume a different terminal! + ---@type string|nil + local prog = vim.env.TERM_PROGRAM + ---@type vim.Version|nil + local version = vim.version.parse(vim.env.TERM_PROGRAM_VERSION or '') + if prog == 'iTerm.app' and version and vim.version.ge(version, { 3, 5, 0 }) then + write_multipart_image(image, args) + else + write_image(image, args) + end + + if opts.pos then + terminal.cursor.restore() + end +end + +return M From a56a5f0117826362df0fde9422ff2c2ad3c0afd7 Mon Sep 17 00:00:00 2001 From: Chip Senkbeil Date: Sun, 1 Dec 2024 14:23:39 -0600 Subject: [PATCH 6/7] feat(img): add kitty backend Implement the kitty graphics protocol as a backend, using kitty's chunked image rendering, which should work within tmux and ssh if we keep the chunks small enough. --- runtime/lua/vim/img/_backend.lua | 1 + runtime/lua/vim/img/_backend/kitty.lua | 108 +++++++++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 runtime/lua/vim/img/_backend/kitty.lua diff --git a/runtime/lua/vim/img/_backend.lua b/runtime/lua/vim/img/_backend.lua index 058f0bdf84..a1268f0f63 100644 --- a/runtime/lua/vim/img/_backend.lua +++ b/runtime/lua/vim/img/_backend.lua @@ -8,4 +8,5 @@ return vim._defer_require('vim.img._backend', { iterm2 = ..., --- @module 'vim.img._backend.iterm2' + kitty = ..., --- @module 'vim.img._backend.kitty' }) diff --git a/runtime/lua/vim/img/_backend/kitty.lua b/runtime/lua/vim/img/_backend/kitty.lua new file mode 100644 index 0000000000..d311ffa3b5 --- /dev/null +++ b/runtime/lua/vim/img/_backend/kitty.lua @@ -0,0 +1,108 @@ +---@class vim.img.KittyBackend: vim.img.Backend +local M = {} + +---For kitty, we need to write an image in chunks +--- +---Graphics codes are in this form: +--- +--- _G;\ +--- +---To stream data for a PNG, we specify the format `f=100`. +--- +---To simultaneously transmit and display an image, we use `a=T`. +--- +---Chunking data (such as from over a network) requires the +---specification of `m=0|1`, where all chunks must have a +---value of `1` except the very last chunk. +---@param data string +local function write_seq(data) + local terminal = require('vim.img._terminal') + + terminal.write(terminal.code.ESC .. '_G') -- Begin sequence + terminal.write(data) -- Primary data + terminal.write(terminal.code.ESC .. '\\') -- End sequence +end + +---Builds a header table of key value pairs. +---@param opts vim.img.Backend.RenderOpts +---@return table +local function make_header(opts) + ---@type table + local header = {} + + header['a'] = 'T' + header['f'] = '100' + + local crop = opts.crop + local size = opts.size + + if crop then + header['x'] = tostring(crop.x) + header['y'] = tostring(crop.y) + header['w'] = tostring(crop.width) + header['h'] = tostring(crop.height) + end + + if size then + header['c'] = tostring(size.width) + header['r'] = tostring(size.height) + end + + return header +end + +---@param image vim.img.Image +---@param opts vim.img.Backend.RenderOpts +local function write_multipart_image(image, opts) + image:for_each_chunk(function(chunk, pos, has_more) + local data = {} + + -- If at the beginning of our image, mark as a PNG to be + -- transmitted and displayed immediately + if pos == 1 then + -- Add an entry in our data to write out to the terminal + -- that is "k=v," for the key-value entries from the header + for key, value in pairs(make_header(opts)) do + table.insert(data, key .. '=' .. value .. ',') + end + end + + -- If we are still sending chunks and not at the end + if has_more then + table.insert(data, 'm=1') + else + table.insert(data, 'm=0') + end + + -- If we have a chunk available, write it + if string.len(chunk) > 0 then + table.insert(data, ';') + table.insert(data, chunk) + end + + write_seq(table.concat(data)) + end) +end + +---@param image vim.img.Image +---@param opts? vim.img.Backend.RenderOpts +function M.render(image, opts) + local terminal = require('vim.img._terminal') + + if not image:is_loaded() then + return + end + + opts = opts or {} + if opts.pos then + terminal.cursor.move(opts.pos.col, opts.pos.row, true) + end + + write_multipart_image(image, opts) + + if opts.pos then + terminal.cursor.restore() + end +end + +return M From ce818a9a914b3c8ddd0cf747238ae5a4d0a18671 Mon Sep 17 00:00:00 2001 From: Chip Senkbeil Date: Sun, 1 Dec 2024 14:23:39 -0600 Subject: [PATCH 7/7] 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`. --- runtime/lua/vim/img.lua | 35 +++++++++++- runtime/lua/vim/img/_detect.lua | 88 +++++++++++++++++++++++++++++++ runtime/lua/vim/img/_terminal.lua | 62 ++++++++++++++++++++++ 3 files changed, 184 insertions(+), 1 deletion(-) create mode 100644 runtime/lua/vim/img/_detect.lua 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