From 6fdbc29b9ae0eb72ca7904eedda7702aa6f0c5c7 Mon Sep 17 00:00:00 2001 From: Eric Bower Date: Thu, 1 Jan 2026 20:07:27 -0500 Subject: [PATCH] fix(formatter): preserve background colors on cells without text The VT formatter was treating cells without text as blank and emitting them as plain spaces, losing any background color styling. This caused TUIs like htop to lose their background colors when rehydrating terminal state (e.g., after detach/reattach in zmx). For styled formats (VT/HTML), cells with background colors or style_id are now emitted with proper SGR sequences and a space character instead of being accumulated as unstyled blanks. Adds handling for bg_color_palette and bg_color_rgb content tags which were previously unreachable. Reference: https://ampcode.com/threads/T-019b7a35-c3f3-73fc-adfa-00bbe9dbda3c --- src/terminal/formatter.zig | 97 +++++++++++++++++++++++++++++++++----- 1 file changed, 86 insertions(+), 11 deletions(-) diff --git a/src/terminal/formatter.zig b/src/terminal/formatter.zig index d0e248d72..a107b0535 100644 --- a/src/terminal/formatter.zig +++ b/src/terminal/formatter.zig @@ -1113,12 +1113,16 @@ pub const PageFormatter = struct { // If we have a zero value, then we accumulate a counter. We // only want to turn zero values into spaces if we have a non-zero - // char sometime later. - if (!cell.hasText()) { + // char sometime later. However, for styled formats (VT, HTML), if + // the cell has styling (e.g., background color), we must emit it + // to preserve the visual appearance. + const dominated_by_style = self.opts.emit.styled() and + (!cell.isEmpty() or cell.hasStyling()); + if (!dominated_by_style and !cell.hasText()) { blank_cells += 1; continue; } - if (cell.codepoint() == ' ' and self.opts.trim) { + if (cell.codepoint() == ' ' and self.opts.trim and !dominated_by_style) { blank_cells += 1; continue; } @@ -1215,24 +1219,46 @@ pub const PageFormatter = struct { } } - try self.writeCell(tag, writer, cell); + // For styled cells without text, emit a space to carry the styling + if (cell.hasText()) { + try self.writeCell(tag, writer, cell); + } else { + try writer.writeByte(' '); + } // 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 self.writeCell(tag, &discarding.writer, cell); - for (0..discarding.count) |_| map.map.append(map.alloc, .{ + const byte_count: usize = if (cell.hasText()) count: { + var discarding: std.Io.Writer.Discarding = .init(&.{}); + try self.writeCell(tag, &discarding.writer, cell); + break :count discarding.count; + } else 1; + for (0..byte_count) |_| map.map.append(map.alloc, .{ .x = x, .y = y, }) catch return error.WriteFailed; } }, - // Unreachable since we do hasText() above - .bg_color_palette, - .bg_color_rgb, - => unreachable, + // Cells with only background color (no text). Emit a space + // with the appropriate background color SGR sequence. + .bg_color_palette => { + const index = cell.content.color_palette; + try self.emitBgColorSgr(writer, index, null, &style); + try writer.writeByte(' '); + if (self.point_map) |*map| { + map.map.append(map.alloc, .{ .x = x, .y = y }) catch return error.WriteFailed; + } + }, + .bg_color_rgb => { + const rgb = cell.content.color_rgb; + try self.emitBgColorSgr(writer, null, rgb, &style); + try writer.writeByte(' '); + if (self.point_map) |*map| { + map.map.append(map.alloc, .{ .x = x, .y = y }) catch return error.WriteFailed; + } + }, } } } @@ -1348,6 +1374,55 @@ pub const PageFormatter = struct { } } + /// Emit background color SGR sequence for bg_color_* content tags. + /// Updates the style tracking to reflect the emitted background. + fn emitBgColorSgr( + self: PageFormatter, + writer: *std.Io.Writer, + palette_index: ?u8, + rgb: ?Cell.RGB, + style: *Style, + ) std.Io.Writer.Error!void { + switch (self.opts.emit) { + .plain => {}, + .vt => { + // Close previous style if non-default + if (!style.default()) try writer.writeAll("\x1b[0m"); + // Emit background color + if (palette_index) |idx| { + try writer.print("\x1b[48;5;{d}m", .{idx}); + } else if (rgb) |c| { + try writer.print("\x1b[48;2;{d};{d};{d}m", .{ c.r, c.g, c.b }); + } + // Update style tracking - set bg_color so we know to reset later + style.* = .{}; + style.bg_color = if (palette_index) |idx| + .{ .palette = idx } + else if (rgb) |c| + .{ .rgb = .{ .r = c.r, .g = c.g, .b = c.b } } + else + .none; + }, + .html => { + // Close previous tag if needed + if (!style.default()) try writer.writeAll(""); + // Emit background color as inline style + if (palette_index) |idx| { + try writer.print("
", .{idx}); + } else if (rgb) |c| { + try writer.print("
2}{x:0>2}{x:0>2};\">", .{ c.r, c.g, c.b }); + } + style.* = .{}; + style.bg_color = if (palette_index) |idx| + .{ .palette = idx } + else if (rgb) |c| + .{ .rgb = .{ .r = c.r, .g = c.g, .b = c.b } } + else + .none; + }, + } + } + fn formatStyleOpen( self: PageFormatter, writer: *std.Io.Writer,