From c0e483c49e9bce963e27178407425ba6a12c4705 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 29 Oct 2025 20:55:52 -0700 Subject: [PATCH] terminal: HTML formatting (#9402) This adds HTML formatting capabilities to the formatter package. HTML is emitted as inline styles. For palette indexes, direct RGB is emitted if we have access to a palette; otherwise, we fall back to CSS variables. This isn't exposed to end users yet, but will enable copy as html features. This is available in libghostty. Fixes #9395 **AI disclosure:** I used AI (Amp) to help me write tests, but the implementation was done manually. I reviewed everything. --- src/lib_vt.zig | 1 + src/terminal/formatter.zig | 594 ++++++++++++++++++++++++++++++++++--- src/terminal/style.zig | 255 +++++++++++++++- 3 files changed, 809 insertions(+), 41 deletions(-) diff --git a/src/lib_vt.zig b/src/lib_vt.zig index aa37c6110..e95eee5f4 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -25,6 +25,7 @@ pub const osc = terminal.osc; pub const point = terminal.point; pub const color = terminal.color; pub const device_status = terminal.device_status; +pub const formatter = terminal.formatter; pub const kitty = terminal.kitty; pub const modes = terminal.modes; pub const page = terminal.page; diff --git a/src/terminal/formatter.zig b/src/terminal/formatter.zig index cd4b76340..246624d5b 100644 --- a/src/terminal/formatter.zig +++ b/src/terminal/formatter.zig @@ -1,6 +1,7 @@ const std = @import("std"); const assert = std.debug.assert; const Allocator = std.mem.Allocator; +const color = @import("color.zig"); const size = @import("size.zig"); const charsets = @import("charsets.zig"); const kitty = @import("kitty.zig"); @@ -33,10 +34,27 @@ pub const Format = enum { /// moves back to the beginning prior emitting follow-up lines. vt, + /// HTML output. + /// + /// This will emit inline styles for as much styling as possible, + /// in the interest of simplicity and ease of editing. This isn't meant + /// to build the most beautiful or efficient HTML, but rather to be + /// stylistically correct. + /// + /// For colors, RGB values are emitted as inline CSS (#RRGGBB) while palette + /// indices use CSS variables (var(--vt-palette-N)). The palette colors are + /// emitted by TerminalFormatter.Extra.palette as a "); + }, } // If we have a pin_map, add the bytes we wrote to map. @@ -461,7 +504,7 @@ pub const ScreenFormatter = struct { pub fn format( self: ScreenFormatter, writer: *std.Io.Writer, - ) !void { + ) std.Io.Writer.Error!void { switch (self.content) { .none => {}, @@ -484,6 +527,10 @@ pub const ScreenFormatter = struct { switch (self.opts.emit) { .plain => return, .vt => if (!self.extra.isSet()) return, + + // HTML doesn't preserve any screen state because it has + // nothing to do with rendering. + .html => return, } // Emit current SGR style state @@ -656,7 +703,7 @@ pub const PageListFormatter = struct { pub fn format( self: PageListFormatter, writer: *std.Io.Writer, - ) !void { + ) std.Io.Writer.Error!void { const tl: PageList.Pin = self.top_left orelse self.list.getTopLeft(.screen); const br: PageList.Pin = self.bottom_right orelse self.list.getBottomRight(.screen).?; @@ -791,14 +838,14 @@ pub const PageFormatter = struct { pub fn format( self: PageFormatter, writer: *std.Io.Writer, - ) !void { + ) std.Io.Writer.Error!void { _ = try self.formatWithState(writer); } pub fn formatWithState( self: PageFormatter, writer: *std.Io.Writer, - ) !TrailingState { + ) std.Io.Writer.Error!TrailingState { var blank_rows: usize = 0; var blank_cells: usize = 0; @@ -854,6 +901,17 @@ pub const PageFormatter = struct { return .{ .rows = blank_rows, .cells = blank_cells }; } + // Wrap HTML output in monospace font styling + if (self.opts.emit == .html) { + const monospace = "
"; + try writer.writeAll(monospace); + if (self.point_map) |*map| map.map.appendNTimes( + map.alloc, + .{ .x = 0, .y = 0 }, + monospace.len, + ) catch return error.WriteFailed; + } + // Our style for non-plain formats var style: Style = .{}; @@ -904,8 +962,19 @@ pub const PageFormatter = struct { if (blank_rows > 0) { const sequence: []const u8 = switch (self.opts.emit) { + // Plaintext just uses standard newlines because newlines + // on their own usually move the cursor back in anywhere + // you type plaintext. .plain => "\n", + + // VT uses \r\n because in a raw pty, \n alone doesn't + // guarantee moving the cursor back to column 0. \r + // makes it work for sure. .vt => "\r\n", + + // HTML uses just \n because HTML rendering will move + // the cursor back. + .html => "\n", }; for (0..blank_rows) |_| try writer.writeAll(sequence); @@ -1012,6 +1081,16 @@ pub const PageFormatter = struct { // We combine codepoint and graphemes because both have // shared style handling. We use comptime to dup it. inline .codepoint, .codepoint_grapheme => |tag| { + // Handle closing our styling if we go back to unstyled + // content. + if (self.opts.emit.styled() and + !cell.hasStyling() and + !style.default()) + { + try self.formatStyleClose(writer); + style = .{}; + } + // If we're emitting styling and we have styles, then // we need to load the style and emit any sequences // as necessary. @@ -1026,15 +1105,28 @@ pub const PageFormatter = struct { // emitted style, don't bloat the output. if (cell_style.eql(style)) break :style; + // We need to emit a closing tag if the style + // was non-default before, which means we set + // styles once. + const closing = !style.default(); + // New style, emit it. style = cell_style.*; - try writer.print("{f}", .{style.formatterVt()}); + try self.formatStyleOpen( + writer, + &style, + closing, + ); // If we have a point map, we map the style to // this cell. if (self.point_map) |*map| { var discarding: std.Io.Writer.Discarding = .init(&.{}); - try discarding.writer.print("{f}", .{style.formatterVt()}); + try self.formatStyleOpen( + &discarding.writer, + &style, + closing, + ); for (0..discarding.count) |_| map.map.append(map.alloc, .{ .x = x, .y = y, @@ -1042,24 +1134,13 @@ pub const PageFormatter = struct { } } - try writer.print("{u}", .{cell.content.codepoint}); - if (comptime tag == .codepoint_grapheme) { - for (self.page.lookupGrapheme(cell).?) |cp| { - try writer.print("{u}", .{cp}); - } - } + try self.writeCell(tag, writer, cell); // If we have a point map, all codepoints map to this // cell. if (self.point_map) |*map| { var discarding: std.Io.Writer.Discarding = .init(&.{}); - try discarding.writer.print("{u}", .{cell.content.codepoint}); - if (comptime tag == .codepoint_grapheme) { - for (self.page.lookupGrapheme(cell).?) |cp| { - try writer.print("{u}", .{cp}); - } - } - + try self.writeCell(tag, &discarding.writer, cell); for (0..discarding.count) |_| map.map.append(map.alloc, .{ .x = x, .y = y, @@ -1075,8 +1156,117 @@ pub const PageFormatter = struct { } } + // If the style is non-default, we need to close our style tag. + if (!style.default()) try self.formatStyleClose(writer); + + // Close the monospace wrapper for HTML output + if (self.opts.emit == .html) { + const closing = "
"; + try writer.writeAll(closing); + if (self.point_map) |*map| { + map.map.ensureUnusedCapacity( + map.alloc, + closing.len, + ) catch return error.WriteFailed; + map.map.appendNTimesAssumeCapacity( + map.map.items[map.map.items.len - 1], + closing.len, + ); + } + } + return .{ .rows = blank_rows, .cells = blank_cells }; } + + fn writeCell( + self: PageFormatter, + comptime tag: Cell.ContentTag, + writer: *std.Io.Writer, + cell: *const Cell, + ) !void { + try self.writeCodepoint(writer, cell.content.codepoint); + if (comptime tag == .codepoint_grapheme) { + for (self.page.lookupGrapheme(cell).?) |cp| { + try self.writeCodepoint(writer, cp); + } + } + } + + fn writeCodepoint( + self: PageFormatter, + writer: *std.Io.Writer, + codepoint: u21, + ) !void { + switch (self.opts.emit) { + .plain, .vt => try writer.print("{u}", .{codepoint}), + .html => { + switch (codepoint) { + '<' => try writer.writeAll("<"), + '>' => try writer.writeAll(">"), + '&' => try writer.writeAll("&"), + '"' => try writer.writeAll("""), + '\'' => try writer.writeAll("'"), + else => try writer.print("{u}", .{codepoint}), + } + }, + } + } + + fn formatStyleOpen( + self: PageFormatter, + writer: *std.Io.Writer, + style: *const Style, + closing: bool, + ) std.Io.Writer.Error!void { + switch (self.opts.emit) { + .plain => unreachable, + + // Note: we don't use closing on purpose because VT sequences + // always reset the prior style. Our formatter always emits a + // \x1b[0m before emitting a new style if necessary. + .vt => { + var formatter = style.formatterVt(); + formatter.palette = self.opts.palette; + try writer.print("{f}", .{formatter}); + }, + + // We use `display: inline` so that the div doesn't impact + // layout since we're primarily using it as a CSS wrapper. + .html => { + if (closing) try writer.writeAll(""); + var formatter = style.formatterHtml(); + formatter.palette = self.opts.palette; + try writer.print( + "
", + .{formatter}, + ); + }, + } + } + + fn formatStyleClose( + self: PageFormatter, + writer: *std.Io.Writer, + ) std.Io.Writer.Error!void { + const str: []const u8 = switch (self.opts.emit) { + .plain => return, + .vt => "\x1b[0m", + .html => "
", + }; + + try writer.writeAll(str); + if (self.point_map) |*m| { + assert(m.map.items.len > 0); + m.map.ensureUnusedCapacity( + m.alloc, + str.len, + ) catch return error.WriteFailed; + m.map.appendNTimesAssumeCapacity( + m.map.items[m.map.items.len - 1], + str.len, + ); + } + } }; test "Page plain single line" { @@ -2785,7 +2975,7 @@ test "Page VT single line with bold" { try formatter.format(&builder.writer); const output = builder.writer.buffered(); - try testing.expectEqualStrings("\x1b[0m\x1b[1mhello", output); + try testing.expectEqualStrings("\x1b[0m\x1b[1mhello\x1b[0m", output); // Verify point map - style sequences should point to first character they style try testing.expectEqual(output.len, point_map.items.len); @@ -2831,7 +3021,7 @@ test "Page VT multiple styles" { try formatter.format(&builder.writer); const output = builder.writer.buffered(); - try testing.expectEqualStrings("\x1b[0m\x1b[1mhello \x1b[0m\x1b[1m\x1b[3mworld", output); + try testing.expectEqualStrings("\x1b[0m\x1b[1mhello \x1b[0m\x1b[1m\x1b[3mworld\x1b[0m", output); // Verify point map matches output length try testing.expectEqual(output.len, point_map.items.len); @@ -2866,7 +3056,7 @@ test "Page VT with foreground color" { try formatter.format(&builder.writer); const output = builder.writer.buffered(); - try testing.expectEqualStrings("\x1b[0m\x1b[38;5;1mred", output); + try testing.expectEqualStrings("\x1b[0m\x1b[38;5;1mred\x1b[0m", output); // Verify point map - style sequences should point to first character they style try testing.expectEqual(output.len, point_map.items.len); @@ -2912,7 +3102,7 @@ test "Page VT multi-line with styles" { try formatter.format(&builder.writer); const output = builder.writer.buffered(); - try testing.expectEqualStrings("\x1b[0m\x1b[1mfirst\r\n\x1b[0m\x1b[3msecond", output); + try testing.expectEqualStrings("\x1b[0m\x1b[1mfirst\r\n\x1b[0m\x1b[3msecond\x1b[0m", output); // Verify point map matches output length try testing.expectEqual(output.len, point_map.items.len); @@ -2947,7 +3137,7 @@ test "Page VT duplicate style not emitted twice" { try formatter.format(&builder.writer); const output = builder.writer.buffered(); - try testing.expectEqualStrings("\x1b[0m\x1b[1mhello", output); + try testing.expectEqualStrings("\x1b[0m\x1b[1mhello\x1b[0m", output); // Verify point map matches output length try testing.expectEqual(output.len, point_map.items.len); @@ -3229,7 +3419,7 @@ test "PageList VT spanning two pages" { try formatter.format(&builder.writer); const full_output = builder.writer.buffered(); const output = std.mem.trimStart(u8, full_output, "\r\n"); - try testing.expectEqualStrings("\x1b[0m\x1b[1mpage one\r\n\x1b[0m\x1b[1mpage two", output); + try testing.expectEqualStrings("\x1b[0m\x1b[1mpage one\x1b[0m\r\n\x1b[0m\x1b[1mpage two\x1b[0m", output); // Verify pin map try testing.expectEqual(full_output.len, pin_map.items.len); @@ -4536,3 +4726,341 @@ test "Terminal vt with pwd" { // Verify pwd matches try testing.expectEqualStrings(t.pwd.items, t2.pwd.items); } + +test "Page html with multiple styles" { + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + // Set bold, then italic, then reset + try s.nextSlice("\x1b[1mbold\x1b[3mitalic\x1b[0mnormal"); + + const pages = &t.screen.pages; + const page = &pages.pages.last.?.data; + var formatter: PageFormatter = .init(page, .{ .emit = .html }); + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + try testing.expectEqualStrings( + "
" ++ + "
bold
" ++ + "
italic
" ++ + "normal" ++ + "
", + output, + ); +} + +test "Page html plain text" { + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello, world"); + + const pages = &t.screen.pages; + const page = &pages.pages.last.?.data; + var formatter: PageFormatter = .init(page, .{ .emit = .html }); + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + // Plain text without styles should be wrapped in monospace div + try testing.expectEqualStrings( + "
hello, world
", + output, + ); +} + +test "Page html with colors" { + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + // Set red foreground, blue background + try s.nextSlice("\x1b[31;44mcolored"); + + const pages = &t.screen.pages; + const page = &pages.pages.last.?.data; + var formatter: PageFormatter = .init(page, .{ .emit = .html }); + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + try testing.expectEqualStrings( + "
" ++ + "
colored
" ++ + "
", + output, + ); +} + +test "TerminalFormatter html with palette" { + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + // Modify some palette colors + try s.nextSlice("\x1b]4;0;rgb:12/34/56\x1b\\"); + try s.nextSlice("\x1b]4;1;rgb:ab/cd/ef\x1b\\"); + try s.nextSlice("\x1b]4;255;rgb:ff/00/ff\x1b\\"); + try s.nextSlice("test"); + + var formatter: TerminalFormatter = .init(&t, .{ .emit = .html }); + formatter.extra.palette = true; + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + // Verify palette CSS variables are emitted + try testing.expect(std.mem.indexOf(u8, output, "") != null); + try testing.expect(std.mem.indexOf(u8, output, "test") != null); +} + +test "Page html with escaping" { + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("&\"'text"); + + const pages = &t.screen.pages; + const page = &pages.pages.last.?.data; + var formatter: PageFormatter = .init(page, .{ .emit = .html }); + + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + try testing.expectEqualStrings( + "
<tag>&"'text
", + output, + ); + + // Verify point map length matches output + try testing.expectEqual(output.len, point_map.items.len); + + // Opening wrapper div + const wrapper_start = "
"; + const wrapper_start_len = wrapper_start.len; + for (0..wrapper_start_len) |i| try testing.expectEqual(Coordinate{ .x = 0, .y = 0 }, point_map.items[i]); + + // Verify each character maps correctly, accounting for escaping + const offset = wrapper_start_len; + // < (4 bytes: <) -> x=0 + for (0..4) |i| try testing.expectEqual(Coordinate{ .x = 0, .y = 0 }, point_map.items[offset + i]); + // t (1 byte) -> x=1 + try testing.expectEqual(Coordinate{ .x = 1, .y = 0 }, point_map.items[offset + 4]); + // a (1 byte) -> x=2 + try testing.expectEqual(Coordinate{ .x = 2, .y = 0 }, point_map.items[offset + 5]); + // g (1 byte) -> x=3 + try testing.expectEqual(Coordinate{ .x = 3, .y = 0 }, point_map.items[offset + 6]); + // > (4 bytes: >) -> x=4 + for (0..4) |i| try testing.expectEqual(Coordinate{ .x = 4, .y = 0 }, point_map.items[offset + 7 + i]); + // & (5 bytes: &) -> x=5 + for (0..5) |i| try testing.expectEqual(Coordinate{ .x = 5, .y = 0 }, point_map.items[offset + 11 + i]); + // " (6 bytes: ") -> x=6 + for (0..6) |i| try testing.expectEqual(Coordinate{ .x = 6, .y = 0 }, point_map.items[offset + 16 + i]); + // ' (5 bytes: ') -> x=7 + for (0..5) |i| try testing.expectEqual(Coordinate{ .x = 7, .y = 0 }, point_map.items[offset + 22 + i]); + // t (1 byte) -> x=8 + try testing.expectEqual(Coordinate{ .x = 8, .y = 0 }, point_map.items[offset + 27]); + // e (1 byte) -> x=9 + try testing.expectEqual(Coordinate{ .x = 9, .y = 0 }, point_map.items[offset + 28]); + // x (1 byte) -> x=10 + try testing.expectEqual(Coordinate{ .x = 10, .y = 0 }, point_map.items[offset + 29]); + // t (1 byte) -> x=11 + try testing.expectEqual(Coordinate{ .x = 11, .y = 0 }, point_map.items[offset + 30]); +} + +test "Page VT with palette option emits RGB" { + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + // Set a custom palette color and use it + try s.nextSlice("\x1b]4;1;rgb:ab/cd/ef\x1b\\"); + try s.nextSlice("\x1b[31mred"); + + const pages = &t.screen.pages; + const page = &pages.pages.last.?.data; + + // Without palette option - should emit palette index + { + builder.clearRetainingCapacity(); + var formatter: PageFormatter = .init(page, .vt); + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("\x1b[0m\x1b[38;5;1mred\x1b[0m", output); + } + + // With palette option - should emit RGB directly + { + builder.clearRetainingCapacity(); + var opts: Options = .vt; + opts.palette = &t.color_palette.colors; + var formatter: PageFormatter = .init(page, opts); + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("\x1b[0m\x1b[38;2;171;205;239mred\x1b[0m", output); + } +} + +test "Page html with palette option emits RGB" { + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + // Set a custom palette color and use it + try s.nextSlice("\x1b]4;1;rgb:ab/cd/ef\x1b\\"); + try s.nextSlice("\x1b[31mred"); + + const pages = &t.screen.pages; + const page = &pages.pages.last.?.data; + + // Without palette option - should emit CSS variable + { + builder.clearRetainingCapacity(); + var formatter: PageFormatter = .init(page, .{ .emit = .html }); + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings( + "
" ++ + "
red
" ++ + "
", + output, + ); + } + + // With palette option - should emit RGB directly + { + builder.clearRetainingCapacity(); + var opts: Options = .{ .emit = .html }; + opts.palette = &t.color_palette.colors; + var formatter: PageFormatter = .init(page, opts); + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings( + "
" ++ + "
red
" ++ + "
", + output, + ); + } +} + +test "Page VT style reset properly closes styles" { + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + // Set bold, then reset with SGR 0 + try s.nextSlice("\x1b[1mbold\x1b[0mnormal"); + + const pages = &t.screen.pages; + const page = &pages.pages.last.?.data; + + builder.clearRetainingCapacity(); + var formatter: PageFormatter = .init(page, .vt); + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + // The reset should properly close the bold style + try testing.expectEqualStrings("\x1b[0m\x1b[1mbold\x1b[0mnormal", output); +} diff --git a/src/terminal/style.zig b/src/terminal/style.zig index fea4666b8..d7e6b03ab 100644 --- a/src/terminal/style.zig +++ b/src/terminal/style.zig @@ -306,9 +306,24 @@ pub const Style = struct { return .{ .style = self }; } + /// Returns a formatter that renders this style as inline CSS properties, + /// to be used with `{f}`. The output is a valid CSS style string suitable + /// for use in a `style` attribute (e.g., "color: rgb(255, 0, 0); font-weight: bold;"). + /// + /// Palette colors are emitted as CSS variables like `var(--vt-palette-N)`. + pub fn formatterHtml(self: *const Style) HtmlFormatter { + return .{ .style = self }; + } + const VTFormatter = struct { style: *const Style, + /// If set, palette colors will be emitted as RGB values instead of + /// palette indices. This is useful when you want to capture the + /// exact colors at formatting time rather than relying on the + /// terminal's palette. + palette: ?*const color.Palette = null, + pub fn format( self: VTFormatter, writer: *std.Io.Writer, @@ -337,30 +352,110 @@ pub const Style = struct { } // Various RGB colors. - try formatColor(writer, 38, self.style.fg_color); - try formatColor(writer, 48, self.style.bg_color); - try formatColor(writer, 58, self.style.underline_color); + try self.formatColor(writer, 38, self.style.fg_color); + try self.formatColor(writer, 48, self.style.bg_color); + try self.formatColor(writer, 58, self.style.underline_color); } fn formatColor( + self: VTFormatter, writer: *std.Io.Writer, prefix: u8, value: Color, ) !void { switch (value) { .none => {}, - .palette => |idx| try writer.print( - "\x1b[{d};5;{}m", - .{ prefix, idx }, - ), + .palette => |idx| { + if (self.palette) |p| { + const rgb = p[idx]; + try writer.print( + "\x1b[{d};2;{d};{d};{d}m", + .{ prefix, rgb.r, rgb.g, rgb.b }, + ); + } else { + try writer.print( + "\x1b[{d};5;{d}m", + .{ prefix, idx }, + ); + } + }, .rgb => |rgb| try writer.print( - "\x1b[{d};2;{};{};{}m", + "\x1b[{d};2;{d};{d};{d}m", .{ prefix, rgb.r, rgb.g, rgb.b }, ), } } }; + const HtmlFormatter = struct { + style: *const Style, + + /// If set, palette colors will be emitted as RGB values instead of + /// CSS variables. This is useful when you want to capture the exact + /// colors at formatting time rather than relying on CSS variables. + palette: ?*const color.Palette = null, + + pub fn format( + self: HtmlFormatter, + writer: *std.Io.Writer, + ) !void { + // Colors + try self.formatColor(writer, "color", self.style.fg_color); + try self.formatColor(writer, "background-color", self.style.bg_color); + try self.formatColor(writer, "text-decoration-color", self.style.underline_color); + + // Text decoration line + const has_line = self.style.flags.underline != .none or + self.style.flags.strikethrough or + self.style.flags.overline or + self.style.flags.blink; + if (has_line) { + try writer.writeAll("text-decoration-line:"); + if (self.style.flags.underline != .none) try writer.writeAll(" underline"); + if (self.style.flags.strikethrough) try writer.writeAll(" line-through"); + if (self.style.flags.overline) try writer.writeAll(" overline"); + if (self.style.flags.blink) try writer.writeAll(" blink"); + try writer.writeAll(";"); + } + + // Text decoration style + switch (self.style.flags.underline) { + .none => {}, + .single => try writer.writeAll("text-decoration-style: solid;"), + .double => try writer.writeAll("text-decoration-style: double;"), + .curly => try writer.writeAll("text-decoration-style: wavy;"), + .dotted => try writer.writeAll("text-decoration-style: dotted;"), + .dashed => try writer.writeAll("text-decoration-style: dashed;"), + } + + if (self.style.flags.bold) try writer.writeAll("font-weight: bold;"); + if (self.style.flags.italic) try writer.writeAll("font-style: italic;"); + if (self.style.flags.faint) try writer.writeAll("opacity: 0.5;"); + if (self.style.flags.invisible) try writer.writeAll("visibility: hidden;"); + if (self.style.flags.inverse) try writer.writeAll("filter: invert(100%);"); + } + + fn formatColor( + self: HtmlFormatter, + writer: *std.Io.Writer, + property: []const u8, + c: Color, + ) !void { + switch (c) { + .none => {}, + .palette => |idx| { + if (self.palette) |p| { + const rgb = p[idx]; + try writer.print("{s}: rgb({d}, {d}, {d});", .{ property, rgb.r, rgb.g, rgb.b }); + } else { + try writer.print("{s}: var(--vt-palette-{d});", .{ property, idx }); + } + }, + .rgb => |rgb| try writer.print("{s}: rgb({d}, {d}, {d});", .{ property, rgb.r, rgb.g, rgb.b }), + } + } + }; + /// `PackedStyle` represents the same data as `Style` but without padding, /// which is necessary for hashing via re-interpretation of the underlying /// bytes. @@ -772,6 +867,39 @@ test "Style VT formatting all colors palette" { ); } +test "Style VT formatting palette with palette set emits rgb" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .fg_color = .{ .palette = 1 } }; + var formatter = style.formatterVt(); + formatter.palette = &color.default; + try builder.writer.print("{f}", .{formatter}); + try testing.expectEqualStrings("\x1b[0m\x1b[38;2;204;102;102m", builder.writer.buffered()); +} + +test "Style VT formatting all palette colors with palette set" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ + .fg_color = .{ .palette = 1 }, + .bg_color = .{ .palette = 2 }, + .underline_color = .{ .palette = 3 }, + }; + var formatter = style.formatterVt(); + formatter.palette = &color.default; + try builder.writer.print("{f}", .{formatter}); + try testing.expectEqualStrings( + "\x1b[0m\x1b[38;2;204;102;102m\x1b[48;2;181;189;104m\x1b[58;2;240;198;116m", + builder.writer.buffered(), + ); +} + test "Set basic usage" { const testing = std.testing; const alloc = testing.allocator; @@ -831,3 +959,114 @@ test "Set capacities" { // We want to support at least this many styles without overflowing. _ = Set.Layout.init(16384); } + +test "Style HTML formatting basic bold" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .flags = .{ .bold = true } }; + try builder.writer.print("{f}", .{style.formatterHtml()}); + try testing.expectEqualStrings("font-weight: bold;", builder.writer.buffered()); +} + +test "Style HTML formatting fg color rgb" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .fg_color = .{ .rgb = .{ .r = 255, .g = 128, .b = 64 } } }; + try builder.writer.print("{f}", .{style.formatterHtml()}); + try testing.expectEqualStrings("color: rgb(255, 128, 64);", builder.writer.buffered()); +} + +test "Style HTML formatting bg color palette" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .bg_color = .{ .palette = 7 } }; + try builder.writer.print("{f}", .{style.formatterHtml()}); + try testing.expectEqualStrings("background-color: var(--vt-palette-7);", builder.writer.buffered()); +} + +test "Style HTML formatting combined colors and flags" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ + .fg_color = .{ .rgb = .{ .r = 255, .g = 0, .b = 0 } }, + .bg_color = .{ .rgb = .{ .r = 0, .g = 0, .b = 255 } }, + .flags = .{ .bold = true, .italic = true }, + }; + try builder.writer.print("{f}", .{style.formatterHtml()}); + const result = builder.writer.buffered(); + try testing.expect(std.mem.indexOf(u8, result, "color: rgb(255, 0, 0);") != null); + try testing.expect(std.mem.indexOf(u8, result, "background-color: rgb(0, 0, 255);") != null); + try testing.expect(std.mem.indexOf(u8, result, "font-weight: bold;") != null); + try testing.expect(std.mem.indexOf(u8, result, "font-style: italic;") != null); +} + +test "Style HTML formatting single decoration line" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .flags = .{ .underline = .single } }; + try builder.writer.print("{f}", .{style.formatterHtml()}); + const result = builder.writer.buffered(); + try testing.expect(std.mem.indexOf(u8, result, "text-decoration-line: underline;") != null); + try testing.expect(std.mem.indexOf(u8, result, "text-decoration-style: solid;") != null); +} + +test "Style HTML formatting multiple decoration lines" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .flags = .{ .underline = .curly, .strikethrough = true, .overline = true } }; + try builder.writer.print("{f}", .{style.formatterHtml()}); + const result = builder.writer.buffered(); + try testing.expect(std.mem.indexOf(u8, result, "text-decoration-line: underline line-through overline;") != null); + try testing.expect(std.mem.indexOf(u8, result, "text-decoration-style: wavy;") != null); +} + +test "Style HTML formatting palette with palette set emits rgb" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .bg_color = .{ .palette = 7 } }; + var formatter = style.formatterHtml(); + formatter.palette = &color.default; + try builder.writer.print("{f}", .{formatter}); + try testing.expectEqualStrings("background-color: rgb(197, 200, 198);", builder.writer.buffered()); +} + +test "Style HTML formatting all palette colors with palette set" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ + .fg_color = .{ .palette = 1 }, + .bg_color = .{ .palette = 2 }, + .underline_color = .{ .palette = 3 }, + }; + var formatter = style.formatterHtml(); + formatter.palette = &color.default; + try builder.writer.print("{f}", .{formatter}); + try testing.expectEqualStrings( + "color: rgb(204, 102, 102);background-color: rgb(181, 189, 104);text-decoration-color: rgb(240, 198, 116);", + builder.writer.buffered(), + ); +}