diff --git a/README.md b/README.md index 7efc728..b944754 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,10 @@ No ImageMagick. No luarocks. No external binaries. Pure Lua on Neovim >= 0.10. - tmux is explicitly unsupported in v0.x (blit no-ops under tmux) - GUI frontends / `--embed` (e.g. Neovide) are unsupported (blit no-ops) -Run `:checkhealth blit` to see detection results for your environment. +Run `:checkhealth blit` to see detection results for your environment. On +Neovim >= 0.12 it also lists any error the terminal sent back for an image +(e.g. a PNG it rejected); on 0.10 / 0.11 those errors are not available. +WezTerm sends no such error for a rejected PNG, so none is listed there. ## Install @@ -89,6 +92,10 @@ general questions go in [Discussions](https://github.com/optiflowic/blit.nvim/di documented as an anchor byte column and silently ignored; it is now validated as a non-negative integer, so a negative or fractional `col` that used to be accepted raises an argument error. +- On Neovim >= 0.12, error responses from the terminal (e.g. a rejected + PNG) are recorded and listed by `:checkhealth blit` + ([#12](https://github.com/optiflowic/blit.nvim/issues/12)). Neovim 0.10 / + 0.11 behave as before. ### v0.2.0 (2026-08-08) diff --git a/doc/blit.txt b/doc/blit.txt index e964e9b..0faff17 100644 --- a/doc/blit.txt +++ b/doc/blit.txt @@ -31,7 +31,10 @@ hidden only once it has no overlap with the window at all. See - tmux is explicitly unsupported in v0.x; blit no-ops under tmux - GUI frontends and `--embed` (e.g. Neovide) are unsupported; blit no-ops -Run `:checkhealth blit` to see detection results for your environment. +Run `:checkhealth blit` to see detection results for your environment. On +Neovim >= 0.12 it also lists any error the terminal sent back for an image +(e.g. a PNG it rejected); on 0.10 / 0.11 those errors are not available. +WezTerm sends no such error for a rejected PNG, so none is listed there. ============================================================================== 3. Setup *blit-setup* diff --git a/docs/manual-testing.md b/docs/manual-testing.md index 5adbc71..761f89b 100644 --- a/docs/manual-testing.md +++ b/docs/manual-testing.md @@ -75,6 +75,15 @@ WezTerm (and Ghostty when available) before tagging a release. original tab restores it (issue #16). - [ ] Quitting Neovim (`:qa`) leaves no stray image on screen after exit. - [ ] `:checkhealth blit` reports this terminal as supported. +- [ ] (Neovim >= 0.12) After normal use — show, scroll, resize, clear — + typing still works normally (no stray characters inserted) and + `:checkhealth blit` lists no "terminal rejected image" error. +- [ ] (Neovim >= 0.12) `show()` a file with a valid PNG signature and IHDR + but corrupt image data: no image appears, and on kitty and Ghostty + `:checkhealth blit` lists a timestamped "terminal rejected image" + error naming that file. WezTerm sends no error response for this + case, so nothing is listed there (see + `docs/spec/kitty-graphics.md`'s "Per-terminal quirks"). ## WezTerm diff --git a/docs/spec/kitty-graphics.md b/docs/spec/kitty-graphics.md index ea1f53f..092d321 100644 --- a/docs/spec/kitty-graphics.md +++ b/docs/spec/kitty-graphics.md @@ -27,7 +27,7 @@ Every command is an APC (Application Program Command) escape sequence: | `f` | pixel format | `100` (PNG) — only format blit ever sends, per the PNG-only v0.x constraint | | `t` | transmission medium | `d` (direct, i.e. the payload is in the escape code itself) — blit never uses file-based (`t=f`) or shared-memory transmission, to avoid any filesystem/IPC surface beyond reading the source PNG | | `i` | image id | one of blit's reserved range, see below | -| `q` | quiet | `2` (suppress all responses) always, see "Response handling" below | +| `q` | quiet | when responses can be received: `1` (suppress `OK`, keep errors) on transmits (`a=T`/`a=t`) and standalone placements (`a=p`). Otherwise `2` (suppress everything) on transmits and no `q` key on placements. Never on deletes — see "Response handling" below | | `m` | more chunks | `1` (more chunks follow) / `0` (last chunk) | | `p` | placement id | a per-handle id, distinct across every concurrently-live placement — see "Placement" below | | `c`, `r` | placement columns/rows | shrink to the visible cell span when a placement is partially clipped, see "Source-rectangle cropping" below | @@ -199,16 +199,55 @@ facts; the stateful counter that actually hands out ids from this range is ## Response handling -blit always sets `q=2` (suppress all responses — no `OK` and no error -response is sent back by the terminal). This is a deliberate v0.x -limitation: blit does not read stdin asynchronously to parse protocol -responses, so any response bytes that did arrive would otherwise leak into -Neovim's normal input stream. The consequence is that blit cannot currently -detect terminal-side transmission errors (e.g. malformed PNG rejected by the -terminal) — failures are only visible if they cause a visible rendering -problem. Revisiting this (async stdin reader surfacing errors through -`:checkhealth` or return values) is a future-version consideration, not -implemented speculatively here. +The terminal answers a graphics command that carries an `i=` with an APC +of its own: `ESC _ G i=[,p=] ; ESC \`, where +`` is `OK` or `:` (e.g. `EBADPNG:...`, `ENOENT:...`). +The `q` key controls which of these are sent: `q=1` suppresses `OK`, `q=2` +suppresses errors too. + +blit never reads stdin itself — Neovim's TUI owns it, and a second reader +would race it for input bytes. The only safe channel is Neovim's own +`TermResponse` event, which delivers APC responses from Neovim 0.12 onward +(0.10/0.11 deliver OSC/DCS only). The sequence arrives as +`ESC _ G ; ` with the terminating ST already stripped. +`terminal.has_response_support()` reports whether that channel exists, and +`terminal.parse_response()` decodes one sequence, returning nil for +anything that is not a graphics response about an id in blit's reserved +range. + +- **Neovim >= 0.12**: transmits carry `q=1`, so only error responses come + back. `renderer.lua` records them (see + `docs/spec/renderer-placement.md`'s "Terminal error responses") and + `:checkhealth blit` lists them. +- **Neovim 0.10 / 0.11**: transmits carry `q=2`, exactly as before; a + terminal-side transmission error stays invisible unless it shows up as a + rendering problem. + +Standalone placement (`a=p`) commands follow the same split, with one +difference on old versions: + +- **Neovim >= 0.12**: `a=p` carries `q=1`. Without it the terminal may + acknowledge every re-placement with an `OK`, i.e. one `TermResponse` + (for blit's listener and every other plugin's) per re-placed handle on + every redraw pass, only to be discarded. Error answers still come back + and the same listener records them, which surfaces e.g. an `ENOENT` for + a placement against an id whose pixel data the terminal has dropped. +- **Neovim 0.10 / 0.11**: `a=p` carries no `q` key, so the terminal's + default applies and it may answer with `OK` or an error. These bytes + have always been emitted this way, and no answer has been observed + leaking into Neovim's input as keystrokes (`docs/manual-testing.md` + checks this). + +Delete (`a=d`) commands carry no `q` key on any version. + +`has_response_support()` is a version check (`has("nvim-0.12")`), so it +assumes a released 0.12.0 or later; 0.12 pre-release builds that predate +the APC `TermResponse` support are not accounted for. + +Responses are diagnostic only. They arrive asynchronously, after `show()` +has returned, so they cannot become a `nil, err` return value, and no +recovery path (Ghostty retransmit, delete retries) waits on or reacts to +them. ## Per-terminal quirks @@ -223,6 +262,23 @@ implemented speculatively here. battle-tested of the three supported terminals. `docs/manual-testing.md` should weight Ghostty checks accordingly before any release. +Error responses under `q=1`, observed on Neovim 0.12.5 by transmitting a +PNG with a valid signature and IHDR but a corrupt IDAT (`a=T`), then +letting the next redraw pass re-place it (`a=p`): + +| terminal | answer to the transmit | answer to the later `a=p` | +|---|---|---| +| kitty 0.48.2 | `EBADPNG:IDAT: incorrect header check` | `ENOENT:Put command refers to image with id: that could not load its data` | +| Ghostty 1.3.1 | `EINVAL: invalid data` | `ENOENT: image not found` | +| WezTerm 20260716-195552-76b606ec | none | none | + +So the error code and text are terminal-specific (blit records the message +verbatim and never matches on it), and WezTerm reports nothing for this +case: on WezTerm a rejected PNG stays invisible to `:checkhealth blit`, +exactly as on Neovim 0.10 / 0.11. On all three, show + scroll + split +with a valid PNG produced no response at all (no `OK` under `q=1`, no +error) and nothing leaked into Neovim's input. + ## Source This memo is derived from the upstream [kitty graphics protocol diff --git a/docs/spec/renderer-placement.md b/docs/spec/renderer-placement.md index b7a6ba7..4fd16d1 100644 --- a/docs/spec/renderer-placement.md +++ b/docs/spec/renderer-placement.md @@ -479,6 +479,31 @@ created for them. Like `debounce_timer`, `ghostty_retransmit_timer` is stopped and closed by `maybe_teardown_autocmds()` once zero handles remain, preserving the "no timers active when zero images are displayed" rule. +## Terminal error responses + +On Neovim >= 0.12 (`terminal.has_response_support()`, see +`docs/spec/kitty-graphics.md`'s "Response handling"), `ensure_autocmds` +adds one `TermResponse` autocmd to the `blit` augroup. Its callback hands +every sequence to `terminal.parse_response()` and records the result when +it is an error (`ok == false`) for an id blit currently owns (`used_ids`); +`OK` responses, foreign ids, and unrelated OSC/DCS responses are ignored. + +Recorded errors live in a bounded list (the most recent 20, oldest +dropped first) of `{ id, placement_id?, path?, message, time }`; `path` +comes from a live handle using that id and is nil once none does, and +`time` is the `os.time()` the response arrived at. +`renderer.response_errors()` returns a copy, and `:checkhealth blit` +prints each entry prefixed with that time, so an old entry can be told +apart from a current one. The list is never cleared during a session — it is a +diagnostic log, not handle state — and recording an error changes nothing +about the handle, its extmark, or the transmission cache. + +The listener belongs to the handle-gated augroup, so it is removed with +the last handle like every other autocmd (no listener while idle). An +error that arrives after that teardown is not recorded. + +On Neovim 0.10 / 0.11 no listener is registered and the list stays empty. + ## Lifecycle Two distinct kinds of state transition, kept separate: diff --git a/docs/spec/terminal-detection.md b/docs/spec/terminal-detection.md index 83a2764..1e9734d 100644 --- a/docs/spec/terminal-detection.md +++ b/docs/spec/terminal-detection.md @@ -10,9 +10,9 @@ rather than re-deriving detection logic ad hoc. blit must never emit escape sequences to a terminal that won't understand them (garbage on screen, or worse, sequences interpreted as something else). Detection is env-var based — no runtime protocol query (`a=q`) is -used, since that would require asynchronously reading stdin for the -terminal's response, which blit does not do in v0.x (see the "Response -handling" section of `docs/spec/kitty-graphics.md`). +used: a query answer can only be received through Neovim's `TermResponse` +event on Neovim >= 0.12 (see the "Response handling" section of +`docs/spec/kitty-graphics.md`), and detection must work on 0.10 as well. ## Detection matrix @@ -143,6 +143,7 @@ to consume the whole buffer in one call regardless of blocking mode, so DA1 (`\x1b[c`) or XTGETTCAP queries could provide a stronger capability check than env vars alone, but require reading a terminal response -asynchronously — the same complexity blit avoids for protocol responses in -general (see `docs/spec/kitty-graphics.md`). Out of scope until a measured -need (real-world false detection reports) justifies the added complexity. +asynchronously. The `TermResponse` channel blit uses for graphics error +responses (see `docs/spec/kitty-graphics.md`) could carry these too, on +Neovim >= 0.12 only. Out of scope until a measured need (real-world false +detection reports) justifies the added complexity. diff --git a/lua/blit/health.lua b/lua/blit/health.lua index 7f84cd6..08596ed 100644 --- a/lua/blit/health.lua +++ b/lua/blit/health.lua @@ -3,6 +3,7 @@ -- docs/spec/terminal-detection.md for the detection matrix this reflects. local terminal = require("blit.terminal") +local renderer = require("blit.renderer") local M = {} @@ -39,12 +40,29 @@ function M.check() vim.health.warn(WEZTERM_CRASH_WARNING) end - if caps.supported then - vim.health.ok("blit is supported in this environment") - else + if not caps.supported then vim.health.warn( REASON_MESSAGES[caps.reason] or ("blit is unsupported: " .. tostring(caps.reason)) ) + return + end + vim.health.ok("blit is supported in this environment") + + if terminal.has_response_support() then + vim.health.ok("Terminal error responses are reported here (Neovim >= 0.12)") + else + vim.health.info("Terminal error responses need Neovim >= 0.12; they stay suppressed") + end + + for _, response_error in ipairs(renderer.response_errors()) do + vim.health.error( + ("[%s] terminal rejected image id %d (%s): %s"):format( + os.date("%H:%M:%S", response_error.time), + response_error.id, + response_error.path or "no longer displayed", + response_error.message + ) + ) end end diff --git a/lua/blit/renderer.lua b/lua/blit/renderer.lua index 99c83e7..046caa3 100644 --- a/lua/blit/renderer.lua +++ b/lua/blit/renderer.lua @@ -66,6 +66,43 @@ M._redraw_fn = function() vim.cmd("redraw") end +-- Overridable seam for tests: production code always asks terminal.lua +-- whether this Neovim can deliver protocol responses at all. +M._has_response_support_fn = terminal.has_response_support + +-- Terminal error responses ---------------------------------------------------- +-- See docs/spec/renderer-placement.md's "Terminal error responses" section. + +local MAX_RESPONSE_ERRORS = 20 + +---@class blit.ResponseError +---@field id integer +---@field placement_id? integer +---@field path? string nil when no live handle references the id anymore +---@field message string the terminal's `:` error string +---@field time integer `os.time()` when the response arrived + +---@type blit.ResponseError[] +local response_errors = {} + +-- `q=1` keeps `OK` suppressed but lets error responses through; only worth +-- asking for when something can actually receive them. +---@return 1|2 +local function transmit_quiet() + return M._has_response_support_fn() and 1 or 2 +end + +---@param id integer +---@return string? +local function path_for_id(id) + for _, handle in ipairs(M._handles) do + if handle.id == id then + return handle.path + end + end + return nil +end + -- Image id allocation --------------------------------------------------------- -- Pure logic over terminal.lua's reserved range. Ids are handed out -- sequentially and only returned to the free pool by destroy_handle's @@ -595,6 +632,21 @@ local function placement_opts(handle, placement) } end +-- Options for a standalone `a=p`: with a response listener in place, `q=1` +-- drops the per-placement `OK` (one TermResponse per re-placed handle on +-- every redraw pass) while keeping errors. Without one the bytes stay as +-- they always were (no `q` key). +---@param handle blit.Handle +---@param placement blit.PlacementResult +---@return blit.terminal.PlacementOpts +local function standalone_placement_opts(handle, placement) + local opts = placement_opts(handle, placement) + if M._has_response_support_fn() then + opts.quiet = 1 + end + return opts +end + ---@param handle blit.Handle ---@param placement blit.PlacementResult ---@return boolean ok @@ -603,7 +655,7 @@ local function place_existing(handle, placement) local sequences = { terminal.build_save_cursor(), terminal.build_move_cursor(placement.screen_row, placement.screen_col), - terminal.build_placement(handle.id, placement_opts(handle, placement)), + terminal.build_placement(handle.id, standalone_placement_opts(handle, placement)), terminal.build_restore_cursor(), } local ok, err = M._write_fn(sequences) @@ -729,6 +781,7 @@ local function retransmit_and_place_group(handles) terminal.build_transmit(bytes, { id = new_id, action = "T", + quiet = transmit_quiet(), placement = placement_opts(display_handle, placements[display_handle]), }) ) @@ -737,7 +790,10 @@ local function retransmit_and_place_group(handles) -- No handle sharing this id is currently visible; keep the data ready -- (transmit-only) so whichever handle becomes visible next places -- correctly against the new id without needing its own re-transmit. - vim.list_extend(sequences, terminal.build_transmit(bytes, { id = new_id, action = "t" })) + vim.list_extend( + sequences, + terminal.build_transmit(bytes, { id = new_id, action = "t", quiet = transmit_quiet() }) + ) end for _, h in ipairs(handles) do @@ -745,7 +801,7 @@ local function retransmit_and_place_group(handles) vim.list_extend(sequences, { terminal.build_save_cursor(), terminal.build_move_cursor(placements[h].screen_row, placements[h].screen_col), - terminal.build_placement(new_id, placement_opts(h, placements[h])), + terminal.build_placement(new_id, standalone_placement_opts(h, placements[h])), terminal.build_restore_cursor(), }) end @@ -1117,6 +1173,24 @@ local function on_vim_leave_pre() terminal.reset_writer() end +---@param sequence any the TermResponse event's `sequence` +local function record_response_error(sequence) + local response = terminal.parse_response(sequence) + if not response or response.ok or not used_ids[response.id] then + return + end + table.insert(response_errors, { + id = response.id, + placement_id = response.placement_id, + path = path_for_id(response.id), + message = response.message, + time = os.time(), + }) + if #response_errors > MAX_RESPONSE_ERRORS then + table.remove(response_errors, 1) + end +end + local function ensure_autocmds() if autocmds_ready then return @@ -1154,6 +1228,15 @@ local function ensure_autocmds() group = group, callback = on_vim_leave_pre, }) + + if M._has_response_support_fn() then + vim.api.nvim_create_autocmd("TermResponse", { + group = group, + callback = function(args) + record_response_error(type(args.data) == "table" and args.data.sequence or nil) + end, + }) + end end ---@param v any @@ -1383,14 +1466,19 @@ function M.show(path, opts) ) vim.list_extend( sequences, - terminal.build_transmit( - bytes, - { id = id, action = "T", placement = placement_opts(handle, placement) } - ) + terminal.build_transmit(bytes, { + id = id, + action = "T", + quiet = transmit_quiet(), + placement = placement_opts(handle, placement), + }) ) table.insert(sequences, terminal.build_restore_cursor()) else - vim.list_extend(sequences, terminal.build_transmit(bytes, { id = id, action = "t" })) + vim.list_extend( + sequences, + terminal.build_transmit(bytes, { id = id, action = "t", quiet = transmit_quiet() }) + ) end local ok, err = M._write_fn(sequences) if not ok then @@ -1420,6 +1508,13 @@ function M.show(path, opts) return handle end +-- Error responses the terminal sent back for blit's own ids, oldest first +-- (bounded to the most recent few). Always empty on Neovim < 0.12. +---@return blit.ResponseError[] +function M.response_errors() + return vim.deepcopy(response_errors) +end + ---@param handle blit.Handle function M.clear(handle) vim.validate({ handle = { handle, "table" } }) @@ -1453,6 +1548,8 @@ function M._reset() used_ids = {} next_id = terminal.ID_RANGE_START next_placement_id = 1 + response_errors = {} + M._has_response_support_fn = terminal.has_response_support M._write_fn = function(sequences) return terminal.write(sequences) end diff --git a/lua/blit/terminal.lua b/lua/blit/terminal.lua index 25f26bd..16e90d1 100644 --- a/lua/blit/terminal.lua +++ b/lua/blit/terminal.lua @@ -22,6 +22,7 @@ local APC_END = ESC .. "\\" ---@field crop_y? integer source rectangle pixel offset from the transmitted image's top edge ---@field crop_w? integer source rectangle pixel width ---@field crop_h? integer source rectangle pixel height +---@field quiet? 1|2 `q=` for a standalone `a=p` (build_placement only); omitted = no `q` key ---@class blit.terminal.TransmitOpts ---@field id integer @@ -189,8 +190,18 @@ function M.build_placement(id, opts) id = { id, M.is_valid_id, "a valid id in blit's reserved range" }, opts = { opts, "table" }, placement_id = { opts.placement_id, is_positive_integer, "a positive integer" }, + quiet = { + opts.quiet, + function(v) + return v == nil or v == 1 or v == 2 + end, + "1, 2, or nil", + }, }) local parts = { { "a", "p" }, { "i", id }, { "p", opts.placement_id } } + if opts.quiet then + parts[#parts + 1] = { "q", opts.quiet } + end append_placement_parts(parts, opts) return APC_START .. build_control(parts) .. APC_END end @@ -223,6 +234,57 @@ function M.build_delete(id, opts) return APC_START .. build_control(parts) .. APC_END end +-- Response parsing (pure, no I/O) --------------------------------------------- +-- See docs/spec/kitty-graphics.md's "Response handling" section. + +---@class blit.terminal.Response +---@field id integer +---@field placement_id? integer +---@field ok boolean +---@field message string `OK`, or the terminal's `:` error string + +-- Parses one kitty graphics protocol response as Neovim's TermResponse +-- event delivers it: `ESC _ G ; `, with the trailing ST +-- already stripped (tolerated here anyway). Returns nil for anything that +-- is not a graphics response about an id in blit's reserved range, so a +-- caller can feed it every TermResponse sequence unfiltered. +---@param sequence any +---@return blit.terminal.Response? +function M.parse_response(sequence) + if type(sequence) ~= "string" or sequence:sub(1, #APC_START) ~= APC_START then + return nil + end + local body = sequence:sub(#APC_START + 1) + if body:sub(-#APC_END) == APC_END then + body = body:sub(1, -#APC_END - 1) + end + local separator = body:find(";", 1, true) + if not separator then + return nil + end + local keys = {} + for key, value in body:sub(1, separator - 1):gmatch("(%a)=(%d+)") do + keys[key] = tonumber(value) + end + if not M.is_valid_id(keys.i) then + return nil + end + local message = body:sub(separator + 1) + return { id = keys.i, placement_id = keys.p, ok = message == "OK", message = message } +end + +-- Whether this Neovim delivers APC responses through TermResponse (0.12+). +-- Assumes a released 0.12.0 or later: `has("nvim-0.12")` is also true on +-- 0.12 pre-release builds that predate the APC support. +-- Older versions only deliver OSC/DCS, so blit keeps every response +-- suppressed there — see docs/spec/kitty-graphics.md's "Response handling". +---@param has? fun(feature: string): integer +---@return boolean +function M.has_response_support(has) + has = has or vim.fn.has + return has("nvim-0.12") == 1 +end + -- Cursor positioning ---------------------------------------------------------- -- Regular (non-unicode-placeholder) kitty placements render at the -- terminal's current cursor position at the moment the placement command is diff --git a/tests/test_renderer.lua b/tests/test_renderer.lua index 84c9694..eded319 100644 --- a/tests/test_renderer.lua +++ b/tests/test_renderer.lua @@ -1341,4 +1341,83 @@ T["redraw"]["non-ghostty: never retransmits a still-visible handle after a resiz MiniTest.expect.equality(first_all:find("a=p", 1, true) ~= nil, true) end +T["response_errors"] = MiniTest.new_set() + +---@param sequence string +local function deliver_response(sequence) + vim.api.nvim_exec_autocmds("TermResponse", { group = "blit", data = { sequence = sequence } }) +end + +---@param supported boolean +---@return blit.Handle +local function show_with_response_support(supported) + renderer._has_response_support_fn = function() + return supported + end + local buf, win = setup_floating(numbered_lines(10), 20, 10) + return renderer.show(tmp_path, { width = 5, height = 3, buf = buf, win = win, lnum = 2, col = 0 }) +end + +T["response_errors"]["supported: transmits with q=1 and records an error response"] = function() + local handle = show_with_response_support(true) + MiniTest.expect.equality(table.concat(captured[1], ""):find(",q=1,", 1, true) ~= nil, true) + + deliver_response(ESC .. "_Gi=" .. handle.id .. ";EBADPNG:bad data") + + local errors = renderer.response_errors() + MiniTest.expect.equality(type(errors[1].time), "number") + errors[1].time = nil + MiniTest.expect.equality(errors, { + { id = handle.id, path = tmp_path, message = "EBADPNG:bad data" }, + }) +end + +T["response_errors"]["ignores OK, unowned ids, and unrelated sequences"] = function() + local handle = show_with_response_support(true) + + deliver_response(ESC .. "_Gi=" .. handle.id .. ",p=" .. handle.placement_id .. ";OK") + deliver_response(ESC .. "_Gi=" .. (handle.id + 1) .. ";ENOENT:not ours") + deliver_response(ESC .. "]11;rgb:0000/0000/0000") + + MiniTest.expect.equality(renderer.response_errors(), {}) +end + +T["response_errors"]["keeps only the most recent errors"] = function() + local handle = show_with_response_support(true) + + for n = 1, 25 do + deliver_response(ESC .. "_Gi=" .. handle.id .. ";EINVAL:" .. n) + end + + local errors = renderer.response_errors() + MiniTest.expect.equality(#errors, 20) + MiniTest.expect.equality(errors[1].message, "EINVAL:6") + MiniTest.expect.equality(errors[20].message, "EINVAL:25") +end + +T["response_errors"]["unsupported: transmits with q=2 and never listens"] = function() + show_with_response_support(false) + + MiniTest.expect.equality(table.concat(captured[1], ""):find(",q=2,", 1, true) ~= nil, true) + MiniTest.expect.equality( + #vim.api.nvim_get_autocmds({ group = "blit", event = "TermResponse" }), + 0 + ) +end + +T["response_errors"]["listener is torn down with the last handle"] = function() + local handle = show_with_response_support(true) + MiniTest.expect.equality( + #vim.api.nvim_get_autocmds({ group = "blit", event = "TermResponse" }), + 1 + ) + + renderer.clear(handle) + vim.wait(200, function() + return not pcall(vim.api.nvim_get_autocmds, { group = "blit" }) + end) + + MiniTest.expect.equality(pcall(vim.api.nvim_get_autocmds, { group = "blit" }), false) +end + return T diff --git a/tests/test_terminal_escape.lua b/tests/test_terminal_escape.lua index 1d802ee..158fa6b 100644 --- a/tests/test_terminal_escape.lua +++ b/tests/test_terminal_escape.lua @@ -151,6 +151,11 @@ T["build_placement"]["minimal"] = function() MiniTest.expect.equality(seq, ESC .. "_Ga=p,i=" .. ID .. ",p=3" .. ESC .. "\\") end +T["build_placement"]["quiet adds q right after the placement id"] = function() + local seq = terminal.build_placement(ID, { placement_id = 3, quiet = 1, columns = 10, rows = 5 }) + MiniTest.expect.equality(seq, ESC .. "_Ga=p,i=" .. ID .. ",p=3,q=1,c=10,r=5" .. ESC .. "\\") +end + T["build_placement"]["requires a positive integer placement_id"] = function() local ok = pcall(terminal.build_placement, ID, {}) MiniTest.expect.equality(ok, false) @@ -261,4 +266,54 @@ T["is_valid_id"]["rejects non-integer and non-number input"] = function() MiniTest.expect.equality(terminal.is_valid_id(nil), false) end +T["parse_response"] = MiniTest.new_set() + +T["parse_response"]["error response, as TermResponse delivers it (no ST)"] = function() + local response = terminal.parse_response(ESC .. "_Gi=" .. ID .. ";EBADPNG:bad data") + MiniTest.expect.equality( + response, + { id = ID, placement_id = nil, ok = false, message = "EBADPNG:bad data" } + ) +end + +T["parse_response"]["OK response with a placement id and trailing ST"] = function() + local response = terminal.parse_response(ESC .. "_Gi=" .. ID .. ",p=7;OK" .. ESC .. "\\") + MiniTest.expect.equality(response, { id = ID, placement_id = 7, ok = true, message = "OK" }) +end + +T["parse_response"]["message containing a semicolon is kept whole"] = function() + local response = terminal.parse_response(ESC .. "_Gi=" .. ID .. ";EINVAL:a;b") + MiniTest.expect.equality(response.message, "EINVAL:a;b") +end + +T["parse_response"]["ignores ids outside blit's reserved range"] = function() + MiniTest.expect.equality(terminal.parse_response(ESC .. "_Gi=1;ENOENT:x"), nil) + MiniTest.expect.equality(terminal.parse_response(ESC .. "_GI=3;ENOENT:x"), nil) +end + +T["parse_response"]["ignores non-graphics and malformed sequences"] = function() + MiniTest.expect.equality(terminal.parse_response(ESC .. "]11;rgb:0000/0000/0000"), nil) + MiniTest.expect.equality(terminal.parse_response(ESC .. "_Gi=" .. ID), nil) + MiniTest.expect.equality(terminal.parse_response(""), nil) + MiniTest.expect.equality(terminal.parse_response(nil), nil) +end + +T["has_response_support"] = MiniTest.new_set() + +T["has_response_support"]["true only when nvim-0.12 is available"] = function() + local asked + local supported = terminal.has_response_support(function(feature) + asked = feature + return 1 + end) + MiniTest.expect.equality(supported, true) + MiniTest.expect.equality(asked, "nvim-0.12") + MiniTest.expect.equality( + terminal.has_response_support(function() + return 0 + end), + false + ) +end + return T