diff --git a/include/ghostty/vt/render.h b/include/ghostty/vt/render.h index 2e3f2f71e..044d1734b 100644 --- a/include/ghostty/vt/render.h +++ b/include/ghostty/vt/render.h @@ -236,6 +236,19 @@ typedef enum GHOSTTY_ENUM_TYPED { /** Row-local selected cell range (GhosttyRenderStateRowSelection). */ GHOSTTY_RENDER_STATE_ROW_DATA_SELECTION = 4, + + /** A borrowed view of the raw cell values for the current row + * (GhosttyCellsView). One value per column, identical to querying + * GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_RAW for each cell. The view + * is only valid as long as the underlying render state is not + * updated; it is unsafe to use after updating the render state. + * + * This is the bulk alternative to iterating cells one at a time. + * It lets callers with expensive call boundaries (e.g. WebAssembly + * embedders) read an entire row with a single call, then drill + * into the cells iterator only for cells that need managed data + * (styles, graphemes). */ + GHOSTTY_RENDER_STATE_ROW_DATA_CELLS_RAW = 5, GHOSTTY_RENDER_STATE_ROW_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyRenderStateRowData; diff --git a/include/ghostty/vt/screen.h b/include/ghostty/vt/screen.h index 9f639b583..0d009a781 100644 --- a/include/ghostty/vt/screen.h +++ b/include/ghostty/vt/screen.h @@ -50,6 +50,23 @@ typedef uint64_t GhosttyCell; */ typedef uint64_t GhosttyRow; +/** + * A borrowed view of contiguous raw cell values. + * + * The memory is not owned by this struct. The pointer is only valid + * for the lifetime documented by the API that produces it. Each value + * is queried via ghostty_cell_get() like any other GhosttyCell. + * + * @ingroup screen + */ +typedef struct { + /** Pointer to len contiguous cell values. */ + const GhosttyCell* ptr; + + /** Number of cells. */ + size_t len; +} GhosttyCellsView; + /** * Cell content tag. * diff --git a/src/terminal/c/cell.zig b/src/terminal/c/cell.zig index cb76cb2fd..8444cfd97 100644 --- a/src/terminal/c/cell.zig +++ b/src/terminal/c/cell.zig @@ -10,6 +10,12 @@ const Result = @import("result.zig").Result; /// C: GhosttyCell pub const CCell = u64; +/// C: GhosttyCellsView +pub const CellsView = extern struct { + ptr: ?[*]const CCell, + len: usize, +}; + /// C: GhosttyCellContentTag pub const ContentTag = enum(c_int) { codepoint = 0, diff --git a/src/terminal/c/render.zig b/src/terminal/c/render.zig index 60e67e609..c5383558c 100644 --- a/src/terminal/c/render.zig +++ b/src/terminal/c/render.zig @@ -12,6 +12,7 @@ const terminal_c = @import("terminal.zig"); const ZigTerminal = @import("../Terminal.zig"); const renderpkg = @import("../render.zig"); const Result = @import("result.zig").Result; +const cell_c = @import("cell.zig"); const row = @import("row.zig"); const style_c = @import("style.zig"); @@ -614,10 +615,11 @@ fn rowCellsGetTypedInner( switch (data) { .invalid => return .invalid_value, .raw => out.* = cell.cval(), - .style => out.* = if (cell.hasStyling()) - style_c.Style.fromStyle(cells.styles[x]) - else - comptime style_c.Style.fromStyle(.{}), + .style => if (cell.hasStyling()) { + style_c.Style.write(cells.styles[x], out); + } else { + out.* = style_c.Style.default; + }, .graphemes_len => { if (!cell.hasText()) { out.* = 0; @@ -739,6 +741,7 @@ pub const RowData = enum(c_int) { raw = 2, cells = 3, selection = 4, + cells_raw = 5, /// Output type expected for querying the data of the given kind. pub fn OutType(comptime self: RowData) type { @@ -748,6 +751,7 @@ pub const RowData = enum(c_int) { .raw => row.CRow, .cells => RowCells, .selection => RowSelection, + .cells_raw => cell_c.CellsView, }; } }; @@ -864,6 +868,13 @@ fn rowGetTyped( out.start_x = sel[0]; out.end_x = sel[1]; }, + .cells_raw => { + const raws: []const page.Cell = it.cells[y].items(.raw); + out.* = .{ + .ptr = @ptrCast(raws.ptr), + .len = raws.len, + }; + }, } return .success; @@ -1326,6 +1337,68 @@ test "render: row get selection" { try testing.expectEqual(Result.no_value, row_get(it, .selection, @ptrCast(&sel))); } +test "render: row get cells_raw" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + 10, + 3, + )); + defer terminal_c.free(terminal); + + terminal_c.vt_write(terminal, "AB", 2); + + var state: RenderState = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &state, + )); + defer free(state); + + try testing.expectEqual(Result.success, update(state, terminal)); + + var it: RowIterator = null; + try testing.expectEqual(Result.success, row_iterator_new( + &lib.alloc.test_allocator, + &it, + )); + defer row_iterator_free(it); + + try testing.expectEqual(Result.success, get(state, .row_iterator, @ptrCast(&it))); + + // Not positioned on a row yet. + var view: cell_c.CellsView = undefined; + try testing.expectEqual(Result.invalid_value, row_get(it, .cells_raw, @ptrCast(&view))); + + try testing.expect(row_iterator_next(it)); + try testing.expectEqual(Result.success, row_get(it, .cells_raw, @ptrCast(&view))); + try testing.expectEqual(@as(usize, 10), view.len); + + // The view must match the per-cell raw reads. + var cells: RowCells = null; + try testing.expectEqual(Result.success, row_cells_new( + &lib.alloc.test_allocator, + &cells, + )); + defer row_cells_free(cells); + try testing.expectEqual(Result.success, row_get(it, .cells, @ptrCast(&cells))); + const ptr = view.ptr.?; + var x: u16 = 0; + while (x < view.len) : (x += 1) { + var raw: page.Cell.C = undefined; + try testing.expectEqual(Result.success, row_cells_select(cells, x)); + try testing.expectEqual(Result.success, row_cells_get(cells, .raw, @ptrCast(&raw))); + try testing.expectEqual(raw, ptr[x]); + } + + // Contents sanity: first two cells hold our text. + const first: page.Cell = @bitCast(ptr[0]); + const second: page.Cell = @bitCast(ptr[1]); + try testing.expectEqual(@as(u21, 'A'), first.codepoint()); + try testing.expectEqual(@as(u21, 'B'), second.codepoint()); +} + test "render: row cells get selected" { var terminal: terminal_c.Terminal = null; try testing.expectEqual(Result.success, terminal_c.new( diff --git a/src/terminal/c/style.zig b/src/terminal/c/style.zig index 82729fa94..bea4d074c 100644 --- a/src/terminal/c/style.zig +++ b/src/terminal/c/style.zig @@ -25,20 +25,32 @@ pub const Color = extern struct { tag: ColorTag, value: ColorValue, + // fromColor converts the tag as a plain integer, which requires + // the internal and C tag values to line up. + comptime { + const Tag = std.meta.Tag(style.Style.Color); + for (@typeInfo(Tag).@"enum".fields) |f| { + assert(f.value == @intFromEnum(@field(ColorTag, f.name))); + } + } + pub fn fromColor(c: style.Style.Color) Color { - return switch (c) { - .none => .{ - .tag = .none, - .value = .{ ._padding = 0 }, - }, - .palette => |idx| .{ - .tag = .palette, - .value = .{ .palette = idx }, - }, - .rgb => |rgb| .{ - .tag = .rgb, - .value = .{ .rgb = rgb.cval() }, - }, + // The value is built as a single integer rather than through + // the per-variant union fields: the C union layout puts + // .palette at byte 0 and .rgb at bytes 0-2, which is exactly + // the internal payload representation, so each variant is the + // payload zero-extended to the u64 union backing. This keeps + // the conversion to two stores (tag + value) per color, which + // matters for the per-cell style reads in the render state + // API. + const value: u64 = switch (c) { + .none => 0, + .palette => |idx| idx, + .rgb => |rgb| @as(u24, @bitCast(rgb)), + }; + return .{ + .tag = @enumFromInt(@intFromEnum(std.meta.activeTag(c))), + .value = .{ ._padding = value }, }; } }; @@ -59,27 +71,83 @@ pub const Style = extern struct { overline: bool, underline: c_int, - pub fn fromStyle(s: style.Style) Style { - return .{ - .fg_color = .fromColor(s.fg_color), - .bg_color = .fromColor(s.bg_color), - .underline_color = .fromColor(s.underline_color), - .bold = s.flags.bold, - .italic = s.flags.italic, - .faint = s.flags.faint, - .blink = s.flags.blink, - .inverse = s.flags.inverse, - .invisible = s.flags.invisible, - .strikethrough = s.flags.strikethrough, - .overline = s.flags.overline, - .underline = @intFromEnum(s.flags.underline), + /// The default (empty) style in C representation. C + pub const default: Style = .{ + .fg_color = .{ .tag = .none, .value = .{ ._padding = 0 } }, + .bg_color = .{ .tag = .none, .value = .{ ._padding = 0 } }, + .underline_color = .{ .tag = .none, .value = .{ ._padding = 0 } }, + .bold = false, + .italic = false, + .faint = false, + .blink = false, + .inverse = false, + .invisible = false, + .strikethrough = false, + .overline = false, + .underline = 0, + }; + + // fromStyle writes the eight bool fields as one 8-byte store, + // which requires them to be consecutive and to match the low + // eight flag bits in order. + comptime { + const flag_names = [8][:0]const u8{ + "bold", "italic", "faint", "blink", + "inverse", "invisible", "strikethrough", "overline", }; + const base = @offsetOf(Style, "bold"); + for (flag_names, 0..) |name, i| { + assert(@offsetOf(Style, name) == base + i); + assert(@bitOffsetOf(@FieldType(style.Style, "flags"), name) == i); + } + } + + /// Write the C representation of the given style directly through + /// the out pointer. The hot per-cell style read in the render + /// state API uses this rather than `out.* = fromStyle(s)`: the + /// by-value form materializes a stack temporary plus a + /// @sizeOf(Style)-byte copy that LLVM does not elide. + pub fn write(s: style.Style, out: *Style) void { + out.size = @sizeOf(Style); + out.fg_color = .fromColor(s.fg_color); + out.bg_color = .fromColor(s.bg_color); + out.underline_color = .fromColor(s.underline_color); + + // I know this looks scary, but this results in truly measurable + // performance improvements due to how often styles are written + // especially in render loops. We vectorize our 8-bit to 8-byte + // write. + // + // We have to align(1) because we're ptrCasting a u8 pointer. + @as(*align(1) u64, @ptrCast(&out.bold)).* = bytes: { + // Spread the low eight flag bits (the eight bool fields, in + // order) into one byte each and write them with a single + // 8-byte store. + const flag_bits: u8 = @truncate(@as(u16, @bitCast(s.flags))); + const masks: @Vector(8, u8) = .{ 1, 2, 4, 8, 16, 32, 64, 128 }; + const hits = (@as(@Vector(8, u8), @splat(flag_bits)) & masks) == masks; + const bytes: @Vector(8, u8) = @select( + u8, + hits, + @as(@Vector(8, u8), @splat(1)), + @as(@Vector(8, u8), @splat(0)), + ); + break :bytes @bitCast(bytes); + }; + + out.underline = @intFromEnum(s.flags.underline); + } + + pub fn fromStyle(s: style.Style) Style { + var result: Style = undefined; + write(s, &result); + return result; } }; /// Returns the default style. pub fn default_style(result: *Style) callconv(lib.calling_conv) void { - result.* = .fromStyle(.{}); + result.* = .default; assert(result.size == @sizeOf(Style)); } diff --git a/src/terminal/color.zig b/src/terminal/color.zig index 8bf327eac..3df9a0e6a 100644 --- a/src/terminal/color.zig +++ b/src/terminal/color.zig @@ -155,16 +155,27 @@ pub fn paletteCvalSlice(src: []const RGB, dst: []RGB.C) void { // (pointer cast) because a value-level @bitCast of packed // structs operates on the 24-bit value bits, not the 4-byte // in-memory representation. + const V = @Vector(16, u8); const dst_bytes: [*]u8 = @ptrCast(dst.ptr); while (i + 6 <= src.len) : (i += 4) { - const in: @Vector(16, u8) = @as( - *const [16]u8, - @ptrCast(src[i..][0..4]), - ).*; - const out: [16]u8 = @shuffle(u8, in, undefined, [16]i32{ - 0, 1, 2, 4, 5, 6, 8, 9, 10, 12, 13, 14, 3, 7, 11, 15, + // All intermediaries have to also go through a Vector type + // to ensure LLVM lowers as vectorized ops. In Zig 0.16 the + // LLVM auto-vectorizer is disabled so this is required. + const in: V = @as(*align(4) const V, @ptrCast(src[i..][0..4])).*; + const out: V = @shuffle(u8, in, undefined, [16]i32{ + 0, 1, 2, + 4, 5, 6, + 8, 9, 10, + 12, 13, 14, + + // Overflow we don't care about + 3, 7, 11, + 15, }); - dst_bytes[i * 3 ..][0..16].* = out; + + // Scary looking but safe, mapping a byte pointer to the full + // vector type (hence align 1). + @as(*align(1) V, @ptrCast(dst_bytes[i * 3 ..][0..16])).* = out; } } diff --git a/src/terminal/render.zig b/src/terminal/render.zig index 11db57a2a..0c9bca318 100644 --- a/src/terminal/render.zig +++ b/src/terminal/render.zig @@ -793,7 +793,7 @@ pub const RenderState = struct { const end = @min(run.end, styles.len); const start = @min(run.start, end); - @memset(styles[start..end], run.style); + fillStyles(styles[start..end], run.style); } // Record what we applied so the next rebuild of this row @@ -810,6 +810,36 @@ pub const RenderState = struct { self.pending_styles.clearRetainingCapacity(); } + /// Fill a slice of styles with one value. + /// + /// This is equivalent to `@memset(dst, value)` but manually vectorized: + /// `@memset` with a struct value lowers to a per-element field copy + /// that reloads the source at every iteration because LLVM cannot + /// prove the destination doesn't alias it. And, Zig 0.16 disables + /// auto-vectorization due to an LLVM bug. + /// + /// This complexity is justified by accounting for ~10% of the endUpdate + /// times under heavily styled cases. + fn fillStyles(dst: []Style, value: Style) void { + // Each element is written as two overlapping 16-byte vector + // stores held in registers, which requires 16 <= size <= 32. + const elem_size = @sizeOf(Style); + comptime assert(elem_size >= 16 and elem_size <= 32); + + const V = @Vector(16, u8); + const src: *align(@alignOf(Style)) const [elem_size]u8 = @ptrCast(&value); + const lo = @as(*align(4) const V, @ptrCast(src[0..16])).*; + const hi = @as(*align(1) const V, @ptrCast(src[elem_size - 16 ..][0..16])).*; + + const dst_bytes = std.mem.sliceAsBytes(dst); + var off: usize = 0; + for (0..dst.len) |_| { + @as(*align(1) V, @ptrCast(dst_bytes[off..][0..16])).* = lo; + @as(*align(1) V, @ptrCast(dst_bytes[off + elem_size - 16 ..][0..16])).* = hi; + off += elem_size; + } + } + /// Returns true if the two style run lists denormalize to /// identical per-cell style data. This is a semantic comparison /// (styles are compared field-wise, never by bytes, since padding