From 36693121497f0577fe3ab3695024789582b3feeb Mon Sep 17 00:00:00 2001 From: Zsolt Simon Date: Fri, 31 Jul 2026 14:43:51 +1000 Subject: [PATCH] feat(term): let the cell size be configured when the pty reports none Some ptys report cell counts but no pixel geometry -- WSL2 always, and often SSH. utils/term then assumed 8x16, and since cell size scales every crop rectangle, a wrong assumption samples the wrong region of the source image. Add a `cell_size` option, accepting "14x32" or { width = 14, height = 32 }. It is consulted only when TIOCGWINSZ reports no pixels, so terminals that do report geometry are unaffected. term.lua is required (via utils) before setup() runs, so its size is computed at load time and an option read there would be ignored. Export set_cell_size() and call it from setup() to re-resolve once the option is known. Also honour IMAGE_NVIM_CELL_SIZE as a fallback when no option is given: Neovim cannot query the terminal itself -- TermResponse never fires for the CSI 16 t reply, and a child process gets no /dev/tty -- so the value sometimes has to come from the shell, which does own the tty: printf '\033[16t' # reply: CSI 6 ; height ; width t Precedence is option > IMAGE_NVIM_CELL_SIZE > 8x16. Sizes of zero are rejected rather than passed through to divide cell_width downstream. Co-Authored-By: Claude Opus 5 --- lua/image/init.lua | 7 +++ lua/image/utils/term.lua | 93 ++++++++++++++++++++++++++++++++++++---- lua/types.lua | 1 + 3 files changed, 93 insertions(+), 8 deletions(-) diff --git a/lua/image/init.lua b/lua/image/init.lua index f547fb6..28dd09b 100644 --- a/lua/image/init.lua +++ b/lua/image/init.lua @@ -56,6 +56,9 @@ local default_options = { scale_factor = 1.0, kitty_method = "normal", kitty_direct_chunk_size = 4096, + -- cell_size: "WxH" or { width, height }, assumed only when the pty reports no + -- pixel geometry (WSL2, often SSH). Defaults to IMAGE_NVIM_CELL_SIZE, else 8x16. + cell_size = nil, window_overlap_clear_enabled = false, window_overlap_clear_ft_ignore = { "cmp_menu", "cmp_docs", "snacks_notif", "scrollview", "scrollview_sign" }, editor_only_render_when_focused = false, @@ -118,6 +121,10 @@ api.setup = function(options) -- setup logger with debug configuration if opts.debug then logger.setup(opts.debug) end + -- term.lua is required (and sized) before setup() runs, so re-resolve now that + -- the option is known. + utils.term.set_cell_size(opts.cell_size) + if opts.processor == "magick_rock" then vim.schedule(function() require("image/magick").warn_if_magick_rock_missing() diff --git a/lua/image/utils/term.lua b/lua/image/utils/term.lua index 3ce17d5..9fb9cd5 100644 --- a/lua/image/utils/term.lua +++ b/lua/image/utils/term.lua @@ -2,6 +2,59 @@ local cached_size = nil local size_warned = false +-- Used when the pty reports no pixel geometry and nothing better is configured. +-- Historical value; mainly keeps cell_width/cell_height non-zero so the crop +-- math downstream can't divide by zero. +local FALLBACK_CELL_WIDTH = 8 +local FALLBACK_CELL_HEIGHT = 16 + +---@type { width: number, height: number }|nil +local configured_cell_size = nil + +---@param value string +---@param source string +---@return { width: number, height: number }|nil +local parse_cell_size = function(value, source) + local w, h = value:match("^(%d+)[xX](%d+)$") + if w and tonumber(w) > 0 and tonumber(h) > 0 then + return { width = tonumber(w), height = tonumber(h) } + end + vim.notify( + ("image.nvim: ignoring malformed %s=%s (expected e.g. 14x32)"):format(source, value), + vim.log.levels.WARN + ) + return nil +end + +--- Accepts "WxH" or { width = W, height = H }. +---@param value string|{ width: number, height: number } +---@param source string +---@return { width: number, height: number }|nil +local normalize_cell_size = function(value, source) + if type(value) == "string" then return parse_cell_size(value, source) end + if type(value) == "table" then + local w, h = tonumber(value.width), tonumber(value.height) + if w and h and w > 0 and h > 0 then return { width = w, height = h } end + end + vim.notify( + ('image.nvim: ignoring invalid %s (expected "14x32" or { width = 14, height = 32 })'):format(source), + vim.log.levels.WARN + ) + return nil +end + +--- Resolve the cell size to assume when the pty reports no pixel geometry. +--- The option wins; IMAGE_NVIM_CELL_SIZE is the fallback, for when the value has +--- to come from outside Neovim (see set_cell_size). +---@param option string|{ width: number, height: number }|nil +---@return { width: number, height: number }|nil +local resolve_cell_size = function(option) + if option ~= nil then return normalize_cell_size(option, "cell_size") end + local env = vim.env.IMAGE_NVIM_CELL_SIZE + if env then return parse_cell_size(env, "IMAGE_NVIM_CELL_SIZE") end + return nil +end + -- https://github.com/edluffy/hologram.nvim/blob/main/lua/hologram/state.lua#L15 local update_size = function() local ffi = require("ffi") @@ -45,14 +98,14 @@ local update_size = function() local xpixel = sz.xpixel local ypixel = sz.ypixel - -- Fallback when pixel dimensions are unavailable (common over SSH) - -- TIOCGWINSZ returns xpixel=0, ypixel=0 in SSH sessions because the - -- SSH protocol does not propagate client pixel dimensions. Without this - -- fallback, cell_width/cell_height become 0, causing integer overflow - -- in image geometry calculations (width becomes INT64_MIN). + -- Some ptys report cells but no pixels: WSL2 always, and often SSH. Cell size + -- scales every crop rectangle, so guessing wrong samples the wrong region of + -- the source image -- prefer a configured size over the fallback. if xpixel == 0 or ypixel == 0 then - xpixel = sz.col * 8 - ypixel = sz.row * 16 + local cw = configured_cell_size and configured_cell_size.width or FALLBACK_CELL_WIDTH + local ch = configured_cell_size and configured_cell_size.height or FALLBACK_CELL_HEIGHT + xpixel = sz.col * cw + ypixel = sz.row * ch end cached_size = { @@ -65,9 +118,32 @@ local update_size = function() } end +--- Set the assumed cell size and recompute. Called from setup(). +--- +--- This module is required before setup() runs, so the size computed at load time +--- uses the fallback; this recomputes it once the option is known. +--- +--- Neovim cannot ask the terminal itself -- TermResponse never fires for the +--- CSI 16 t reply, and a child process gets no /dev/tty -- so on a pty without +--- pixel geometry the value has to be supplied. Either pass `cell_size` here, or +--- export IMAGE_NVIM_CELL_SIZE from the shell that does own the tty: +--- +--- printf '\033[16t' # reply: CSI 6 ; height ; width t +--- +---@param option string|{ width: number, height: number }|nil +local set_cell_size = function(option) + configured_cell_size = resolve_cell_size(option) + update_size() +end + +configured_cell_size = resolve_cell_size(nil) update_size() + vim.api.nvim_create_autocmd("VimResized", { - callback = update_size, + callback = function() + -- A resize can also mean a font-size change, so recompute rather than reuse. + update_size() + end, }) local get_tty = function() @@ -85,4 +161,5 @@ return { return cached_size end, get_tty = get_tty, + set_cell_size = set_cell_size, } diff --git a/lua/types.lua b/lua/types.lua index e0a7c54..608609f 100644 --- a/lua/types.lua +++ b/lua/types.lua @@ -51,6 +51,7 @@ ---@field scale_factor? number ---@field kitty_method "normal"|"unicode-placeholders" ---@field kitty_direct_chunk_size? number +---@field cell_size? string|{ width: number, height: number } ---@field window_overlap_clear_enabled? boolean ---@field window_overlap_clear_ft_ignore? string[] ---@field editor_only_render_when_focused? boolean