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.
pull/9407/head
Mitchell Hashimoto 2025-10-29 20:55:52 -07:00 committed by GitHub
parent e70ca0b9b5
commit c0e483c49e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 809 additions and 41 deletions

View File

@ -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;

View File

@ -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 <style> block if you
/// want to also include that. But if you only format a screen or lower,
/// the formatter doesn't have access to the current palette to render it.
///
/// Newlines are emitted as actual '\n' characters. Consumers should use
/// CSS white-space: pre or pre-wrap to preserve spacing and alignment.
html,
pub fn styled(self: Format) bool {
return switch (self) {
.plain => false,
.vt => true,
.html, .vt => true,
};
}
};
@ -56,8 +74,15 @@ pub const Options = struct {
/// is currently only space characters (0x20).
trim: bool = true,
/// If set, then styled formats in `emit` will use this palette to
/// emit colors directly as RGB. If this is null, styled formats will
/// still work but will use deferred palette styling (e.g. CSS variables
/// for HTML or the actual palette indexes for VT).
palette: ?*const color.Palette = null,
pub const plain: Options = .{ .emit = .plain };
pub const vt: Options = .{ .emit = .vt };
pub const html: Options = .{ .emit = .html };
};
/// Maps byte positions in formatted output to PageList pins.
@ -191,16 +216,34 @@ pub const TerminalFormatter = struct {
pub fn format(
self: TerminalFormatter,
writer: *std.Io.Writer,
) !void {
) std.Io.Writer.Error!void {
// Emit palette before screen content if using VT format. Technically
// we could do this after but this way if replay is slow for whatever
// reason the colors will be right right away.
if (self.opts.emit == .vt and self.extra.palette) {
for (self.terminal.color_palette.colors, 0..) |rgb, i| {
try writer.print(
"\x1b]4;{d};rgb:{x:0>2}/{x:0>2}/{x:0>2}\x1b\\",
.{ i, rgb.r, rgb.g, rgb.b },
);
if (self.extra.palette) palette: {
switch (self.opts.emit) {
.plain => break :palette,
.vt => {
for (self.terminal.color_palette.colors, 0..) |rgb, i| {
try writer.print(
"\x1b]4;{d};rgb:{x:0>2}/{x:0>2}/{x:0>2}\x1b\\",
.{ i, rgb.r, rgb.g, rgb.b },
);
}
},
// For HTML, we emit CSS to setup our palette variables.
.html => {
try writer.writeAll("<style>:root{");
for (self.terminal.color_palette.colors, 0..) |rgb, i| {
try writer.print(
"--vt-palette-{d}: #{x:0>2}{x:0>2}{x:0>2};",
.{ i, rgb.r, rgb.g, rgb.b },
);
}
try writer.writeAll("}</style>");
},
}
// 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 = "<div style=\"font-family: monospace; white-space: pre;\">";
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 = "</div>";
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("&lt;"),
'>' => try writer.writeAll("&gt;"),
'&' => try writer.writeAll("&amp;"),
'"' => try writer.writeAll("&quot;"),
'\'' => try writer.writeAll("&#39;"),
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("</div>");
var formatter = style.formatterHtml();
formatter.palette = self.opts.palette;
try writer.print(
"<div style=\"display: inline;{f}\">",
.{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 => "</div>",
};
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(
"<div style=\"font-family: monospace; white-space: pre;\">" ++
"<div style=\"display: inline;font-weight: bold;\">bold</div>" ++
"<div style=\"display: inline;font-weight: bold;font-style: italic;\">italic</div>" ++
"normal" ++
"</div>",
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(
"<div style=\"font-family: monospace; white-space: pre;\">hello, world</div>",
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(
"<div style=\"font-family: monospace; white-space: pre;\">" ++
"<div style=\"display: inline;color: var(--vt-palette-1);background-color: var(--vt-palette-4);\">colored</div>" ++
"</div>",
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, "<style>:root{") != null);
try testing.expect(std.mem.indexOf(u8, output, "--vt-palette-0: #123456;") != null);
try testing.expect(std.mem.indexOf(u8, output, "--vt-palette-1: #abcdef;") != null);
try testing.expect(std.mem.indexOf(u8, output, "--vt-palette-255: #ff00ff;") != null);
try testing.expect(std.mem.indexOf(u8, output, "}</style>") != 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("<tag>&\"'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(
"<div style=\"font-family: monospace; white-space: pre;\">&lt;tag&gt;&amp;&quot;&#39;text</div>",
output,
);
// Verify point map length matches output
try testing.expectEqual(output.len, point_map.items.len);
// Opening wrapper div
const wrapper_start = "<div style=\"font-family: monospace; white-space: pre;\">";
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: &lt;) -> 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: &gt;) -> x=4
for (0..4) |i| try testing.expectEqual(Coordinate{ .x = 4, .y = 0 }, point_map.items[offset + 7 + i]);
// & (5 bytes: &amp;) -> x=5
for (0..5) |i| try testing.expectEqual(Coordinate{ .x = 5, .y = 0 }, point_map.items[offset + 11 + i]);
// " (6 bytes: &quot;) -> x=6
for (0..6) |i| try testing.expectEqual(Coordinate{ .x = 6, .y = 0 }, point_map.items[offset + 16 + i]);
// ' (5 bytes: &#39;) -> 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(
"<div style=\"font-family: monospace; white-space: pre;\">" ++
"<div style=\"display: inline;color: var(--vt-palette-1);\">red</div>" ++
"</div>",
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(
"<div style=\"font-family: monospace; white-space: pre;\">" ++
"<div style=\"display: inline;color: rgb(171, 205, 239);\">red</div>" ++
"</div>",
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);
}

View File

@ -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(),
);
}