Something went wrong. Try again.
A charm-like tui library
Something went wrong. Try again.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585const 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");}