const std = @import("std"); const measure = @import("measure.zig"); const util = @import("util.zig"); const position = @import("position.zig"); const properties = @import("properties.zig"); const Color = @import("Color.zig"); const t = std.testing; const assert = std.debug.assert; const Allocator = std.mem.Allocator; const Writer = std.Io.Writer; const Underline = properties.Underline; const Strikethrough = properties.Strikethrough; const Padding = properties.Padding; const Margin = properties.Margin; const Border = properties.Border; const Side = properties.Side; const Style = @This(); internal: Internal, const Internal = struct { bold: bool = false, italic: bool = false, underline: Underline = .none, strikethrough: Strikethrough = .off, reverse: bool = false, blink: bool = false, faint: bool = false, foreground: ?Color = null, background: ?Color = null, width: ?u16 = null, height: ?u16 = null, tabs: ?u8 = 4, padding: Padding = .none, margin: Margin = .none, border: Border = .none, max_width: ?u16 = null, max_height: ?u16 = null, align_horizontal: HAlign = .left, align_vertical: VAlign = .top, color_whitespace: bool = true, inline_mode: bool = false, link: ?[]const u8 = null, link_params: ?[]const u8 = null, }; pub const empty: Style = .{ .internal = .{} }; pub const none: Style = .empty; pub fn new(style: Internal) Style { return .{ .internal = style }; } pub fn getPadding(style: Style) Padding { return style.internal.padding; } pub fn getMargin(style: Style) Margin { return style.internal.margin; } pub fn getBorder(style: Style) Border { return style.internal.border; } pub fn getStrikethrough(style: Style) Strikethrough { return style.internal.strikethrough; } pub fn getInt(style: Style, field: enum { height, width, max_height, max_width, }) ?u16 { return @field(style.internal, @tagName(field)); } pub fn getColor(style: Style, v: enum { fg, bg }) ?Color { return switch (v) { .fg => style.internal.foreground, .bg => style.internal.background, }; } pub fn getBool(style: Style, field: enum { bold, italic, reverse, blink, faint, inline_mode, color_whitespace, }) bool { return @field(style.internal, field); } pub fn bold(style: Style, set: bool) Style { var copy = style; copy.internal.bold = set; return copy; } pub fn reverse(style: Style, set: bool) Style { var copy = style; copy.internal.reverse = set; return copy; } pub fn faint(style: Style, set: bool) Style { var copy = style; copy.internal.faint = set; return copy; } pub fn italic(style: Style, set: bool) Style { var copy = style; copy.internal.italic = set; return copy; } pub fn blink(style: Style, set: bool) Style { var copy = style; copy.internal.blink = set; return copy; } pub fn strikethrough(style: Style, set: Strikethrough) Style { var copy = style; copy.internal.strikethrough = set; return copy; } pub const st = strikethrough; pub fn underline(style: Style, set: Underline) Style { var copy = style; copy.internal.underline = set; return copy; } pub const ul = underline; /// Sets the border (lipgloss `BorderStyle`). All four edges are drawn unless /// disabled via `Border.edge`/`Border.edges`; colors are set on the border /// itself — see [Border.fg], [Border.bg] and [Border.colored]. pub fn border(style: Style, set: Border) Style { var copy = style; copy.internal.border = set; return copy; } pub const box = border; pub fn foreground(style: Style, set: ?Color) Style { var copy = style; copy.internal.foreground = set; return copy; } pub const fg = foreground; pub fn background(style: Style, set: ?Color) Style { var copy = style; copy.internal.background = set; return copy; } pub const bg = background; pub fn width(style: Style, set: ?u16) Style { var copy = style; copy.internal.width = set; return copy; } pub const cols = width; pub fn height(style: Style, set: ?u16) Style { var copy = style; copy.internal.height = set; return copy; } pub const rows = height; /// Sets the tab stop width (lipgloss `TabWidth`). `null` disables tab /// conversion, `0` strips tabs. pub fn tabWidth(style: Style, spaces: ?u8) Style { var copy = style; copy.internal.tabs = spaces; return copy; } pub const tabs = tabWidth; /// Error set returned by [render]: allocation failures plus writer failures. pub const RenderError = Allocator.Error || Writer.Error; /// A render-time text transform (lipgloss `Transform`). It receives the /// allocator `render` is using and must return a freshly allocated string; /// `render` owns and frees the result. const Transform = fn (Allocator, []const u8) Allocator.Error![]u8; /// Horizontal alignment of content within a styled block. Mirrors lipgloss /// `Left`/`Center`/`Right`. Unlike lipgloss's float `Position`, alignment is /// an enum: fractional placement only exists for `position.place`/`join`. const HAlign = enum { left, center, right }; /// Vertical alignment of content within a styled block. Mirrors lipgloss /// `Top`/`Center`/`Bottom`. See [HAlign] on why this is an enum. const VAlign = enum { top, center, bottom }; /// Sets padding on the given side(s) (lipgloss `Padding*`). pub fn pad(style: Style, side: Side, cells: u16) Style { var out = style; out.internal.padding.spacing.set(side, cells); return out; } /// Sets the padding fill character (lipgloss `PaddingChar`). pub fn paddingChar(style: Style, char: u21) Style { var out = style; out.internal.padding.char = char; return out; } /// Sets margin on the given side(s) (lipgloss `Margin*`). pub fn margin(style: Style, side: Side, cells: u16) Style { var out = style; out.internal.margin.spacing.set(side, cells); return out; } /// Sets the margin fill character (lipgloss `MarginChar`). pub fn marginChar(style: Style, char: u21) Style { var out = style; out.internal.margin.char = char; return out; } /// Sets the margin background color (lipgloss `MarginBackground`). pub fn marginBackground(style: Style, color: ?Color) Style { var out = style; out.internal.margin = out.internal.margin.colored(color); return out; } /// Caps the rendered width, truncating each line (lipgloss `MaxWidth`). pub fn maxWidth(style: Style, cells: ?u16) Style { var out = style; out.internal.max_width = cells; return out; } /// Caps the rendered height, truncating trailing lines (lipgloss `MaxHeight`). pub fn maxHeight(style: Style, lines: ?u16) Style { var out = style; out.internal.max_height = lines; return out; } /// Sets the horizontal alignment (lipgloss `AlignHorizontal`). pub fn alignH(style: Style, a: HAlign) Style { var out = style; out.internal.align_horizontal = a; return out; } /// Sets the vertical alignment (lipgloss `AlignVertical`). pub fn alignV(style: Style, a: VAlign) Style { var out = style; out.internal.align_vertical = a; return out; } /// Controls whether the background color is applied to whitespace such as /// padding (lipgloss `ColorWhitespace`). Defaults to true. pub fn colorWhitespace(style: Style, set: bool) Style { var out = style; out.internal.color_whitespace = set; return out; } /// Renders on a single line, disabling padding, borders, and margins /// (lipgloss `Inline`). pub fn inlineMode(style: Style, set: bool) Style { var out = style; out.internal.inline_mode = set; return out; } /// Sets a hyperlink on the rendered text (lipgloss `Hyperlink`). pub fn hyperlink(style: Style, url: []const u8) Style { var out = style; out.internal.link = url; return out; } /// Sets a hyperlink with OSC 8 parameters (lipgloss `Hyperlink` with params). pub fn hyperlinkParams(style: Style, url: []const u8, params: []const u8) Style { var out = style; out.internal.link = url; out.internal.link_params = params; return out; } pub fn printAlloc(style: Style, allocator: Allocator, comptime fmt: []const u8, args: anytype) ![]u8 { var buffer: Writer.Allocating = .init(allocator); defer buffer.deinit(); try style.print(&buffer.writer, fmt, args); return buffer.toOwnedSlice(); } pub fn print(style: Style, writer: *Writer, comptime fmt: []const u8, args: anytype) !void { var buf: [4096]u8 = undefined; const text = std.fmt.bufPrint(&buf, fmt, args) catch |err| return err; try style.render(writer, text); } /// Renders `text` styled by this style into a freshly allocated string backed /// by `gpa`; the caller owns the result. A convenience wrapper over the /// allocator-free [render]. No trailing newline is emitted (matching lipgloss). pub fn renderAlloc(style: Style, allocator: Allocator, text: []const u8) ![]u8 { var buffer: Writer.Allocating = .init(allocator); defer buffer.deinit(); try style.render(&buffer.writer, text); return buffer.toOwnedSlice(); } pub fn render(style: Style, writer: *Writer, text: []const u8) Writer.Error!void { if (style.internal.max_width == null and style.internal.max_height == null) { return style.renderInner(writer, text); } // max_width / max_height are post-process caps on the final output; apply // them with a streaming filter so render stays allocator-free. var filter: FilterWriter = .init( writer, style.internal.max_width orelse std.math.maxInt(u16), style.internal.max_height orelse std.math.maxInt(u16), ); return style.renderInner(&filter.interface, text); } /// Renders `text` at compile time, returning a `[]const u8` with static /// lifetime (no allocator, no runtime work). Because `render` is allocator-free /// and only streams to a `*Writer`, the full layout pipeline — wrapping, /// alignment, borders, padding, margins, tab expansion, SGR/OSC8 emission — /// runs in the comptime interpreter. `self` and `text` must be comptime-known. /// /// Output is sized in two passes: a probe render into a fixed scratch buffer to /// learn the exact length, then an exact-sized emit. Output exceeding /// [comptime_scratch_size] raises a comptime error. pub fn renderComptime(comptime self: Style, comptime text: []const u8) []const u8 { const comptime_scratch_size = 64 * 1024; comptime { @setEvalBranchQuota(@as(u32, @max(text.len, 64)) * 100 + 10_000); // Pass 1: render into a fixed scratch buffer to learn the exact output // length. The buffer exists only in the comptime interpreter, so its // size costs nothing at runtime; overflowing it is a comptime error. var scratch: [comptime_scratch_size]u8 = undefined; var probe: Writer = .fixed(&scratch); // Overflowing the scratch buffer trips this `unreachable` at comptime, // reported as output exceeding `comptime_scratch_size`. self.render(&probe, text) catch unreachable; const len = probe.buffered().len; // Pass 2: emit into an exact-sized buffer and freeze it into a const so // the returned slice has static lifetime. var buf: [len]u8 = undefined; var fixed: Writer = .fixed(&buf); self.render(&fixed, text) catch unreachable; const frozen = buf; return &frozen; } } fn renderInner(style: Style, writer: *Writer, text: []const u8) Writer.Error!void { if (style.internal.inline_mode) { const has_link = style.internal.link != null and text.len > 0; if (has_link) try writeOsc8Open(writer, style.internal.link.?, style.internal.link_params); try writeSgrOpen(writer, style); const max_w = style.internal.max_width orelse std.math.maxInt(u16); var w: u16 = 0; var i: usize = 0; while (i < text.len) { const c = text[i]; if (c == '\r' or c == '\n') { i += 1; continue; } if (c == '\t') { i += 1; if (style.internal.tabs == null) { if (w < max_w) { try writer.writeByte('\t'); w += 1; } } else if (style.internal.tabs.? > 0) { const n: u16 = style.internal.tabs.?; var k: u16 = 0; while (k < n and w < max_w) : (k += 1) { try writer.writeByte(' '); w += 1; } } continue; } if (c == 0x1b) { const e = skipEscape(text, i); try writer.writeAll(text[i..e]); i = e; continue; } if (w >= max_w) break; const l = utf8Len(text, i); try writer.writeAll(text[i .. i + l]); w += 1; i += l; } try writeSgrClose(writer, style); if (has_link) try writeOsc8Close(writer); return; } // Block mode: two-pass (scan then emit). const sides = style.internal.border.edges; const left_b: u16 = if (sides.left) 1 else 0; const right_b: u16 = if (sides.right) 1 else 0; const top_b: u16 = if (sides.top) 1 else 0; const bottom_b: u16 = if (sides.bottom) 1 else 0; const h_border = left_b + right_b; const v_border = top_b + bottom_b; const pad_l = style.internal.padding.spacing.left; const pad_r = style.internal.padding.spacing.right; const pad_t = style.internal.padding.spacing.top; const pad_b = style.internal.padding.spacing.bottom; const marg_l = style.internal.margin.spacing.left; const marg_r = style.internal.margin.spacing.right; const wrap_at: u16 = if (style.internal.width) |w| w -| h_border -| pad_l -| pad_r else 0; // Pass 1: scan to get line count and max content width. const scan = wrapScanW(text, style.internal.tabs, wrap_at); const body_lines = scan.line_count; const content_w: u16 = if (style.internal.width) |w| w -| h_border else util.satAdd(util.satAdd(scan.max_line_w, pad_l), pad_r); const box_w = util.satAdd(util.satAdd(left_b, content_w), right_b); const total_w = util.satAdd(util.satAdd(marg_l, box_w), marg_r); const target_h: u16 = if (style.internal.height) |h| h -| v_border else util.satAdd(util.satAdd(body_lines, pad_t), pad_b); const inner_h = target_h -| pad_t -| pad_b; const blank_before: u16 = switch (style.internal.align_vertical) { .bottom => inner_h -| body_lines, .center => (inner_h -| body_lines) / 2, .top => 0, }; // Pass 2: streaming emit. Emit '\n' *before* each row except the first to // avoid a trailing newline (matching render()'s stripped output). var sep = false; // true once the first row has been emitted // margin top { var r: u16 = 0; while (r < style.internal.margin.spacing.top) : (r += 1) { if (sep) try writer.writeByte('\n'); sep = true; try writeMargin(writer, style, total_w); } } // border top if (sides.top) { if (sep) try writer.writeByte('\n'); sep = true; try writeBorderLineW(writer, style, marg_l, marg_r, content_w, sides.left, sides.right, .top); } // padding top { var r: u16 = 0; while (r < pad_t) : (r += 1) { if (sep) try writer.writeByte('\n'); sep = true; try writeFillRowW(writer, style, marg_l, marg_r, content_w, sides.left, sides.right); } } // vertical-align blank rows before content { var r: u16 = 0; while (r < blank_before) : (r += 1) { if (sep) try writer.writeByte('\n'); sep = true; try writeContentRowW(writer, style, marg_l, marg_r, content_w, sides.left, sides.right, ""); } } // content rows { var iter: WrapLineIterW = .init(text, style.internal.tabs, wrap_at); // Count the vertical-align blank rows already emitted so the fill loop // below tops up to exactly inner_h (max_height is handled by render's // FilterWriter, not here). var rendered: u16 = blank_before; while (iter.next()) |line| { if (rendered >= inner_h) break; if (sep) try writer.writeByte('\n'); sep = true; try writeContentRowW(writer, style, marg_l, marg_r, content_w, sides.left, sides.right, line); rendered += 1; } // fill remaining inner rows (height padding / vertical align) while (rendered < inner_h) : (rendered += 1) { if (sep) try writer.writeByte('\n'); sep = true; try writeContentRowW(writer, style, marg_l, marg_r, content_w, sides.left, sides.right, ""); } } // padding bottom { var r: u16 = 0; while (r < pad_b) : (r += 1) { if (sep) try writer.writeByte('\n'); sep = true; try writeFillRowW(writer, style, marg_l, marg_r, content_w, sides.left, sides.right); } } // border bottom if (sides.bottom) { if (sep) try writer.writeByte('\n'); sep = true; try writeBorderLineW(writer, style, marg_l, marg_r, content_w, sides.left, sides.right, .bottom); } // margin bottom { var r: u16 = 0; while (r < style.internal.margin.spacing.bottom) : (r += 1) { if (sep) try writer.writeByte('\n'); sep = true; try writeMargin(writer, style, total_w); } } } /// Encodes `cp` as UTF-8 and writes it `count` times. Invalid code points /// fall back to a space. fn writeRepeatCp(writer: *Writer, cp: u21, count: u16) Writer.Error!void { if (count == 0) return; var buf: [4]u8 = undefined; const len: usize = std.unicode.utf8Encode(cp, &buf) catch blk: { buf[0] = ' '; break :blk 1; }; var i: u16 = 0; while (i < count) : (i += 1) try writer.writeAll(buf[0..len]); } fn writeMargin(writer: *Writer, style: Style, count: u16) Writer.Error!void { if (count == 0) return; if (style.internal.margin.color) |c| { try c.writeSgr(writer, .bg); try writeRepeatCp(writer, style.internal.margin.char, count); try writer.writeAll("\x1b[0m"); } else { try writeRepeatCp(writer, style.internal.margin.char, count); } } /// Emits border glyphs, keeping one SGR group open while consecutive segments /// share colors. A uniformly-colored border therefore costs a single open/reset /// pair per row, while per-segment colors split into groups only where the /// colors actually change. const BorderRun = struct { writer: *Writer, active: bool = false, fg: ?Color = null, bg: ?Color = null, fn glyph(self: *BorderRun, char: []const u8, fg_color: ?Color, bg_color: ?Color, count: u16) Writer.Error!void { if (!std.meta.eql(fg_color, self.fg) or !std.meta.eql(bg_color, self.bg)) { try self.close(); if (fg_color) |c| { try c.writeSgr(self.writer, .fg); self.fg = c; } if (bg_color) |c| { try c.writeSgr(self.writer, .bg); self.bg = c; } self.active = fg_color != null or bg_color != null; } var i: u16 = 0; while (i < count) : (i += 1) try self.writer.writeAll(char); } fn close(self: *BorderRun) Writer.Error!void { if (self.active) try self.writer.writeAll("\x1b[0m"); self.active = false; self.fg = null; self.bg = null; } }; const BorderRow = enum { top, bottom }; fn writeOsc8Open(writer: *Writer, url: []const u8, params: ?[]const u8) Writer.Error!void { try writer.writeAll("\x1b]8;"); if (params) |p| try writer.writeAll(p); try writer.writeByte(';'); try writer.writeAll(url); try writer.writeAll("\x1b\\"); } fn writeOsc8Close(writer: *Writer) Writer.Error!void { try writer.writeAll("\x1b]8;;\x1b\\"); } fn writeUnderlineCode(writer: *Writer, kind: Underline.Style) Writer.Error!void { switch (kind) { .none => {}, .single => try writer.writeAll("\x1b[4m"), .double => try writer.writeAll("\x1b[4:2m"), .curly => try writer.writeAll("\x1b[4:3m"), .dotted => try writer.writeAll("\x1b[4:4m"), .dashed => try writer.writeAll("\x1b[4:5m"), } } fn writeSgrOpen(writer: *Writer, style: Style) Writer.Error!void { if (!style.hasAnyStyling()) return; if (style.internal.bold) try writer.writeAll("\x1b[1m"); if (style.internal.faint) try writer.writeAll("\x1b[2m"); if (style.internal.italic) try writer.writeAll("\x1b[3m"); try writeUnderlineCode(writer, style.internal.underline.style); if (style.internal.blink) try writer.writeAll("\x1b[5m"); if (style.internal.reverse) try writer.writeAll("\x1b[7m"); if (style.internal.strikethrough.enabled) try writer.writeAll("\x1b[9m"); if (style.internal.foreground) |color| try color.writeSgr(writer, .fg); if (style.internal.background) |color| try color.writeSgr(writer, .bg); if (style.internal.underline.color) |color| try color.writeUnderlineSgr(writer); } fn writeSgrClose(writer: *Writer, style: Style) Writer.Error!void { if (!style.hasAnyStyling()) return; try writer.writeAll("\x1b[0m"); } /// Opens the SGR sequence for a run of spaces under the space styler. Spaces /// receive the colors plus underline/strikethrough only when requested — not /// bold/italic/etc. Returns whether anything was written. fn writeSpaceSgrOpen(writer: *Writer, style: Style, ul_spaces: bool, st_spaces: bool) Writer.Error!bool { var any = false; if (ul_spaces) { const kind: Underline.Style = if (style.internal.underline.style == .none) .single else style.internal.underline.style; try writeUnderlineCode(writer, kind); any = true; } if (st_spaces) { try writer.writeAll("\x1b[9m"); any = true; } if (style.internal.foreground) |color| { try color.writeSgr(writer, .fg); any = true; } if (style.internal.background) |color| { try color.writeSgr(writer, .bg); any = true; } if (style.internal.underline.color) |color| { try color.writeUnderlineSgr(writer); any = true; } return any; } /// Opens the SGR sequence for whitespace (padding, alignment fill). Only /// `reverse` and the background color (when `color_whitespace` is set) bleed /// onto whitespace — text attributes like bold/underline do not. Returns /// whether anything was written, so the caller can pair it with a reset. fn writeWhitespaceOpen(writer: *Writer, style: Style) Writer.Error!bool { var any = false; if (style.internal.reverse) { try writer.writeAll("\x1b[7m"); if (style.internal.foreground) |color| try color.writeSgr(writer, .fg); any = true; } if (style.internal.color_whitespace) { if (style.internal.background) |color| { try color.writeSgr(writer, .bg); any = true; } } return any; } fn writeWhitespaceClose(writer: *Writer, any: bool) Writer.Error!void { if (any) try writer.writeAll("\x1b[0m"); } /// returns style if it has any styling, else null; pub fn maybeStyle(style: Style) ?Style { return if (style.hasAnyStyling()) style else null; } pub fn hasAnyStyling(style: Style) bool { return style.internal.bold or style.internal.italic or style.internal.underline.style != .none or style.internal.strikethrough.enabled or style.internal.reverse or style.internal.blink or style.internal.faint or style.internal.foreground != null or style.internal.background != null or style.internal.underline.color != null; } pub fn isNone(style: Style) bool { return !style.hasAnyStyling(); } fn skipEscape(s: []const u8, index: usize) usize { if (index + 1 >= s.len) return s.len; switch (s[index + 1]) { '[' => { var j = index + 2; while (j < s.len and (s[j] < 0x40 or s[j] > 0x7e)) : (j += 1) {} return if (j < s.len) j + 1 else s.len; }, ']' => { var j = index + 2; while (j < s.len) : (j += 1) { if (s[j] == 0x07) return j + 1; if (s[j] == 0x1b and j + 1 < s.len and s[j + 1] == '\\') return j + 2; } return s.len; }, else => return index + 2, } } fn utf8Len(s: []const u8, i: usize) usize { return std.unicode.utf8ByteSequenceLength(s[i]) catch 1; } /// Visible cell width of `line` with tabs expanded per `tw`, matching the width /// `wrapScanW` uses to size `content_w`. Without this, `writeContentRowW` would /// undercount tab runs and pad the row out too far. fn visibleWidthTab(line: []const u8, tw: ?u8) u16 { var w: u16 = 0; var run_start: usize = 0; var i: usize = 0; while (i < line.len) : (i += 1) { if (line[i] != '\t') continue; w = util.satAdd(w, measure.cellWidth(line[run_start..i])); if (tw == null) { w = util.satAdd(w, 1); } else if (tw.? > 0) { w = util.satAdd(w, tw.?); } run_start = i + 1; } return util.satAdd(w, measure.cellWidth(line[run_start..])); } /// A zero-buffer `Writer` adapter that streams `render`'s output through the /// `max_width` and `max_height` caps, forwarding filtered bytes to `out`. /// `max_width` truncates each line to N visible cells (ANSI-aware: escape /// sequences pass through and are not counted, so trailing resets/links /// survive). `max_height` keeps at most N lines (mirrors lipgloss truncating /// the final string). A cap of `maxInt(u16)` means "no limit". const FilterWriter = struct { out: *Writer, max_w: u16, max_h: u16, visible: u16 = 0, lines: u16 = 0, height_done: bool = false, esc: Esc = .normal, utf8_fwd: u3 = 0, // continuation bytes left to forward for the current cp utf8_drop: u3 = 0, // continuation bytes left to drop for the current cp interface: Writer = .{ .buffer = &.{}, .vtable = &.{ .drain = drain, }, }, const Esc = enum { normal, start, csi, osc, osc_esc }; fn init(writer: *Writer, max_w: u16, max_h: u16) FilterWriter { return .{ .out = writer, .max_w = max_w, .max_h = max_h, .height_done = max_h == 0, }; } fn drain(writer: *Writer, data: []const []const u8, splat: usize) Writer.Error!usize { const self: *FilterWriter = @fieldParentPtr("interface", writer); for (data[0 .. data.len - 1]) |slice| { for (slice) |b| try self.byte(b); } const last = data[data.len - 1]; var r: usize = 0; while (r < splat) : (r += 1) { for (last) |b| try self.byte(b); } return Writer.countSplat(data, splat); } fn byte(self: *FilterWriter, b: u8) Writer.Error!void { if (self.height_done) return; // Continuation bytes belong to a codepoint already decided on. if (self.utf8_fwd > 0) { self.utf8_fwd -= 1; return self.out.writeByte(b); } if (self.utf8_drop > 0) { self.utf8_drop -= 1; return; } // Pass ANSI escape sequences through untouched and uncounted. switch (self.esc) { .start => { self.esc = switch (b) { '[' => .csi, ']' => .osc, else => .normal, }; return self.out.writeByte(b); }, .csi => { if (b >= 0x40 and b <= 0x7e) self.esc = .normal; return self.out.writeByte(b); }, .osc => { if (b == 0x07) self.esc = .normal else if (b == 0x1b) self.esc = .osc_esc; return self.out.writeByte(b); }, .osc_esc => { self.esc = .normal; return self.out.writeByte(b); }, .normal => {}, } if (b == 0x1b) { self.esc = .start; return self.out.writeByte(b); } if (b == '\n') { // Keep at most max_h lines: drop the newline that would open line // max_h + 1, and everything after it. if (self.max_h != std.math.maxInt(u16) and self.lines + 1 >= self.max_h) { self.height_done = true; return; } self.lines += 1; self.visible = 0; return self.out.writeByte(b); } if (self.visible >= self.max_w) { // Past the cut: drop this codepoint (and its continuation bytes). const len = std.unicode.utf8ByteSequenceLength(b) catch 1; self.utf8_drop = @intCast(len - 1); return; } // Control bytes forward without counting (matches lipgloss truncate). if (b < 0x20 or b == 0x7f) return self.out.writeByte(b); const len = std.unicode.utf8ByteSequenceLength(b) catch 1; self.visible += 1; self.utf8_fwd = @intCast(len - 1); return self.out.writeByte(b); } }; /// Like `writeBorderLine` but without the trailing `\n`. fn writeBorderLineW( writer: *Writer, style: Style, marg_l: u16, marg_r: u16, content_w: u16, bl: bool, br: bool, row: BorderRow, ) Writer.Error!void { const b = style.internal.border; const glyphs = switch (row) { .top => .{ b.style.top_left, b.style.top, b.style.top_right }, .bottom => .{ b.style.bottom_left, b.style.bottom, b.style.bottom_right }, }; const fgs = switch (row) { .top => .{ b.foreground_colors.top_left, b.foreground_colors.top, b.foreground_colors.top_right }, .bottom => .{ b.foreground_colors.bottom_left, b.foreground_colors.bottom, b.foreground_colors.bottom_right }, }; const bgs = switch (row) { .top => .{ b.background_colors.top_left, b.background_colors.top, b.background_colors.top_right }, .bottom => .{ b.background_colors.bottom_left, b.background_colors.bottom, b.background_colors.bottom_right }, }; try writeMargin(writer, style, marg_l); var run: BorderRun = .{ .writer = writer }; if (bl) try run.glyph(glyphs[0], fgs[0], bgs[0], 1); try run.glyph(glyphs[1], fgs[1], bgs[1], content_w); if (br) try run.glyph(glyphs[2], fgs[2], bgs[2], 1); try run.close(); try writeMargin(writer, style, marg_r); } /// Like `writeFillRow` but without the trailing `\n`. fn writeFillRowW( writer: *Writer, style: Style, marg_l: u16, marg_r: u16, content_w: u16, bl: bool, br: bool, ) Writer.Error!void { try writeMargin(writer, style, marg_l); const b = style.internal.border; var run: BorderRun = .{ .writer = writer }; if (bl) { try run.glyph(b.style.left, b.foreground_colors.left, b.background_colors.left, 1); // Close before the content so the border's colors don't bleed onto it; // the right border reopens its own group below. try run.close(); } const ws = try writeWhitespaceOpen(writer, style); try writeRepeatCp(writer, ' ', content_w); try writeWhitespaceClose(writer, ws); if (br) try run.glyph(b.style.right, b.foreground_colors.right, b.background_colors.right, 1); try run.close(); try writeMargin(writer, style, marg_r); } /// Like `writeContentRow` but without the trailing `\n` and with tab expansion. fn writeContentRowW( writer: *Writer, style: Style, marg_l: u16, marg_r: u16, content_w: u16, bl: bool, br: bool, line: []const u8, ) Writer.Error!void { const pad_l = style.internal.padding.spacing.left; const pad_r = style.internal.padding.spacing.right; const inner_w = content_w -| pad_l -| pad_r; // Tab-aware so it matches the width wrapScanW used to compute content_w. const visible = visibleWidthTab(line, style.internal.tabs); const fill = inner_w -| visible; const left_fill: u16 = switch (style.internal.align_horizontal) { .right => fill, .center => fill / 2, .left => 0, }; const right_fill = fill - left_fill; try writeMargin(writer, style, marg_l); const b = style.internal.border; var run: BorderRun = .{ .writer = writer }; if (bl) { try run.glyph(b.style.left, b.foreground_colors.left, b.background_colors.left, 1); // Close before the content so the border's colors don't bleed onto it; // the right border reopens its own group below. try run.close(); } { const ws = try writeWhitespaceOpen(writer, style); try writeRepeatCp(writer, style.internal.padding.char, pad_l); try writeRepeatCp(writer, ' ', left_fill); try writeWhitespaceClose(writer, ws); } const has_link = style.internal.link != null and line.len > 0; if (has_link) try writeOsc8Open(writer, style.internal.link.?, style.internal.link_params); try writeStyledTextW(writer, style, line, inner_w); if (has_link) try writeOsc8Close(writer); { const ws = try writeWhitespaceOpen(writer, style); try writeRepeatCp(writer, ' ', right_fill); try writeRepeatCp(writer, style.internal.padding.char, pad_r); try writeWhitespaceClose(writer, ws); } if (br) try run.glyph(b.style.right, b.foreground_colors.right, b.background_colors.right, 1); try run.close(); try writeMargin(writer, style, marg_r); } /// Like `writeStyledText` but uses `writeTruncatedW` for tab expansion. fn writeStyledTextW(writer: *Writer, style: Style, line: []const u8, max_w: u16) Writer.Error!void { const underline_on = style.internal.underline.style != .none; const strike_on = style.internal.strikethrough.enabled; const ul_spaces = style.internal.underline.underline_spaces; const st_spaces = style.internal.strikethrough.strikethrough_spaces; const spaces_differ = (ul_spaces != underline_on) or (st_spaces != strike_on); if (!spaces_differ) { try writeSgrOpen(writer, style); try writeTruncatedW(writer, line, max_w, style.internal.tabs); try writeSgrClose(writer, style); return; } // Space-differing path: expand tabs as spaces inline. var visible: u16 = 0; var i: usize = 0; while (i < line.len and visible < max_w) { if (line[i] == '\t') { i += 1; const any = try writeSpaceSgrOpen(writer, style, ul_spaces, st_spaces); if (style.internal.tabs != null and style.internal.tabs.? > 0) { const n: u16 = style.internal.tabs.?; var k: u16 = 0; while (k < n and visible < max_w) : (k += 1) { try writer.writeByte(' '); visible += 1; } } else if (style.internal.tabs == null) { if (visible < max_w) { try writer.writeByte('\t'); visible += 1; } } if (any) try writer.writeAll("\x1b[0m"); continue; } if (line[i] == ' ') { const any = try writeSpaceSgrOpen(writer, style, ul_spaces, st_spaces); while (i < line.len and visible < max_w and line[i] == ' ') { try writer.writeByte(' '); visible += 1; i += 1; } if (any) try writer.writeAll("\x1b[0m"); } else { try writeSgrOpen(writer, style); while (i < line.len and visible < max_w and line[i] != ' ' and line[i] != '\t') { if (line[i] == 0x1b) { const e = skipEscape(line, i); try writer.writeAll(line[i..e]); i = e; continue; } const b = line[i]; if (b < 0x20 or b == 0x7f) { try writer.writeByte(b); i += 1; continue; } const l = utf8Len(line, i); try writer.writeAll(line[i .. i + l]); visible += 1; i += l; } try writeSgrClose(writer, style); } } } /// Like `writeTruncated` but expands tabs according to `tw`. fn writeTruncatedW(writer: *Writer, text: []const u8, max_w: u16, tw: ?u8) Writer.Error!void { if (max_w == 0) return; var visible: u16 = 0; var i: usize = 0; while (i < text.len) { if (text[i] == '\t') { i += 1; if (tw == null) { if (visible >= max_w) { try drainEscapes(writer, text, &i); return; } try writer.writeByte('\t'); visible += 1; } else if (tw.? > 0) { const n: u16 = tw.?; var k: u16 = 0; while (k < n and visible < max_w) : (k += 1) { try writer.writeByte(' '); visible += 1; } } continue; } if (text[i] == 0x1b) { const e = skipEscape(text, i); try writer.writeAll(text[i..e]); i = e; continue; } const b = text[i]; if (b < 0x20 or b == 0x7f) { try writer.writeByte(b); i += 1; continue; } if (visible >= max_w) { // Width exhausted: stop emitting glyphs, but keep writing // zero-width escape sequences (SGR resets) so styles don't leak // into whatever the caller emits next. try drainEscapes(writer, text, &i); return; } const l = utf8Len(text, i); try writer.writeAll(text[i .. i + l]); visible += 1; i += l; } } /// Writes any remaining zero-width escape sequences from `text` starting at /// `i`, advancing `i` past printable content without emitting it. fn drainEscapes(writer: *Writer, text: []const u8, i: *usize) Writer.Error!void { while (i.* < text.len) { if (text[i.*] == 0x1b) { const e = skipEscape(text, i.*); try writer.writeAll(text[i.*..e]); i.* = e; continue; } i.* += utf8Len(text, i.*); } } /// Scan `text` without allocating to determine the number of logical lines and /// the maximum visible line width produced after tab expansion and word wrap. fn wrapScanW(text: []const u8, tw: ?u8, wrap_at: u16) struct { line_count: u16, max_line_w: u16 } { if (wrap_at == 0) { // No wrap: count lines and max width with tab awareness. var max_w: u16 = 0; var line_w: u16 = 0; var line_count: u16 = 1; var i: usize = 0; while (i < text.len) { const c = text[i]; i += 1; if (c == '\r') continue; if (c == '\n') { if (line_w > max_w) max_w = line_w; line_w = 0; line_count = util.satAdd(line_count, 1); continue; } if (c == '\t') { if (tw == null) { line_w = util.satAdd(line_w, 1); } else if (tw.? > 0) { line_w = util.satAdd(line_w, tw.?); } continue; } if (c == 0x1b) { i -= 1; i = skipEscape(text, i); continue; } if (c >= 0x20 and c != 0x7f) { i -= 1; const l = utf8Len(text, i); i += l; line_w = util.satAdd(line_w, 1); } } if (line_w > max_w) max_w = line_w; return .{ .line_count = line_count, .max_line_w = max_w }; } // With wrap: simulate WrapLineIterW to count resulting lines. var iter: WrapLineIterW = .init(text, tw, wrap_at); var line_count: u16 = 0; while (iter.next()) |_| line_count = util.satAdd(line_count, 1); return .{ .line_count = line_count, .max_line_w = wrap_at }; } /// Streaming word-wrap iterator that returns slices of the original `text` /// with no heap allocation. Mirrors the logic of `wrap.wrap` but yields /// `[]const u8` segments instead of building a new string. const WrapLineIterW = struct { text: []const u8, tw: ?u8, wrap_at: u16, pos: usize = 0, seg_start: usize = 0, // byte start of the current line segment to return sp_start: usize = 0, // byte start of the current run of pending spaces pending_sp: u16 = 0, // width of accumulated whitespace not yet committed line_w: u16 = 0, // visible width committed to the current line at_start: bool = true, // true at the beginning of each line // Hard-break continuation state (when a word is wider than wrap_at). hb_pos: usize = 0, hb_end: usize = 0, hb_active: bool = false, done: bool = false, pub fn init(text: []const u8, tw: ?u8, wrap_at: u16) WrapLineIterW { return .{ .text = text, .tw = tw, .wrap_at = wrap_at }; } pub fn next(self: *WrapLineIterW) ?[]const u8 { if (self.done) return null; if (self.hb_active) return self.continueHardBreak(); return self.scanLine(); } fn tabWidth(self: WrapLineIterW) u16 { if (self.tw == null) return 1; if (self.tw.? == 0) return 0; return self.tw.?; } fn scanLine(self: *WrapLineIterW) ?[]const u8 { const text = self.text; while (self.pos < text.len) { const c = text[self.pos]; if (c == '\r') { self.pos += 1; continue; } if (c == '\n') { // Hard newline: yield up to where pending spaces started (trailing // spaces are dropped, matching wrap.zig behaviour). const end = if (self.pending_sp > 0) self.sp_start else self.pos; const seg = text[self.seg_start..end]; self.pos += 1; self.seg_start = self.pos; self.sp_start = self.pos; self.pending_sp = 0; self.line_w = 0; self.at_start = true; return seg; } // Whitespace (spaces and tabs) → accumulate as pending. if (c == ' ' or c == '\t') { if (self.pending_sp == 0) self.sp_start = self.pos; const w: u16 = if (c == ' ') 1 else self.tabWidth(); self.pending_sp +|= w; self.pos += 1; continue; } // ANSI escape: zero width, skip. Flush any pending whitespace first // so a trailing "space + SGR reset" isn't trimmed as trailing // whitespace (which would drop the reset and leak the style). if (c == 0x1b) { if (self.pending_sp > 0) { self.line_w +|= self.pending_sp; self.pending_sp = 0; self.at_start = false; } self.pos = skipEscape(text, self.pos); continue; } // Word: collect a run of non-whitespace, non-newline bytes. const word_start = self.pos; while (self.pos < text.len) { const wc = text[self.pos]; if (wc == ' ' or wc == '\t' or wc == '\n' or wc == '\r') break; if (wc == 0x1b) { self.pos = skipEscape(text, self.pos); continue; } self.pos += utf8Len(text, self.pos); } const word_end = self.pos; const word_w = measure.cellWidth(text[word_start..word_end]); if (self.wrap_at == 0) { // Wrapping disabled: just accumulate width and continue. self.line_w +|= self.pending_sp +| word_w; self.pending_sp = 0; self.at_start = false; continue; } if (self.at_start) { // Keep pending spaces as indentation (wrap.zig semantics). if (self.pending_sp +| word_w <= self.wrap_at) { self.line_w = self.pending_sp +| word_w; self.pending_sp = 0; self.at_start = false; } else if (word_w <= self.wrap_at) { // Indent overshoots with word — drop excess indent, keep word. self.seg_start = word_start; self.sp_start = word_start; self.pending_sp = 0; self.line_w = word_w; self.at_start = false; } else { // Word itself exceeds wrap_at: hard break, emit empty first. const seg = text[self.seg_start..self.sp_start]; self.seg_start = word_start; self.sp_start = word_start; self.pending_sp = 0; self.hb_pos = word_start; self.hb_end = word_end; self.hb_active = true; return seg; } } else { const total: u32 = @as(u32, self.line_w) + self.pending_sp + word_w; if (total <= self.wrap_at) { self.line_w +|= self.pending_sp +| word_w; self.pending_sp = 0; self.at_start = false; } else if (word_w <= self.wrap_at) { // Soft wrap: yield current line, start next at word_start. const seg = text[self.seg_start..self.sp_start]; self.seg_start = word_start; self.sp_start = word_start; self.line_w = word_w; self.pending_sp = 0; self.at_start = false; return seg; } else { // Soft wrap then hard break the oversized word. const seg = text[self.seg_start..self.sp_start]; self.seg_start = word_start; self.sp_start = word_start; self.pending_sp = 0; self.line_w = 0; self.at_start = true; self.hb_pos = word_start; self.hb_end = word_end; self.hb_active = true; return seg; } } } // End of input: yield whatever remains. self.done = true; const end = if (self.pending_sp > 0) self.sp_start else text.len; return text[self.seg_start..end]; } /// Continue hard-breaking a word that exceeds `wrap_at`, one chunk per call. fn continueHardBreak(self: *WrapLineIterW) ?[]const u8 { const text = self.text; if (self.hb_pos >= self.hb_end) { self.hb_active = false; // Resume normal scanning after the hard-broken word. self.seg_start = self.hb_end; self.sp_start = self.hb_end; self.line_w = 0; self.at_start = true; return self.scanLine(); } // Emit up to wrap_at visible cells from hb_pos. var i = self.hb_pos; var w: u16 = 0; while (i < self.hb_end and w < self.wrap_at) { if (text[i] == 0x1b) { i = skipEscape(text, i); continue; } i += utf8Len(text, i); w += 1; } const seg = text[self.hb_pos..i]; self.hb_pos = i; if (self.hb_pos >= self.hb_end) { self.hb_active = false; self.seg_start = self.hb_end; self.sp_start = self.hb_end; self.line_w = w; self.at_start = false; } return seg; } }; // --- tests --- const testing = std.testing; fn renderAllocTest(style: Style, text: []const u8) ![]u8 { return style.renderAlloc(testing.allocator, text); } fn expectRender(expected: []const u8, style: Style, text: []const u8) !void { const got = try renderAllocTest(style, text); defer testing.allocator.free(got); try testing.expectEqualStrings(expected, got); } test "render plain text" { try expectRender("hello", new(.{}), "hello"); } test "render bold + fg" { try expectRender("\x1b[1m\x1b[31mx\x1b[0m", new(.{}).fg(.red).bold(true), "x"); } test "render fixed width pads right" { try expectRender("ab ", new(.{}).cols(5), "ab"); } test "render padding all = 1" { // content_w = visible(1) + pad_l(1) + pad_r(1) = 3. try expectRender(" \n x \n ", new(.{}).pad(.all, 1), "x"); } test "builder pad" { const style = new(.{}).pad(.all, 2); try testing.expectEqual(@as(u16, 2), style.internal.padding.spacing.top); try testing.expectEqual(@as(u16, 2), style.internal.padding.spacing.right); try testing.expectEqual(@as(u16, 2), style.internal.padding.spacing.bottom); try testing.expectEqual(@as(u16, 2), style.internal.padding.spacing.left); } test "render margin all = 1" { // total_w = marg_l(1) + content_w(1) + marg_r(1) = 3. try expectRender(" \n x \n ", new(.{}).margin(.all, 1), "x"); } test "render border normal" { try expectRender("┌─┐\n│x│\n└─┘", new(.{}).box(.normal), "x"); } test "render border top only" { const style = new(.{}).box(.set(.normal, .{ .edges = .{ .right = false, .bottom = false, .left = false } })); try expectRender("─\nx", style, "x"); } test "render border with foreground color" { try expectRender( "\x1b[31m┌─┐\x1b[0m\n\x1b[31m│\x1b[0mx\x1b[31m│\x1b[0m\n\x1b[31m└─┘\x1b[0m", new(.{}).box(.set(.normal, .{ .foreground = .all(.red) })), "x", ); } test "render border with per-segment foreground color" { // Only the top edge is red; the corners and other edges stay uncolored. try expectRender( "┌\x1b[31m─\x1b[0m┐\n│x│\n└─┘", new(.{}).box(.set(.normal, .{ .foreground = .groups(&.{.group(&.{.top}, .red)}) })), "x", ); } test "render border splits SGR only where segment colors differ" { // Uniform red via `.all` is one SGR group per row (above); two // differently-colored edges split into separate groups. try expectRender( "┌\x1b[31m─\x1b[0m┐\n\x1b[34m│\x1b[0mx│\n└─┘", new(.{}) .box(.set(.normal, .{ .foreground = .groups(&.{ .group(&.{.top}, .red), .group(&.{.left}, .blue) }) })), "x", ); } test "border colors do not bleed onto content" { // The border's SGR group must close around the border glyphs rather than // stay open across the styled text in between. Text attributes like bold // apply to the content only — a border segment carries just fg/bg. try expectRender( "\x1b[31m┌─┐\x1b[0m\n\x1b[31m│\x1b[0m\x1b[1m\x1b[31mx\x1b[0m\x1b[31m│\x1b[0m\n\x1b[31m└─┘\x1b[0m", new(.{}) .fg(.red) .bold(true) .box(.set(.normal, .{ .foreground = .all(.red) })), "x", ); } test "render underline single" { try expectRender("\x1b[4mx\x1b[0m", new(.{}).ul(.single), "x"); } test "render underline curly" { try expectRender("\x1b[4:3mx\x1b[0m", new(.{}).ul(.curly), "x"); } test "render hyperlink" { try expectRender( "\x1b]8;;http://x\x1b\\y\x1b]8;;\x1b\\", new(.{}).hyperlink("http://x"), "y", ); } test "render inline mode strips newlines and frame" { const style = new(.{}).inlineMode(true).pad(.all, 4).bold(true); try expectRender("\x1b[1mab\x1b[0m", style, "a\nb"); } test "render padding char" { try expectRender(".x.", new(.{}).pad(.horizontal, 1).paddingChar('.'), "x"); } test "render word wrap" { try expectRender("hello\nworld", new(.{}).cols(5), "hello world"); } test "render tab expansion" { try expectRender("a b", new(.{}).tabWidth(2), "a\tb"); } test "render tab removal" { try expectRender("ab", new(.{}).tabWidth(0), "a\tb"); } test "render max width truncates" { try expectRender("abc", new(.{}).maxWidth(3), "abcdefg"); } test "render max height truncates" { try expectRender("a\nb", new(.{}).maxHeight(2), "a\nb\nc\nd"); } test "render underline spaces disabled" { // Underline on, but spaces opt out: text runs underlined, space run not. try expectRender( "\x1b[4ma\x1b[0m \x1b[4mb\x1b[0m", new(.{}).ul(.set(.single, null)), "a b", ); } test "render align horizontal center" { try expectRender(" x ", new(.{}).cols(5).alignH(.center), "x"); } test "render align horizontal right" { try expectRender(" x", new(.{}).cols(5).alignH(.right), "x"); } test "render align vertical bottom" { try expectRender(" \n \nx", new(.{}).rows(3).alignV(.bottom), "x"); } fn expectRenderComptime(comptime style: Style, comptime text: []const u8) !void { // `got` is produced entirely in the comptime interpreter; compare it to the // runtime renderer's output for the same style and text. const got = comptime style.renderComptime(text); const want = try renderAllocTest(style, text); defer t.allocator.free(want); try t.expectEqualStrings(want, got); } test "renderComptime matches render" { try expectRenderComptime(new(.{}), "hello"); try expectRenderComptime(new(.{}).fg(.red).bold(true), "x"); try expectRenderComptime(new(.{}).box(.set(.normal, .{ .foreground = .all(.red) })), "x"); try expectRenderComptime(new(.{}).cols(5).alignH(.center), "x"); try expectRenderComptime(new(.{}).pad(.all, 1), "x"); try expectRenderComptime(new(.{}).cols(5), "hello world"); try expectRenderComptime(new(.{}).maxWidth(3), "abcdefg"); try expectRenderComptime(new(.{}).rows(3).alignV(.bottom), "x"); try expectRenderComptime(new(.{}).ul(.set(.single, null)), "a b"); try expectRenderComptime(new(.{}).tabWidth(2), "a\tb"); }