opentui(v6): clamp negative draw coords at the node:ffi seam (diff crash fix)

Expanding a tall <diff showLineNumbers> pinned to the scrollbox bottom froze
the TUI with ERR_INVALID_ARG_VALUE looping out of CliRenderer.loop every
frame. Root cause: @opentui/core 0.4.0 marshals OptimizedBuffer
fillRect/drawText/setCell* coordinates as u32 in the FFI table while
LineNumberRenderable.renderSelf passes raw screen coordinates — NEGATIVE when
the diff is partially scrolled above the viewport. Bun's FFI silently wraps
negatives (native side bounds-checks them into a no-op); Node's experimental
node:ffi rejects them. bufferDrawBox already uses i32, which is why ordinary
boxes/text scroll fine and only the diff line-background path crashed.

Fix at the seam we own: boundary/ffiSafe.ts patches OptimizedBuffer to clip
fillRect to the non-negative quadrant and skip negative-origin
drawText/setCell*/drawChar before the FFI call (Bun parity). Installed from
boundary/renderer.ts (live) and test/lib/render.ts (headless). TODO(upstream):
widen those FFI params to i32 so this shim can be deleted.
This commit is contained in:
alt-glitch
2026-06-10 18:48:51 +05:30
parent c4348480f3
commit 0a5b0780f5
4 changed files with 228 additions and 0 deletions
+87
View File
@@ -0,0 +1,87 @@
/**
* Node-FFI coordinate safety shim for @opentui/core 0.4.0.
*
* Root cause (live crash, ERR_INVALID_ARG_VALUE looping every frame): several
* OptimizedBuffer methods marshal x/y/width/height as **u32** in the FFI table
* (zig.ts: `bufferFillRect: ["u32","u32","u32","u32","u32","ptr"]`, same for
* `bufferDrawText` / `bufferSetCell*` / `bufferDrawChar`), while renderables
* pass RAW SCREEN COORDINATES — which go NEGATIVE inside a <scrollbox> when an
* element is partially scrolled above the viewport. Concretely:
* `LineNumberRenderable.renderSelf` does `buffer.fillRect(this.x + gutterWidth,
* this.y + i, …)` for diff added/removed line backgrounds, so expanding a tall
* `<diff showLineNumbers>` pinned to the scrollbox bottom rendered with
* `this.y < 0` and threw out of `CliRenderer.loop` on EVERY frame (frozen UI,
* console error spam) until a resize forced a fresh layout.
*
* Upstream-on-Bun this never throws: Bun's FFI silently WRAPS negatives to
* huge u32s and the native side bounds-checks them into a no-op. Node's
* experimental FFI (node:ffi) instead REJECTS the argument. Other draw entry
* points (`bufferDrawBox`, `bufferDrawTextBufferView`) already use i32 — which
* is why ordinary text/boxes scroll fine and only the diff gutter path crashed.
*
* Fix at the seam we own: clamp/skip BEFORE the FFI call.
* - fillRect: clip the rect to the non-negative quadrant (the native side
* already clips right/bottom against the buffer + scissor) and skip empties.
* - drawText/setCell/setCellWithAlphaBlending/drawChar: skip when the origin
* is negative (Bun-parity: those cells/rows are off-screen anyway).
*
* TODO(upstream): file/track an OpenTUI issue to widen these FFI params to i32
* (or clamp in core) — then this shim can be deleted.
*/
import { OptimizedBuffer } from '@opentui/core'
let installed = false
/** Patch OptimizedBuffer's u32-coordinate methods to tolerate negative coords. Idempotent. */
export function installFfiCoordSafety(): void {
if (installed) return
installed = true
const proto = OptimizedBuffer.prototype
// Prototype monkey-patching: extracting the original methods unbound is the
// point — they're re-invoked with `.call(this, …)` on the correct instance.
/* eslint-disable @typescript-eslint/unbound-method */
const origFillRect = proto.fillRect
proto.fillRect = function (this: OptimizedBuffer, x, y, width, height, bg) {
let x2 = Math.trunc(x)
let y2 = Math.trunc(y)
let w = Math.trunc(width)
let h = Math.trunc(height)
if (x2 < 0) {
w += x2
x2 = 0
}
if (y2 < 0) {
h += y2
y2 = 0
}
if (w <= 0 || h <= 0) return
origFillRect.call(this, x2, y2, w, h, bg)
}
const origDrawText = proto.drawText
proto.drawText = function (this: OptimizedBuffer, text, x, y, ...rest) {
if (x < 0 || y < 0) return
origDrawText.call(this, text, x, y, ...rest)
}
const origSetCell = proto.setCell
proto.setCell = function (this: OptimizedBuffer, x, y, ...rest) {
if (x < 0 || y < 0) return
origSetCell.call(this, x, y, ...rest)
}
const origSetCellAlpha = proto.setCellWithAlphaBlending
proto.setCellWithAlphaBlending = function (this: OptimizedBuffer, x, y, ...rest) {
if (x < 0 || y < 0) return
origSetCellAlpha.call(this, x, y, ...rest)
}
const origDrawChar = proto.drawChar
proto.drawChar = function (this: OptimizedBuffer, char, x, y, ...rest) {
if (x < 0 || y < 0) return
origDrawChar.call(this, char, x, y, ...rest)
}
}
+5
View File
@@ -13,8 +13,13 @@ import { createCliRenderer, type CliRenderer, type KeyEvent, type Selection } fr
import { Deferred, Effect } from 'effect'
import { RendererError } from './errors.ts'
import { installFfiCoordSafety } from './ffiSafe.ts'
import { getLog } from './log.ts'
// Node-FFI seam: clamp negative draw coordinates BEFORE the u32 FFI marshaling
// (see ffiSafe.ts — scrolled-out <diff> line backgrounds crashed the render loop).
installFfiCoordSafety()
/**
* The text a finished selection copies: the RENDERED text the user highlighted,
* verbatim (`getSelectedText()` does correct same-line merging). Markdown markers