//! A row of cells. const std = @import("std"); const ir = @import("root.zig"); const Allocator = std.mem.Allocator; const ArrayList = std.ArrayList; const Writer = std.Io.Writer; const t = std.testing; const Cell = ir.Cell; const Style = ir.Style; const Link = ir.Link; const Method = ir.width.Method; const Line = @This(); cells: []Cell = &.{}, pub fn init(allocator: Allocator, size: usize) !Line { const cells = try allocator.alloc(Cell, size); for (cells) |*cell| cell.* = .space; return .{ .cells = cells }; } // this might be broken in actual usage pub fn deinit(self: *Line, allocator: Allocator) void { allocator.free(self.cells); } // pub fn new(allocator: Allocator, w: usize) !Line { // const cells = try allocator.alloc(Cell, w); // for (cells) |*c| c.* = .blank; // return .{ .cells = cells }; // } pub fn width(self: Line) usize { return self.cells.len; } /// Returns the cell at x, or null when out of bounds. pub fn at(self: Line, x: usize) ?*Cell { if (x >= self.cells.len) return null; return &self.cells[x]; } /// Sets the cell at x. Nil cells are treated as blank empty cells. /// Wide cells are clamped to the line and their continuation columns are /// zero-width placeholders; partially overwriting a wide cell blanks it. pub fn set(self: Line, x: usize, cell: ?Cell) void { const line_width = self.cells.len; if (x >= line_width) return; const prev = self.at(x); if (prev) |p| { const pw = @as(usize, p.width); if (pw > 1) { // Writing to the first wide cell: clear it. var j: usize = 0; while (j < pw and x + j < line_width) : (j += 1) { self.cells[x + j] = p.*; self.cells[x + j].emptyize(); } } else if (pw == 0) { // Writing to wide cell placeholders: clear the wide cell. var j: usize = 1; while (j <= x) : (j += 1) { if (self.at(x - j)) |wide| { const ww = @as(usize, wide.width); if (ww > 1 and j < ww) { var k: usize = 0; while (k < ww) : (k += 1) { self.cells[x - j + k] = wide.*; self.cells[x - j + k].emptyize(); } break; } } } } } const cc = cell orelse { self.cells[x] = .space; return; }; self.cells[x] = cc; const cw = @as(usize, cc.width); if (x + cw > line_width) { // The cell doesn't fit: write blanks with the same style. var i: usize = 0; while (i < cw and x + i < line_width) : (i += 1) { self.cells[x + i] = cc; self.cells[x + i].emptyize(); } return; } if (cw > 1) { // Mark wide cells with zero-width placeholder cells. var j: usize = 1; while (j < cw and x + j < line_width) : (j += 1) { self.cells[x + j] = .empty; } } } /// Plain string representation; trailing (unstyled) spaces are removed. pub fn string(self: Line, allocator: Allocator) ![]u8 { var out: ArrayList(u8) = .empty; defer out.deinit(allocator); var pending: usize = 0; for (self.cells) |c| { if (c.isWidePlaceholder()) continue; if (c.equal(.space)) { pending += 1; continue; } while (pending > 0) : (pending -= 1) try out.append(allocator, ' '); try out.appendSlice(allocator, c.content); } return out.toOwnedSlice(allocator); } /// Renders the line with styles, preserving trailing spaces. pub fn renderAlloc(self: Line, allocator: Allocator) ![]u8 { var aw: Writer.Allocating = .init(allocator); defer aw.deinit(); try self.render(&aw.writer); var list = aw.toArrayList(); return list.toOwnedSlice(allocator); } /// Renders a single line with the required SGR attributes and hyperlinks. /// Empty (space) cells are emitted as spaces without styling; trailing spaces /// are preserved (unlike `Line.string`). fn render(self: Line, writer: *Writer) Writer.Error!void { var pen: Style = .{}; var link: Link = .{}; for (self.cells) |cell| { if (cell.isWidePlaceholder()) continue; if (cell.equal(.space)) { if (!pen.isZero()) { try writer.writeAll(ir.ansi.resetStyle); pen = .{}; } if (!link.isZero()) { _ = try ir.ansi.resetHyperlink(writer, ""); link = .{}; } try writer.writeAll(" "); continue; } if (cell.style.isZero() and !pen.isZero()) { try writer.writeAll(ir.ansi.resetStyle); pen = .{}; } if (!cell.style.equal(pen)) { _ = try Style.writeDiff(pen, cell.style, writer); pen = cell.style; } // Reset a stale hyperlink before switching. if (!cell.link.equal(link) and link.url.len != 0) { _ = try ir.ansi.resetHyperlink(writer, ""); link = .{}; } if (!cell.link.equal(link)) { _ = try ir.ansi.setHyperlink(writer, cell.link.url, cell.link.params); link = cell.link; } try writer.writeAll(cell.content); } if (link.url.len != 0) _ = try ir.ansi.resetHyperlink(writer, ""); if (!pen.isZero()) try writer.writeAll(ir.ansi.resetStyle); } /// Whether the line contains a cell that a cell-level diff cannot safely /// reposition across: a wide cell (width > 1), or a cell whose width the /// terminal may measure differently than the model. pub fn hasDrift(self: Line, method: Method) bool { for (self.cells) |c| { if (c.width == 0 or c.content.len == 0) continue; if (c.width > 1 or method.stringWidth(c.content) != ir.width.wcWidth(c.content)) return true; } return false; } /// FNV-1a over the concatenated cell contents. Deterministic stand-in for /// Go's `hash/maphash`. pub fn hash(self: *const Line) u64 { var h: u64 = 1469598103934665603; for (self.cells) |c| { for (c.content) |b| { h ^= b; h *%= 1099511628211; } } return h; } test "line string trims trailing unstyled spaces" { var l: Line = try .init(t.allocator, 6); defer l.deinit(t.allocator); l.cells[0] = .{ .content = "你", .width = 2 }; l.cells[1] = .empty; // continuation l.cells[2] = .{ .content = "好", .width = 2 }; l.cells[3] = .empty; // continuation l.cells[4] = .of("!", 1); // Pos 5 left as empty space -> trimmed. const slice = try l.string(t.allocator); defer t.allocator.free(slice); try t.expectEqualStrings("你好!", slice); } test "render line preserves trailing spaces" { var l: Line = try .init(t.allocator, 10); defer l.deinit(t.allocator); l.cells[0] = .of("H", 1); l.cells[1] = .of("i", 1); const slice = try l.renderAlloc(t.allocator); defer t.allocator.free(slice); try t.expectEqualStrings("Hi ", slice); } test "render line styled trailing spaces" { var l: Line = try .init(t.allocator, 5); defer l.deinit(t.allocator); l.cells[0] = .of("A", 1); for (l.cells[1..]) |*cell| { cell.* = .{ .content = " ", .width = 1, .style = .{ .bg = .{ .ansi16 = 1 } }, }; } const slice = try l.renderAlloc(t.allocator); defer t.allocator.free(slice); try t.expectEqualStrings("A\x1b[41m \x1b[m", slice); } test "render line with hyperlink" { var l: Line = try .init(t.allocator, 4); defer l.deinit(t.allocator); const link: Link = .init("http://example.com", ""); const contents = [_][]const u8{ "L", "i", "n", "k" }; for (l.cells, 0..) |*c, i| { c.* = .{ .content = contents[i], .width = 1, .link = link, }; } const slice = try l.renderAlloc(t.allocator); defer t.allocator.free(slice); try t.expectEqualStrings( "\x1b]8;;http://example.com\x07Link\x1b]8;;\x07", slice, ); } test "line set semantics" { var l: Line = try .init(t.allocator, 10); defer l.deinit(t.allocator); l.set(5, .of("a", 1)); try t.expectEqualStrings("a", l.cells[5].content); // Wide cell then overwrite first column. l.set(2, .of("世", 2)); try t.expectEqualStrings("世", l.cells[2].content); try t.expect(l.cells[3].isWidePlaceholder()); l.set(2, .of("a", 1)); try t.expectEqualStrings("a", l.cells[2].content); // Overwrite middle of a wide cell blanks it. l.set(4, .of("世", 2)); l.set(5, .of("a", 1)); try t.expectEqualStrings("a", l.cells[5].content); try t.expect(l.cells[4].equal(.space)); // Wide cell at the end doesn't fit: written as styled spaces. for (l.cells) |*cell| cell.* = .space; l.set(9, .of("世", 2)); try t.expect(l.cells[9].equal(.space)); }