const std = @import("std"); const measure = @import("measure.zig"); const util = @import("util.zig"); const Color = @import("color.zig").Color; pub const Border = @import("Border.zig"); const position = @import("position.zig"); const Io = std.Io; const File = Io.File; const Allocator = std.mem.Allocator; const Writer = std.Io.Writer; const Style = @This(); /// 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. pub 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`. pub 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. pub const VAlign = enum { top, center, bottom }; // --- stored properties --- // Optional fields use `null` to mean "unset"; see [[project-v2-target]]. bold: bool = false, italic: bool = false, underline: Underline = .none, strikethrough: bool = false, reverse: bool = false, blink: bool = false, faint: bool = false, foreground: ?Color = null, background: ?Color = null, underline_color: ?Color = null, width: ?u16 = null, height: ?u16 = null, max_width: ?u16 = null, max_height: ?u16 = null, /// Tab stop width. null disables conversion, 0 strips tabs, N expands to N /// spaces. Mirrors lipgloss `TabWidth` (default 4). tab_width: ?u8 = 4, padding: Spacing = .{}, padding_char: u21 = ' ', margin: Spacing = .{}, margin_char: u21 = ' ', margin_background: ?Color = null, border: ?Border = null, border_top: bool = true, border_right: bool = true, border_bottom: bool = true, border_left: bool = true, border_top_fg: ?Color = null, border_right_fg: ?Color = null, border_bottom_fg: ?Color = null, border_left_fg: ?Color = null, border_top_bg: ?Color = null, border_right_bg: ?Color = null, border_bottom_bg: ?Color = null, border_left_bg: ?Color = null, align_horizontal: HAlign = .left, align_vertical: VAlign = .top, color_whitespace: bool = true, inline_mode: bool = false, /// Whether spaces receive the underline. `null` follows the underline state /// (lipgloss `UnderlineSpaces`, which defaults on when underline is on). underline_spaces: ?bool = null, /// Whether spaces receive the strikethrough. `null` follows the strikethrough /// state (lipgloss `StrikethroughSpaces`). strikethrough_spaces: ?bool = null, link: ?[]const u8 = null, link_params: ?[]const u8 = null, pub const none: Style = .{}; /// Underline style. Mirrors lipgloss `Underline` (ansi.Underline). Not all /// terminals support every variant; unsupported styles typically fall back /// to a single underline. const Underline = enum { none, single, double, curly, dotted, dashed }; const Spacing = struct { top: u16 = 0, right: u16 = 0, bottom: u16 = 0, left: u16 = 0, }; const AttrName = enum { bold, italic, strikethrough, reverse, blink, faint, }; const Side = enum { top, right, bottom, left, horizontal, vertical, all, }; const Sides = struct { top: bool, right: bool, bottom: bool, left: bool, }; pub fn new() Style { return .none; } fn init(style: Style) Style { return style; } /// Sets the foreground color (lipgloss `Foreground`). pub fn fg(style: Style, color: Color) Style { var out = style; out.foreground = color; return out; } /// Sets the background color (lipgloss `Background`). pub fn bg(style: Style, color: Color) Style { var out = style; out.background = color; return out; } /// Sets the underline color (lipgloss `UnderlineColor`). pub fn underlineColor(style: Style, color: Color) Style { var out = style; out.underline_color = color; return out; } /// Toggles a boolean text attribute (lipgloss `Bold`/`Italic`/etc.). pub fn attr(style: Style, name: AttrName, value: bool) Style { var out = style; switch (name) { .bold => out.bold = value, .italic => out.italic = value, .strikethrough => out.strikethrough = value, .reverse => out.reverse = value, .blink => out.blink = value, .faint => out.faint = value, } return out; } /// Sets the underline style (lipgloss `UnderlineStyle`). Pass `.single` for a /// plain underline; `.none` disables it. pub fn ul(style: Style, kind: Underline) Style { var out = style; out.underline = kind; return out; } /// Controls whether spaces receive the underline (lipgloss `UnderlineSpaces`). pub fn underlineSpaces(style: Style, on: bool) Style { var out = style; out.underline_spaces = on; return out; } /// Controls whether spaces receive the strikethrough /// (lipgloss `StrikethroughSpaces`). pub fn strikethroughSpaces(style: Style, on: bool) Style { var out = style; out.strikethrough_spaces = on; return out; } /// Sets padding on the given side(s) (lipgloss `Padding*`). pub fn pad(style: Style, side: Side, cells: u16) Style { var out = style; applySpacing(&out.padding, side, cells); return out; } /// Sets the padding fill character (lipgloss `PaddingChar`). pub fn paddingChar(style: Style, char: u21) Style { var out = style; out.padding_char = char; return out; } /// Sets margin on the given side(s) (lipgloss `Margin*`). pub fn mar(style: Style, side: Side, cells: u16) Style { var out = style; applySpacing(&out.margin, side, cells); return out; } /// Sets the margin fill character (lipgloss `MarginChar`). pub fn marginChar(style: Style, char: u21) Style { var out = style; out.margin_char = char; return out; } /// Sets the margin background color (lipgloss `MarginBackground`). pub fn marginBackground(style: Style, color: Color) Style { var out = style; out.margin_background = color; return out; } /// Sets the border style (lipgloss `BorderStyle`). All four sides are drawn /// unless individually disabled with `borderSide`. pub fn box(style: Style, border: Border) Style { var out = style; out.border = border; return out; } /// Enables or disables drawing the border on the given side(s) /// (lipgloss `BorderTop`/`BorderRight`/etc.). pub fn borderSide(style: Style, side: Side, on: bool) Style { var out = style; switch (side) { .top => out.border_top = on, .right => out.border_right = on, .bottom => out.border_bottom = on, .left => out.border_left = on, .horizontal => { out.border_left = on; out.border_right = on; }, .vertical => { out.border_top = on; out.border_bottom = on; }, .all => { out.border_top = on; out.border_right = on; out.border_bottom = on; out.border_left = on; }, } return out; } /// Sets the border foreground color on the given side(s) /// (lipgloss `BorderForeground`/`BorderTopForeground`/etc.). pub fn borderFg(style: Style, side: Side, color: Color) Style { var out = style; switch (side) { .top => out.border_top_fg = color, .right => out.border_right_fg = color, .bottom => out.border_bottom_fg = color, .left => out.border_left_fg = color, .horizontal => { out.border_left_fg = color; out.border_right_fg = color; }, .vertical => { out.border_top_fg = color; out.border_bottom_fg = color; }, .all => { out.border_top_fg = color; out.border_right_fg = color; out.border_bottom_fg = color; out.border_left_fg = color; }, } return out; } /// Sets the border background color on the given side(s) /// (lipgloss `BorderBackground`/`BorderTopBackground`/etc.). pub fn borderBg(style: Style, side: Side, color: Color) Style { var out = style; switch (side) { .top => out.border_top_bg = color, .right => out.border_right_bg = color, .bottom => out.border_bottom_bg = color, .left => out.border_left_bg = color, .horizontal => { out.border_left_bg = color; out.border_right_bg = color; }, .vertical => { out.border_top_bg = color; out.border_bottom_bg = color; }, .all => { out.border_top_bg = color; out.border_right_bg = color; out.border_bottom_bg = color; out.border_left_bg = color; }, } return out; } /// Sets a fixed block width, including borders (lipgloss `Width`). pub fn cols(style: Style, cells: u16) Style { var out = style; out.width = cells; return out; } /// Sets a fixed block height, including borders (lipgloss `Height`). pub fn rows(style: Style, lines: u16) Style { var out = style; out.height = lines; return out; } /// Caps the rendered width, truncating each line (lipgloss `MaxWidth`). pub fn maxWidth(style: Style, cells: u16) Style { var out = style; out.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.max_height = lines; return out; } /// Sets the tab stop width (lipgloss `TabWidth`). `null` disables tab /// conversion. pub fn tabWidth(style: Style, cells: ?u8) Style { var out = style; out.tab_width = cells; return out; } /// Sets the horizontal alignment (lipgloss `AlignHorizontal`). pub fn alignH(style: Style, a: HAlign) Style { var out = style; out.align_horizontal = a; return out; } /// Sets the vertical alignment (lipgloss `AlignVertical`). pub fn alignV(style: Style, a: VAlign) Style { var out = style; out.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, on: bool) Style { var out = style; out.color_whitespace = on; return out; } /// Renders on a single line, disabling padding, borders, and margins /// (lipgloss `Inline`). pub fn inlineMode(style: Style, on: bool) Style { var out = style; out.inline_mode = on; return out; } /// Sets a hyperlink on the rendered text (lipgloss `Hyperlink`). pub fn hyperlink(style: Style, url: []const u8) Style { var out = style; out.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.link = url; out.link_params = params; return out; } fn applySpacing(spacing: *Spacing, side: Side, cells: u16) void { switch (side) { .top => spacing.top = cells, .right => spacing.right = cells, .bottom => spacing.bottom = cells, .left => spacing.left = cells, .horizontal => { spacing.left = cells; spacing.right = cells; }, .vertical => { spacing.top = cells; spacing.bottom = cells; }, .all => { spacing.top = cells; spacing.right = cells; spacing.bottom = cells; spacing.left = cells; }, } } fn borderSides(style: Style) Sides { const has = style.border != null; return .{ .top = has and style.border_top, .right = has and style.border_right, .bottom = has and style.border_bottom, .left = has and style.border_left, }; } /// 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.max_width == null and style.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.max_width orelse std.math.maxInt(u16), style.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.inline_mode) { const has_link = style.link != null and text.len > 0; if (has_link) try writeOsc8Open(writer, style.link.?, style.link_params); try writeSgrOpen(writer, style); const max_w = style.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.tab_width == null) { if (w < max_w) { try writer.writeByte('\t'); w += 1; } } else if (style.tab_width.? > 0) { const n: u16 = style.tab_width.?; 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 = borderSides(style); 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.padding.left; const pad_r = style.padding.right; const pad_t = style.padding.top; const pad_b = style.padding.bottom; const marg_l = style.margin.left; const marg_r = style.margin.right; const wrap_at: u16 = if (style.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.tab_width, wrap_at); const body_lines = scan.line_count; const content_w: u16 = if (style.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.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.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.margin.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.tab_width, 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.margin.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.margin_background) |c| { try c.writeSgr(writer, .bg); try writeRepeatCp(writer, style.margin_char, count); try writer.writeAll("\x1b[0m"); } else { try writeRepeatCp(writer, style.margin_char, count); } } fn writeBorderPiece(writer: *Writer, piece: []const u8, fg_color: ?Color, bg_color: ?Color) Writer.Error!void { const styled = fg_color != null or bg_color != null; if (fg_color) |c| try c.writeSgr(writer, .fg); if (bg_color) |c| try c.writeSgr(writer, .bg); try writer.writeAll(piece); if (styled) try writer.writeAll("\x1b[0m"); } 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) 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.bold) try writer.writeAll("\x1b[1m"); if (style.faint) try writer.writeAll("\x1b[2m"); if (style.italic) try writer.writeAll("\x1b[3m"); try writeUnderlineCode(writer, style.underline); if (style.blink) try writer.writeAll("\x1b[5m"); if (style.reverse) try writer.writeAll("\x1b[7m"); if (style.strikethrough) try writer.writeAll("\x1b[9m"); if (style.foreground) |color| try color.writeSgr(writer, .fg); if (style.background) |color| try color.writeSgr(writer, .bg); if (style.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 = if (style.underline == .none) .single else style.underline; try writeUnderlineCode(writer, kind); any = true; } if (st_spaces) { try writer.writeAll("\x1b[9m"); any = true; } if (style.foreground) |color| { try color.writeSgr(writer, .fg); any = true; } if (style.background) |color| { try color.writeSgr(writer, .bg); any = true; } if (style.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.reverse) { try writer.writeAll("\x1b[7m"); if (style.foreground) |color| try color.writeSgr(writer, .fg); any = true; } if (style.color_whitespace) { if (style.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.bold or style.italic or style.underline != .none or style.strikethrough or style.reverse or style.blink or style.faint or style.foreground != null or style.background != null or style.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 width: u16 = 0; var run_start: usize = 0; var i: usize = 0; while (i < line.len) : (i += 1) { if (line[i] != '\t') continue; width = util.satAdd(width, measure.cellWidth(line[run_start..i])); if (tw == null) { width = util.satAdd(width, 1); } else if (tw.? > 0) { width = util.satAdd(width, tw.?); } run_start = i + 1; } return util.satAdd(width, 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 border = style.border.?; const corner_l = switch (row) { .top => border.top_left, .bottom => border.bottom_left, }; const corner_r = switch (row) { .top => border.top_right, .bottom => border.bottom_right, }; const edge = switch (row) { .top => border.top, .bottom => border.bottom, }; const fg_color = switch (row) { .top => style.border_top_fg, .bottom => style.border_bottom_fg, }; const bg_color = switch (row) { .top => style.border_top_bg, .bottom => style.border_bottom_bg, }; try writeMargin(writer, style, marg_l); const styled = fg_color != null or bg_color != null; if (fg_color) |c| try c.writeSgr(writer, .fg); if (bg_color) |c| try c.writeSgr(writer, .bg); if (bl) try writer.writeAll(corner_l); var i: u16 = 0; while (i < content_w) : (i += 1) try writer.writeAll(edge); if (br) try writer.writeAll(corner_r); if (styled) try writer.writeAll("\x1b[0m"); 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); if (bl) try writeBorderPiece(writer, style.border.?.left, style.border_left_fg, style.border_left_bg); const ws = try writeWhitespaceOpen(writer, style); try writeRepeatCp(writer, ' ', content_w); try writeWhitespaceClose(writer, ws); if (br) try writeBorderPiece(writer, style.border.?.right, style.border_right_fg, style.border_right_bg); 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.padding.left; const pad_r = style.padding.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.tab_width); const fill = inner_w -| visible; const left_fill: u16 = switch (style.align_horizontal) { .right => fill, .center => fill / 2, .left => 0, }; const right_fill = fill - left_fill; try writeMargin(writer, style, marg_l); if (bl) try writeBorderPiece(writer, style.border.?.left, style.border_left_fg, style.border_left_bg); { const ws = try writeWhitespaceOpen(writer, style); try writeRepeatCp(writer, style.padding_char, pad_l); try writeRepeatCp(writer, ' ', left_fill); try writeWhitespaceClose(writer, ws); } const has_link = style.link != null and line.len > 0; if (has_link) try writeOsc8Open(writer, style.link.?, style.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.padding_char, pad_r); try writeWhitespaceClose(writer, ws); } if (br) try writeBorderPiece(writer, style.border.?.right, style.border_right_fg, style.border_right_bg); 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.underline != .none; const strike_on = style.strikethrough; const ul_spaces = style.underline_spaces orelse underline_on; const st_spaces = style.strikethrough_spaces orelse strike_on; 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.tab_width); 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.tab_width != null and style.tab_width.? > 0) { const n: u16 = style.tab_width.?; var k: u16 = 0; while (k < n and visible < max_w) : (k += 1) { try writer.writeByte(' '); visible += 1; } } else if (style.tab_width == 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", init(.{}), "hello"); } test "render bold + fg" { try expectRender("\x1b[1m\x1b[31mx\x1b[0m", init(.{}).fg(.red).attr(.bold, true), "x"); } test "render fixed width pads right" { try expectRender("ab ", init(.{}).cols(5), "ab"); } test "render padding all = 1" { // content_w = visible(1) + pad_l(1) + pad_r(1) = 3. try expectRender(" \n x \n ", init(.{}).pad(.all, 1), "x"); } test "builder pad" { const style = init(.{}).pad(.all, 2); try testing.expectEqual(@as(u16, 2), style.padding.top); try testing.expectEqual(@as(u16, 2), style.padding.right); try testing.expectEqual(@as(u16, 2), style.padding.bottom); try testing.expectEqual(@as(u16, 2), style.padding.left); } test "render margin all = 1" { // total_w = marg_l(1) + content_w(1) + marg_r(1) = 3. try expectRender(" \n x \n ", init(.{}).mar(.all, 1), "x"); } test "render border normal" { try expectRender("┌─┐\n│x│\n└─┘", init(.{}).box(.normal), "x"); } test "render border top only" { const style = init(.{}).box(.normal).borderSide(.all, false).borderSide(.top, true); 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", init(.{}).box(.normal).borderFg(.all, .red), "x", ); } test "render underline single" { try expectRender("\x1b[4mx\x1b[0m", init(.{}).ul(.single), "x"); } test "render underline curly" { try expectRender("\x1b[4:3mx\x1b[0m", init(.{}).ul(.curly), "x"); } test "render hyperlink" { try expectRender( "\x1b]8;;http://x\x1b\\y\x1b]8;;\x1b\\", init(.{}).hyperlink("http://x"), "y", ); } test "render inline mode strips newlines and frame" { const style = init(.{}).inlineMode(true).pad(.all, 4).attr(.bold, true); try expectRender("\x1b[1mab\x1b[0m", style, "a\nb"); } test "render padding char" { try expectRender(".x.", init(.{}).pad(.horizontal, 1).paddingChar('.'), "x"); } test "render word wrap" { try expectRender("hello\nworld", init(.{}).cols(5), "hello world"); } test "render tab expansion" { try expectRender("a b", init(.{}).tabWidth(2), "a\tb"); } test "render tab removal" { try expectRender("ab", init(.{}).tabWidth(0), "a\tb"); } test "render max width truncates" { try expectRender("abc", init(.{}).maxWidth(3), "abcdefg"); } test "render max height truncates" { try expectRender("a\nb", init(.{}).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", init(.{}).ul(.single).underlineSpaces(false), "a b", ); } test "render align horizontal center" { try expectRender(" x ", init(.{}).cols(5).alignH(.center), "x"); } test "render align horizontal right" { try expectRender(" x", init(.{}).cols(5).alignH(.right), "x"); } test "render align vertical bottom" { try expectRender(" \n \nx", init(.{}).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 testing.allocator.free(want); try testing.expectEqualStrings(want, got); } test "renderComptime matches render" { try expectRenderComptime(init(.{}), "hello"); try expectRenderComptime(init(.{}).fg(.red).attr(.bold, true), "x"); try expectRenderComptime(init(.{}).box(.normal).borderFg(.all, .red), "x"); try expectRenderComptime(init(.{}).cols(5).alignH(.center), "x"); try expectRenderComptime(init(.{}).pad(.all, 1), "x"); try expectRenderComptime(init(.{}).cols(5), "hello world"); try expectRenderComptime(init(.{}).maxWidth(3), "abcdefg"); try expectRenderComptime(init(.{}).rows(3).alignV(.bottom), "x"); try expectRenderComptime(init(.{}).ul(.single).underlineSpaces(false), "a b"); try expectRenderComptime(init(.{}).tabWidth(2), "a\tb"); }