diff --git a/packages/core/src/buffer.test.ts b/packages/core/src/buffer.test.ts index aa773e55..c56069bc 100644 --- a/packages/core/src/buffer.test.ts +++ b/packages/core/src/buffer.test.ts @@ -26,6 +26,46 @@ describe("OptimizedBuffer", () => { expect([...buffer.buffers.attributes.slice(0, attributes.length)]).toEqual(attributes) }) + it("clips draws at negative positions", () => { + // Node FFI rejects a negative u32 argument, so every call here also checks that positions cross FFI as i32. + const target = OptimizedBuffer.create(3, 2, "unicode", { id: "negative-positions" }) + try { + const white = RGBA.fromInts(255, 255, 255) + const black = RGBA.fromInts(0, 0, 0) + target.clear(black) + + target.setCell(-1, 0, "S", white, black) + target.setCellWithAlphaBlending(0, -1, "A", white, black) + target.drawChar("D".codePointAt(0)!, -1, -1, white, black) + target.drawSuperSampleBuffer(-1, -1, new Uint8Array(16), 16, "rgba8unorm", 8) + target.drawPackedBuffer(new Uint8Array(48), 48, -1, -1, 1, 1) + target.drawText("ABCD", -2, 1, white, black) + target.fillRect(-1, -1, 2, 2, RGBA.fromInts(255, 0, 0)) + + expect(new TextDecoder().decode(target.getRealCharBytes(true))).toBe(" \nCD \n") + expect([0, 1, 3].map((cell) => target.buffers.bg[cell * 4] & 0xff)).toEqual([255, 0, 0]) + } finally { + target.destroy() + } + }) + + it("fills nothing for a non-positive extent", () => { + // Bun wraps a negative u32 argument into a huge extent, so the wrapper must return before the FFI call. + const target = OptimizedBuffer.create(3, 2, "unicode", { id: "empty-extents" }) + try { + const red = RGBA.fromInts(255, 0, 0) + target.clear(RGBA.fromInts(0, 0, 0)) + + target.fillRect(1, 0, -1, 1, red) + target.fillRect(1, 0, 1, -1, red) + target.fillRect(1, 0, 0, 1, red) + + expect([0, 1, 2, 3, 4, 5].map((cell) => target.buffers.bg[cell * 4] & 0xff)).toEqual([0, 0, 0, 0, 0, 0]) + } finally { + target.destroy() + } + }) + it("draws images as reserved cells with resolved fallback glyphs", () => { const image = NativeImage.fromRgba( Uint8Array.of(255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 255, 255), diff --git a/packages/core/src/buffer.ts b/packages/core/src/buffer.ts index 4e8a8875..fe05a776 100644 --- a/packages/core/src/buffer.ts +++ b/packages/core/src/buffer.ts @@ -302,6 +302,8 @@ export class OptimizedBuffer { public fillRect(x: number, y: number, width: number, height: number, bg: RGBA): void { this.guard() + // An empty rectangle draws nothing. A negative extent would wrap to a huge u32 at the FFI boundary. + if (width <= 0 || height <= 0) return this.lib.bufferFillRect(this.bufferPtr, x, y, width, height, bg) } diff --git a/packages/core/src/zig.ts b/packages/core/src/zig.ts index 6094e5f6..b4a44895 100644 --- a/packages/core/src/zig.ts +++ b/packages/core/src/zig.ts @@ -702,19 +702,19 @@ function getOpenTUILib(libPath?: string) { }, bufferDrawText: { - args: ["u32", "ptr", "u32", "u32", "u32", "buffer", "ptr", "u32"], + args: ["u32", "ptr", "u32", "i32", "i32", "buffer", "ptr", "u32"], returns: "void", }, bufferSetCellWithAlphaBlending: { - args: ["u32", "u32", "u32", "u32", "buffer", "buffer", "u32"], + args: ["u32", "i32", "i32", "u32", "buffer", "buffer", "u32"], returns: "void", }, bufferSetCell: { - args: ["u32", "u32", "u32", "u32", "buffer", "buffer", "u32"], + args: ["u32", "i32", "i32", "u32", "buffer", "buffer", "u32"], returns: "void", }, bufferFillRect: { - args: ["u32", "u32", "u32", "u32", "u32", "buffer"], + args: ["u32", "i32", "i32", "u32", "u32", "buffer"], returns: "void", }, bufferColorMatrix: { @@ -874,7 +874,7 @@ function getOpenTUILib(libPath?: string) { }, bufferDrawSuperSampleBuffer: { - args: ["u32", "u32", "u32", "ptr", "u32", "u8", "u32"], + args: ["u32", "i32", "i32", "ptr", "u32", "u8", "u32"], returns: "void", }, bufferDrawImage: { @@ -882,7 +882,7 @@ function getOpenTUILib(libPath?: string) { returns: "u8", }, bufferDrawPackedBuffer: { - args: ["u32", "ptr", "u32", "u32", "u32", "u32", "u32"], + args: ["u32", "ptr", "u32", "i32", "i32", "u32", "u32"], returns: "void", }, bufferDrawGrayscaleBuffer: { @@ -1698,7 +1698,7 @@ function getOpenTUILib(libPath?: string) { returns: "void", }, bufferDrawChar: { - args: ["u32", "u32", "u32", "u32", "buffer", "buffer", "u32"], + args: ["u32", "u32", "i32", "i32", "buffer", "buffer", "u32"], returns: "void", }, diff --git a/packages/native/src/buffer.zig b/packages/native/src/buffer.zig index 424f3b7b..3ec00e95 100644 --- a/packages/native/src/buffer.zig +++ b/packages/native/src/buffer.zig @@ -330,6 +330,14 @@ pub const OptimizedBuffer = struct { y >= scissor.y and y < scissor.y + @as(i32, @intCast(scissor.height)); } + /// Reports whether a cell is inside the buffer and the active scissor. The bounds check runs + /// first because a u32 coordinate at or above 2^31 does not fit in the signed scissor + /// coordinates. + fn isCellInBufferAndScissor(self: *const OptimizedBuffer, x: u32, y: u32) bool { + if (x >= self.width or y >= self.height) return false; + return self.isPointInScissor(@intCast(x), @intCast(y)); + } + pub fn isRectInScissor(self: *const OptimizedBuffer, x: i32, y: i32, width: u32, height: u32) bool { const scissor = self.getCurrentScissorRect() orelse return true; @@ -652,8 +660,7 @@ pub const OptimizedBuffer = struct { /// Validate coordinates and return buffer index, or null if out of bounds / scissor. fn validateAndIndex(self: *OptimizedBuffer, x: u32, y: u32) ?u32 { - if (x >= self.width or y >= self.height) return null; - if (!self.isPointInScissor(@intCast(x), @intCast(y))) return null; + if (!self.isCellInBufferAndScissor(x, y)) return null; return self.coordsToIndex(x, y); } @@ -927,7 +934,7 @@ pub const OptimizedBuffer = struct { } inline fn setCellWithAlphaBlendingCellWithoutImages(self: *OptimizedBuffer, x: u32, y: u32, cell: Cell) void { - if (!self.isPointInScissor(@intCast(x), @intCast(y))) return; + if (!self.isCellInBufferAndScissor(x, y)) return; const opacity = self.getCurrentOpacity(); if (isFullyTransparent(opacity, cell.fg, cell.bg)) return; if (isFullyOpaque(opacity, cell.fg, cell.bg)) { @@ -946,7 +953,7 @@ pub const OptimizedBuffer = struct { } inline fn setVisibleCellWithAlphaBlending(self: *OptimizedBuffer, x: u32, y: u32, cell: Cell, opacity: f32, fully_transparent: bool) void { - if (!self.isPointInScissor(@intCast(x), @intCast(y))) return; + if (!self.isCellInBufferAndScissor(x, y)) return; if (isFullyOpaque(opacity, cell.fg, cell.bg)) { self.set(x, y, cell); return; @@ -982,7 +989,7 @@ pub const OptimizedBuffer = struct { } fn setCellWithAlphaBlendingRawCell(self: *OptimizedBuffer, x: u32, y: u32, cell: Cell) void { - if (!self.isPointInScissor(@intCast(x), @intCast(y))) return; + if (!self.isCellInBufferAndScissor(x, y)) return; const opacity = self.getCurrentOpacity(); if (opacity == 0.0) return; @@ -1205,7 +1212,8 @@ pub const OptimizedBuffer = struct { } } - fn fillRectClipped( + /// Fills the part of a rectangle at a signed position that is inside the buffer. + pub fn fillRectClipped( self: *OptimizedBuffer, x: i32, y: i32, @@ -1213,20 +1221,19 @@ pub const OptimizedBuffer = struct { height: u32, bg: RGBA, ) void { - if (width == 0 or height == 0) return; + // i64 holds any i32 position plus any u32 extent. + const start_x = @max(0, @as(i64, x)); + const start_y = @max(0, @as(i64, y)); + const end_x = @min(@as(i64, self.width), @as(i64, x) + width); + const end_y = @min(@as(i64, self.height), @as(i64, y) + height); - const startX = @max(0, x); - const startY = @max(0, y); - const endX = @min(@as(i32, @intCast(self.width)) - 1, x + @as(i32, @intCast(width)) - 1); - const endY = @min(@as(i32, @intCast(self.height)) - 1, y + @as(i32, @intCast(height)) - 1); - - if (startX > endX or startY > endY) return; + if (start_x >= end_x or start_y >= end_y) return; self.fillRect( - @intCast(startX), - @intCast(startY), - @intCast(endX - startX + 1), - @intCast(endY - startY + 1), + @intCast(start_x), + @intCast(start_y), + @intCast(end_x - start_x), + @intCast(end_y - start_y), bg, ); } @@ -1252,9 +1259,24 @@ pub const OptimizedBuffer = struct { bg: ?RGBA, attributes: u32, ) BufferError!void { + if (x >= self.width or y >= self.height) return; + return self.drawTextClipped(text, @intCast(x), @intCast(y), fg, bg, attributes); + } + + /// Draws the part of a text at a signed position that is inside the buffer. + pub inline fn drawTextClipped( + self: *OptimizedBuffer, + text: []const u8, + x: i32, + y: i32, + fg: RGBA, + bg: ?RGBA, + attributes: u32, + ) BufferError!void { + if (y < 0) return; const opacity = self.getCurrentOpacity(); if (isFullyTransparent(opacity, fg, bg orelse ansi.rgbColor(0, 0, 0, 0)) and (opacity == 0.0 or self.image_placements.items.len == 0)) return; - return self.drawVisibleText(text, x, y, fg, bg, attributes); + return self.drawVisibleText(text, x, @intCast(y), fg, bg, attributes); } /// Draw one already-segmented grapheme with an authoritative terminal-cell width. @@ -1286,7 +1308,7 @@ pub const OptimizedBuffer = struct { fn drawVisibleText( self: *OptimizedBuffer, text: []const u8, - x: u32, + x: i32, y: u32, fg: RGBA, bg: ?RGBA, @@ -1310,10 +1332,13 @@ pub const OptimizedBuffer = struct { } if (printable) { const background = bg.?; - for (text, 0..) |byte, offset| { - const char_x = x + @as(u32, @intCast(offset)); + // Each printable ASCII byte is one cell, so the bytes left of column 0 are clipped. + const clipped_byte_count: usize = if (x < 0) @min(text.len, @abs(x)) else 0; + var char_x: u32 = @intCast(@max(x, 0)); + for (text[clipped_byte_count..]) |byte| { if (char_x >= self.width) break; self.set(char_x, y, makeCell(byte, fg, background, attributes)); + char_x += 1; } return; } @@ -1332,8 +1357,11 @@ pub const OptimizedBuffer = struct { var special_idx: usize = 0; text_loop: while (byte_offset < text.len) { - const charX = x + advance_cells; - if (charX >= self.width) break; + const char_x_wide = @as(i64, x) + advance_cells; + if (char_x_wide >= self.width) break; + const char_x: i32 = @intCast(char_x_wide); + // Only a tab is drawn from a column left of 0; it clips each of its cells. + const cell_x: u32 = @intCast(@max(char_x, 0)); const at_special = special_idx < render_clusters.len and render_clusters[special_idx].col_start == col; @@ -1354,7 +1382,15 @@ pub const OptimizedBuffer = struct { } const is_tab = grapheme_bytes.len == 1 and grapheme_bytes[0] == '\t'; - if (!is_tab and !self.isPointInScissor(@intCast(charX), @intCast(y))) { + const cluster_byte_start = if (at_special) render_clusters[special_idx - 1].byte_start else byte_offset - 1; + if (!is_tab and char_x < 0) { + // Clip a glyph that starts left of column 0, even a wide glyph that reaches column 0. + // Advance as a drawn glyph does, so the visible glyphs keep their columns. + advance_cells += utf8.getWidthAt(text, cluster_byte_start, tab_width, self.width_method); + col += cluster_width_cols; + continue; + } + if (!is_tab and !self.isPointInScissor(char_x, @intCast(y))) { advance_cells += cluster_width_cols; col += cluster_width_cols; continue; @@ -1363,25 +1399,25 @@ pub const OptimizedBuffer = struct { var bgColor: RGBA = undefined; if (bg) |b| { bgColor = b; - } else if (self.get(charX, y)) |existingCell| { + } else if (self.get(cell_x, y)) |existingCell| { bgColor = existingCell.bg; } else { bgColor = ansi.rgbColor(0, 0, 0, 255); } - const cell_width = utf8.getWidthAt(text, if (at_special) render_clusters[special_idx - 1].byte_start else byte_offset - 1, tab_width, self.width_method); + const cell_width = utf8.getWidthAt(text, cluster_byte_start, tab_width, self.width_method); if (cell_width == 0) { col += cluster_width_cols; continue; } if (cell_width > 1 and !is_tab) { - if (charX + cell_width > self.width) { + if (cell_x + cell_width > self.width) { advance_cells += cluster_width_cols; col += cluster_width_cols; continue; } for (1..cell_width) |span_offset| { - if (!self.isPointInScissor(@intCast(charX + @as(u32, @intCast(span_offset))), @intCast(y))) { + if (!self.isPointInScissor(char_x + @as(i32, @intCast(span_offset)), @intCast(y))) { advance_cells += cluster_width_cols; col += cluster_width_cols; continue :text_loop; @@ -1392,12 +1428,14 @@ pub const OptimizedBuffer = struct { if (is_tab) { var tab_col: u32 = 0; while (tab_col < cluster_width_cols) : (tab_col += 1) { - const tab_x = charX + tab_col; + const tab_x = @as(i64, char_x) + tab_col; + if (tab_x < 0) continue; if (tab_x >= self.width) break; if (!self.isPointInScissor(@intCast(tab_x), @intCast(y))) continue; + const tab_cell_x: u32 = @intCast(tab_x); const cell = makeCell(DEFAULT_SPACE_CHAR, fg, bgColor, attributes); - if (explicit_colors_opaque) self.set(tab_x, y, cell) else self.setTextCell(tab_x, y, cell); + if (explicit_colors_opaque) self.set(tab_cell_x, y, cell) else self.setTextCell(tab_cell_x, y, cell); } advance_cells += cluster_width_cols; col += cluster_width_cols; @@ -1413,7 +1451,7 @@ pub const OptimizedBuffer = struct { } const cell = makeCell(encoded_char, fg, bgColor, attributes); - if (explicit_colors_opaque) self.set(charX, y, cell) else self.setTextCell(charX, y, cell); + if (explicit_colors_opaque) self.set(cell_x, y, cell) else self.setTextCell(cell_x, y, cell); advance_cells += cell_width; col += cluster_width_cols; @@ -2714,8 +2752,8 @@ pub const OptimizedBuffer = struct { /// alignedBytesPerRow: The number of bytes per row in the pixelData buffer, considering alignment/padding. pub fn drawSuperSampleBuffer( self: *OptimizedBuffer, - posX: u32, - posY: u32, + posX: i32, + posY: i32, pixelData: [*]const u8, len: usize, format: u8, // 0: bgra8unorm, 1: rgba8unorm @@ -2726,23 +2764,29 @@ pub const OptimizedBuffer = struct { // TODO: A more robust implementation might take source width/height explicitly. - var y_cell = posY; + // Start at the first cell inside the buffer. A negative position clips the source. + var y_cell: u32 = @intCast(@max(posY, 0)); while (y_cell < self.height) : (y_cell += 1) { - var x_cell = posX; + var x_cell: u32 = @intCast(@max(posX, 0)); while (x_cell < self.width) : (x_cell += 1) { if (!self.isPointInScissor(@intCast(x_cell), @intCast(y_cell))) { continue; } - const renderX: u32 = (x_cell - posX) * 2; - const renderY: u32 = (y_cell - posY) * 2; + const renderX: u64 = @intCast((@as(i64, x_cell) - posX) * 2); + const renderY: u64 = @intCast((@as(i64, y_cell) - posY) * 2); - const tlIndex: usize = @intCast(renderY * alignedBytesPerRow + renderX * bytesPerPixel); - const trIndex: usize = tlIndex + bytesPerPixel; - const blIndex: usize = @intCast((renderY + 1) * alignedBytesPerRow + renderX * bytesPerPixel); - const brIndex: usize = blIndex + bytesPerPixel; + // A far negative position puts these indices past the pixel data. Saturate and clamp + // them to len, which getPixelColor reads as an out-of-bounds pixel. + const tlIndex = renderY *| alignedBytesPerRow +| renderX *| bytesPerPixel; + const blIndex = (renderY + 1) *| alignedBytesPerRow +| renderX *| bytesPerPixel; - const indices = [_]usize{ tlIndex, trIndex, blIndex, brIndex }; + const indices = [_]usize{ + @intCast(@min(tlIndex, len)), + @intCast(@min(tlIndex +| bytesPerPixel, len)), + @intCast(@min(blIndex, len)), + @intCast(@min(blIndex +| bytesPerPixel, len)), + }; // Get RGBA colors for TL, TR, BL, BR var pixelsRgba: [4]RGBA = undefined; @@ -2772,22 +2816,24 @@ pub const OptimizedBuffer = struct { self: *OptimizedBuffer, data: [*]const u8, dataLen: usize, - posX: u32, - posY: u32, + posX: i32, + posY: i32, terminalWidthCells: u32, terminalHeightCells: u32, ) void { const cellResultSize = 48; const numCells = dataLen / cellResultSize; const bufferWidthCells = terminalWidthCells; + if (bufferWidthCells == 0) return; var i: usize = 0; while (i < numCells) : (i += 1) { const cellDataOffset = i * cellResultSize; - const cellX = posX + @as(u32, @intCast(i % bufferWidthCells)); - const cellY = posY + @as(u32, @intCast(i / bufferWidthCells)); + const cellX = @as(i64, posX) + @as(i64, @intCast(i % bufferWidthCells)); + const cellY = @as(i64, posY) + @as(i64, @intCast(i / bufferWidthCells)); + if (cellX < 0 or cellY < 0) continue; if (cellX >= terminalWidthCells or cellY >= terminalHeightCells) continue; if (cellX >= self.width or cellY >= self.height) continue; @@ -2810,7 +2856,7 @@ pub const OptimizedBuffer = struct { char = BLOCK_CHAR; } - self.setCellWithAlphaBlending(cellX, cellY, char, fg, bg, 0); + self.setCellWithAlphaBlending(@intCast(cellX), @intCast(cellY), char, fg, bg, 0); } } diff --git a/packages/native/src/lib.zig b/packages/native/src/lib.zig index 0c439288..e7dbf4f3 100644 --- a/packages/native/src/lib.zig +++ b/packages/native/src/lib.zig @@ -1797,9 +1797,9 @@ export fn bufferWriteResolvedChars(buffer_handle: NativeHandle, outputPtr: ?[*]u return object_ptr.writeResolvedChars(output_slice, addLineBreaks) catch 0; } -export fn bufferDrawText(buffer_handle: NativeHandle, text: ?[*]const u8, textLen: u32, x: u32, y: u32, fg: [*]const u16, bg: ?[*]const u16, attributes: u32) void { +export fn bufferDrawText(buffer_handle: NativeHandle, text: ?[*]const u8, textLen: u32, x: i32, y: i32, fg: [*]const u16, bg: ?[*]const u16, attributes: u32) void { const object_ptr = acquireBuffer(buffer_handle) orelse return; - object_ptr.drawText( + object_ptr.drawTextClipped( sliceFromPtrLen(text, textLen), x, y, @@ -1809,14 +1809,16 @@ export fn bufferDrawText(buffer_handle: NativeHandle, text: ?[*]const u8, textLe ) catch {}; } -export fn bufferSetCellWithAlphaBlending(buffer_handle: NativeHandle, x: u32, y: u32, char: u32, fg: [*]const u16, bg: [*]const u16, attributes: u32) void { +export fn bufferSetCellWithAlphaBlending(buffer_handle: NativeHandle, x: i32, y: i32, char: u32, fg: [*]const u16, bg: [*]const u16, attributes: u32) void { const object_ptr = acquireBuffer(buffer_handle) orelse return; - object_ptr.setCellWithAlphaBlending(x, y, char, ptrToRGBA(fg), ptrToRGBA(bg), attributes); + if (x < 0 or y < 0) return; + object_ptr.setCellWithAlphaBlending(@intCast(x), @intCast(y), char, ptrToRGBA(fg), ptrToRGBA(bg), attributes); } -export fn bufferSetCell(buffer_handle: NativeHandle, x: u32, y: u32, char: u32, fg: [*]const u16, bg: [*]const u16, attributes: u32) void { +export fn bufferSetCell(buffer_handle: NativeHandle, x: i32, y: i32, char: u32, fg: [*]const u16, bg: [*]const u16, attributes: u32) void { const object_ptr = acquireBuffer(buffer_handle) orelse return; - object_ptr.set(x, y, .{ + if (x < 0 or y < 0) return; + object_ptr.set(@intCast(x), @intCast(y), .{ .char = char, .fg = ptrToRGBA(fg), .bg = ptrToRGBA(bg), @@ -1824,9 +1826,9 @@ export fn bufferSetCell(buffer_handle: NativeHandle, x: u32, y: u32, char: u32, }); } -export fn bufferFillRect(buffer_handle: NativeHandle, x: u32, y: u32, width: u32, height: u32, bg: [*]const u16) void { +export fn bufferFillRect(buffer_handle: NativeHandle, x: i32, y: i32, width: u32, height: u32, bg: [*]const u16) void { const object_ptr = acquireBuffer(buffer_handle) orelse return; - object_ptr.fillRect(x, y, width, height, ptrToRGBA(bg)); + object_ptr.fillRectClipped(x, y, width, height, ptrToRGBA(bg)); } export fn bufferColorMatrix(buffer_handle: NativeHandle, matrixPtr: [*]const f32, cellMaskPtr: [*]const f32, cellMaskCount: u32, strength: f32, target: u8) void { @@ -1846,7 +1848,7 @@ export fn bufferColorMatrixUniform(buffer_handle: NativeHandle, matrixPtr: [*]co buffer_effects.colorMatrixUniform(object_ptr, matrix, strength, targetEnum); } -export fn bufferDrawPackedBuffer(buffer_handle: NativeHandle, data: [*]const u8, dataLen: u32, posX: u32, posY: u32, terminalWidthCells: u32, terminalHeightCells: u32) void { +export fn bufferDrawPackedBuffer(buffer_handle: NativeHandle, data: [*]const u8, dataLen: u32, posX: i32, posY: i32, terminalWidthCells: u32, terminalHeightCells: u32) void { const object_ptr = acquireBuffer(buffer_handle) orelse return; object_ptr.drawPackedBuffer(data, dataLen, posX, posY, terminalWidthCells, terminalHeightCells); } @@ -1913,7 +1915,7 @@ export fn bufferClearOpacity(buffer_handle: NativeHandle) void { object_ptr.clearOpacity(); } -export fn bufferDrawSuperSampleBuffer(buffer_handle: NativeHandle, x: u32, y: u32, pixelData: [*]const u8, len: u32, format: u8, alignedBytesPerRow: u32) void { +export fn bufferDrawSuperSampleBuffer(buffer_handle: NativeHandle, x: i32, y: i32, pixelData: [*]const u8, len: u32, format: u8, alignedBytesPerRow: u32) void { const object_ptr = acquireBuffer(buffer_handle) orelse return; object_ptr.drawSuperSampleBuffer(x, y, pixelData, len, format, alignedBytesPerRow); } @@ -3992,12 +3994,13 @@ export fn freeUnicode(charsPtr: ?[*]const EncodedChar, charsLen: u32) void { export fn bufferDrawChar( buffer_handle: NativeHandle, char: u32, - x: u32, - y: u32, + x: i32, + y: i32, fg: [*]const u16, bg: [*]const u16, attributes: u32, ) void { const object_ptr = acquireBuffer(buffer_handle) orelse return; - object_ptr.drawChar(char, x, y, ptrToRGBA(fg), ptrToRGBA(bg), attributes); + if (x < 0 or y < 0) return; + object_ptr.drawChar(char, @intCast(x), @intCast(y), ptrToRGBA(fg), ptrToRGBA(bg), attributes); } diff --git a/packages/native/src/tests/buffer_test.zig b/packages/native/src/tests/buffer_test.zig index 52630cf9..3b63ce07 100644 --- a/packages/native/src/tests/buffer_test.zig +++ b/packages/native/src/tests/buffer_test.zig @@ -773,6 +773,131 @@ test "OptimizedBuffer - alpha blending downgrades blended metadata to rgb" { try std.testing.expectEqual(ansi.ColorIntent.rgb, ansi.intent(bg_blended_cell.bg)); } +test "OptimizedBuffer - blending cell setters skip coordinates outside the buffer" { + const pool = gp.initGlobalPool(std.testing.allocator); + defer gp.deinitGlobalPool(); + + var buf = try OptimizedBuffer.init(std.testing.allocator, 2, 2, .{ .pool = pool, .id = "outside-cell-setters" }); + defer buf.deinit(); + + const base_bg = ansi.rgbaFromFloats(0.0, 0.0, 0.0, 1.0); + buf.clear(base_bg, null); + const opaque_fg = ansi.rgbaFromFloats(1.0, 1.0, 1.0, 1.0); + const translucent_bg = ansi.rgbaFromFloats(1.0, 0.0, 0.0, 0.5); + + // A u32 coordinate at or above 2^31 has no i32 value for the scissor check. + const outside = [_]u32{ 2, std.math.maxInt(i32) + 1, std.math.maxInt(u32) }; + for ([_]bool{ false, true }) |scissored| { + if (scissored) try buf.pushScissorRect(0, 0, 2, 2); + for (outside) |coordinate| { + const points = [_][2]u32{ .{ coordinate, 0 }, .{ 0, coordinate } }; + for (points) |point| { + buf.drawChar('X', point[0], point[1], opaque_fg, base_bg, 0); + buf.drawChar('X', point[0], point[1], opaque_fg, translucent_bg, 0); + buf.setCellWithAlphaBlending(point[0], point[1], 'X', opaque_fg, translucent_bg, 0); + buf.setCellWithAlphaBlendingRaw(point[0], point[1], 'X', opaque_fg, translucent_bg, 0); + } + } + } + + for (0..2) |y| { + for (0..2) |x| { + try std.testing.expectEqual(buffer_mod.DEFAULT_SPACE_CHAR, buf.get(@intCast(x), @intCast(y)).?.char); + } + } +} + +fn expectRowChars(buf: *OptimizedBuffer, y: u32, expected: []const u8) !void { + for (expected, 0..) |char, x| { + try std.testing.expectEqual(@as(u32, char), buf.get(@intCast(x), y).?.char); + } +} + +test "OptimizedBuffer - text and rectangles at signed positions draw their visible part" { + const pool = gp.initGlobalPool(std.testing.allocator); + defer gp.deinitGlobalPool(); + + var buf = try OptimizedBuffer.init(std.testing.allocator, 4, 5, .{ .pool = pool, .id = "signed-draw-positions" }); + defer buf.deinit(); + + const black = ansi.rgbaFromFloats(0.0, 0.0, 0.0, 1.0); + const white = ansi.rgbaFromFloats(1.0, 1.0, 1.0, 1.0); + const red = ansi.rgbaFromFloats(1.0, 0.0, 0.0, 1.0); + const min = std.math.minInt(i32); + buf.clear(black, null); + + // Opaque printable ASCII uses the byte fast path. Text without a background uses the cluster path. + try buf.drawTextClipped("ABCDE", -2, 0, white, black, 0); + // The wide glyph covers columns -1 and 0, so it is clipped and column 0 keeps its cell. + try buf.drawTextClipped("A世BC", -2, 1, white, null, 0); + try buf.drawTextClipped("\tX", -1, 2, white, red, 0); + try buf.drawTextClipped("e\u{301}XY", -1, 3, white, null, 0); + try buf.pushScissorRect(1, 4, 2, 1); + try buf.drawTextClipped("WXYZ", -1, 4, white, null, 0); + buf.popScissorRect(); + // These draws have no cell inside the buffer. + try buf.drawTextClipped("ZZZZ", 0, -1, white, black, 0); + try buf.drawTextClipped("ZZZZ", min, 0, white, black, 0); + try buf.drawTextClipped("Z世ZZ", min, 0, white, null, 0); + + try expectRowChars(buf, 0, "CDE "); + try expectRowChars(buf, 1, " BC "); + try expectRowChars(buf, 2, " X "); + try std.testing.expect(buffer_mod.rgbaEqual(red, buf.get(0, 2).?.bg)); + try expectRowChars(buf, 3, "XY "); + try expectRowChars(buf, 4, " YZ "); + + buf.fillRectClipped(-1, -1, 2, 2, red); + buf.fillRectClipped(std.math.maxInt(i32), min, std.math.maxInt(u32), std.math.maxInt(u32), white); + buf.fillRectClipped(min, 4, std.math.maxInt(u32), 1, red); + + try std.testing.expect(buffer_mod.rgbaEqual(red, buf.get(0, 0).?.bg)); + try std.testing.expect(buffer_mod.rgbaEqual(black, buf.get(1, 0).?.bg)); + try std.testing.expect(buffer_mod.rgbaEqual(black, buf.get(0, 1).?.bg)); + try std.testing.expect(buffer_mod.rgbaEqual(red, buf.get(3, 4).?.bg)); +} + +test "OptimizedBuffer - pixel buffers at signed positions draw their visible part" { + const pool = gp.initGlobalPool(std.testing.allocator); + defer gp.deinitGlobalPool(); + + var buf = try OptimizedBuffer.init(std.testing.allocator, 2, 1, .{ .pool = pool, .id = "signed-pixel-buffers" }); + defer buf.deinit(); + const black = ansi.rgbaFromFloats(0.0, 0.0, 0.0, 1.0); + + // Two cells of 2x2 RGBA pixels: a red cell, then a blue cell. + const red = [4]u8{ 255, 0, 0, 255 }; + const blue = [4]u8{ 0, 0, 255, 255 }; + const pixels = red ++ red ++ blue ++ blue ++ red ++ red ++ blue ++ blue; + + buf.drawSuperSampleBuffer(0, 0, &pixels, pixels.len, 1, 16); + const blue_cell = buf.get(1, 0).?; + buf.clear(black, null); + // The visible cells of the first two draws map past the end of the pixels. + buf.drawSuperSampleBuffer(std.math.minInt(i32), 0, &pixels, pixels.len, 1, std.math.maxInt(u32)); + buf.drawSuperSampleBuffer(0, std.math.minInt(i32), &pixels, pixels.len, 1, std.math.maxInt(u32)); + buf.drawSuperSampleBuffer(-1, 0, &pixels, pixels.len, 1, 16); + + const cell = buf.get(0, 0).?; + try std.testing.expectEqual(blue_cell.char, cell.char); + try std.testing.expect(buffer_mod.rgbaEqual(blue_cell.fg, cell.fg)); + try std.testing.expect(buffer_mod.rgbaEqual(blue_cell.bg, cell.bg)); + + // Each packed cell is bg(4 f32), fg(4 f32), char(u32), and 12 padding bytes. + const packed_cells = [2][12]f32{ + .{ 0, 0, 0, 1, 1, 1, 1, 1, @bitCast(@as(u32, 'A')), 0, 0, 0 }, + .{ 0, 0, 0, 1, 1, 1, 1, 1, @bitCast(@as(u32, 'B')), 0, 0, 0 }, + }; + const packed_bytes = std.mem.sliceAsBytes(&packed_cells); + + buf.clear(black, null); + buf.drawPackedBuffer(packed_bytes.ptr, packed_bytes.len, -1, 0, 2, 1); + buf.drawPackedBuffer(packed_bytes.ptr, packed_bytes.len, 0, -1, 2, 1); + buf.drawPackedBuffer(packed_bytes.ptr, packed_bytes.len, 0, 0, 0, 1); + + try expectRowChars(buf, 0, "B "); +} + test "OptimizedBuffer - transparent framebuffer cell background stays transparent over backdrop" { const pool = gp.initGlobalPool(std.testing.allocator); defer gp.deinitGlobalPool(); diff --git a/packages/web/src/content/docs/reference/buffer-api.mdx b/packages/web/src/content/docs/reference/buffer-api.mdx index 62f4ef3c..de268434 100644 --- a/packages/web/src/content/docs/reference/buffer-api.mdx +++ b/packages/web/src/content/docs/reference/buffer-api.mdx @@ -117,6 +117,10 @@ draw the tagged values returned in `data` and maintain their continuation cells. | `drawText` | `drawText(text, x, y, fg, bg?, attributes = 0, selection?)` segments and clips Unicode text | | `fillRect` | `fillRect(x, y, width, height, bg)` paints the rectangle through tracker-aware cell paths | +Positions are integer cells from `-2^31` to `2^31 - 1`, so they can be negative. These methods draw only the cells +inside the buffer. `drawText()` skips a wide grapheme that starts left of column 0. `fillRect()` draws nothing when +`width` or `height` is 0 or less. + The optional `drawText()` selection object has `{ start, end, bgColor?, fgColor? }`. Its implementation slices the JavaScript string with UTF-16 indexes and adds the same values to the cell x-coordinate. It is reliable only for one-cell Basic Multilingual Plane text. Use `TextBufferView` or `EditorView` selection for general Unicode text.