Feat/clipboard codepoint map (#9499)

Closes #8383
pull/9515/head
Mitchell Hashimoto 2025-11-07 15:01:41 -08:00 committed by GitHub
commit eb29274f6a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 716 additions and 2 deletions

View File

@ -260,6 +260,7 @@ const DerivedConfig = struct {
clipboard_trim_trailing_spaces: bool, clipboard_trim_trailing_spaces: bool,
clipboard_paste_protection: bool, clipboard_paste_protection: bool,
clipboard_paste_bracketed_safe: bool, clipboard_paste_bracketed_safe: bool,
clipboard_codepoint_map: configpkg.Config.RepeatableClipboardCodepointMap,
copy_on_select: configpkg.CopyOnSelect, copy_on_select: configpkg.CopyOnSelect,
right_click_action: configpkg.RightClickAction, right_click_action: configpkg.RightClickAction,
confirm_close_surface: configpkg.ConfirmCloseSurface, confirm_close_surface: configpkg.ConfirmCloseSurface,
@ -334,6 +335,7 @@ const DerivedConfig = struct {
.clipboard_trim_trailing_spaces = config.@"clipboard-trim-trailing-spaces", .clipboard_trim_trailing_spaces = config.@"clipboard-trim-trailing-spaces",
.clipboard_paste_protection = config.@"clipboard-paste-protection", .clipboard_paste_protection = config.@"clipboard-paste-protection",
.clipboard_paste_bracketed_safe = config.@"clipboard-paste-bracketed-safe", .clipboard_paste_bracketed_safe = config.@"clipboard-paste-bracketed-safe",
.clipboard_codepoint_map = try config.@"clipboard-codepoint-map".clone(alloc),
.copy_on_select = config.@"copy-on-select", .copy_on_select = config.@"copy-on-select",
.right_click_action = config.@"right-click-action", .right_click_action = config.@"right-click-action",
.confirm_close_surface = config.@"confirm-close-surface", .confirm_close_surface = config.@"confirm-close-surface",
@ -1971,6 +1973,7 @@ fn copySelectionToClipboards(
.emit = .plain, // We'll override this below .emit = .plain, // We'll override this below
.unwrap = true, .unwrap = true,
.trim = self.config.clipboard_trim_trailing_spaces, .trim = self.config.clipboard_trim_trailing_spaces,
.codepoint_map = self.config.clipboard_codepoint_map.map.list,
.background = self.io.terminal.colors.background.get(), .background = self.io.terminal.colors.background.get(),
.foreground = self.io.terminal.colors.foreground.get(), .foreground = self.io.terminal.colors.foreground.get(),
.palette = &self.io.terminal.colors.palette.current, .palette = &self.io.terminal.colors.palette.current,
@ -1998,6 +2001,9 @@ fn copySelectionToClipboards(
}); });
formatter.content = .{ .selection = sel }; formatter.content = .{ .selection = sel };
try formatter.format(&aw.writer); try formatter.format(&aw.writer);
// Note: We don't apply codepoint mappings to VT format since it contains
// escape sequences that should be preserved as-is
try contents.append(alloc, .{ try contents.append(alloc, .{
.mime = "text/plain", .mime = "text/plain",
.data = try aw.toOwnedSliceSentinel(0), .data = try aw.toOwnedSliceSentinel(0),
@ -2012,6 +2018,9 @@ fn copySelectionToClipboards(
}); });
formatter.content = .{ .selection = sel }; formatter.content = .{ .selection = sel };
try formatter.format(&aw.writer); try formatter.format(&aw.writer);
// Note: We don't apply codepoint mappings to HTML format since HTML
// has its own character encoding and entity system
try contents.append(alloc, .{ try contents.append(alloc, .{
.mime = "text/html", .mime = "text/html",
.data = try aw.toOwnedSliceSentinel(0), .data = try aw.toOwnedSliceSentinel(0),
@ -2019,6 +2028,7 @@ fn copySelectionToClipboards(
}, },
.mixed => { .mixed => {
// First, generate plain text with codepoint mappings applied
var formatter: ScreenFormatter = .init(&self.io.terminal.screen, opts); var formatter: ScreenFormatter = .init(&self.io.terminal.screen, opts);
formatter.content = .{ .selection = sel }; formatter.content = .{ .selection = sel };
try formatter.format(&aw.writer); try formatter.format(&aw.writer);
@ -2028,6 +2038,7 @@ fn copySelectionToClipboards(
}); });
assert(aw.written().len == 0); assert(aw.written().len == 0);
// Second, generate HTML without codepoint mappings
formatter = .init(&self.io.terminal.screen, opts: { formatter = .init(&self.io.terminal.screen, opts: {
var copy = opts; var copy = opts;
copy.emit = .html; copy.emit = .html;
@ -2042,6 +2053,8 @@ fn copySelectionToClipboards(
}); });
formatter.content = .{ .selection = sel }; formatter.content = .{ .selection = sel };
try formatter.format(&aw.writer); try formatter.format(&aw.writer);
// Note: We don't apply codepoint mappings to HTML format
try contents.append(alloc, .{ try contents.append(alloc, .{
.mime = "text/html", .mime = "text/html",
.data = try aw.toOwnedSliceSentinel(0), .data = try aw.toOwnedSliceSentinel(0),

View File

@ -0,0 +1,44 @@
/// ClipboardCodepointMap is a map of codepoints to replacement values
/// for clipboard operations. When copying text to clipboard, matching
/// codepoints will be replaced with their mapped values.
const ClipboardCodepointMap = @This();
const std = @import("std");
const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
// To ease our usage later, we map it directly to formatter entries.
pub const Entry = @import("../terminal/formatter.zig").CodepointMap;
pub const Replacement = Entry.Replacement;
/// The list of entries. We use a multiarraylist for cache-friendly lookups.
///
/// Note: we do a linear search because we expect to always have very
/// few entries, so the overhead of a binary search is not worth it.
list: std.MultiArrayList(Entry) = .{},
pub fn deinit(self: *ClipboardCodepointMap, alloc: Allocator) void {
self.list.deinit(alloc);
}
/// Deep copy of the struct. The given allocator is expected to
/// be an arena allocator of some sort since the struct itself
/// doesn't support fine-grained deallocation of fields.
pub fn clone(self: *const ClipboardCodepointMap, alloc: Allocator) !ClipboardCodepointMap {
var list = try self.list.clone(alloc);
for (list.items(.replacement)) |*r| switch (r.*) {
.string => |s| r.string = try alloc.dupe(u8, s),
.codepoint => {}, // no allocation needed
};
return .{ .list = list };
}
/// Add an entry to the map.
///
/// For conflicting codepoints, entries added later take priority over
/// entries added earlier.
pub fn add(self: *ClipboardCodepointMap, alloc: Allocator, entry: Entry) !void {
assert(entry.range[0] <= entry.range[1]);
try self.list.append(alloc, entry);
}

View File

@ -38,6 +38,7 @@ const RepeatableReadableIO = @import("io.zig").RepeatableReadableIO;
const RepeatableStringMap = @import("RepeatableStringMap.zig"); const RepeatableStringMap = @import("RepeatableStringMap.zig");
pub const Path = @import("path.zig").Path; pub const Path = @import("path.zig").Path;
pub const RepeatablePath = @import("path.zig").RepeatablePath; pub const RepeatablePath = @import("path.zig").RepeatablePath;
const ClipboardCodepointMap = @import("ClipboardCodepointMap.zig");
// We do this instead of importing all of terminal/main.zig to // We do this instead of importing all of terminal/main.zig to
// limit the dependency graph. This is important because some things // limit the dependency graph. This is important because some things
@ -279,6 +280,30 @@ pub const compatibility = std.StaticStringMap(
/// i.e. new windows, tabs, etc. /// i.e. new windows, tabs, etc.
@"font-codepoint-map": RepeatableCodepointMap = .{}, @"font-codepoint-map": RepeatableCodepointMap = .{},
/// Map specific Unicode codepoints to replacement values when copying text
/// to clipboard.
///
/// This configuration allows you to replace specific Unicode characters with
/// other characters or strings when copying terminal content to the clipboard.
/// This is useful for converting special terminal symbols to more compatible
/// characters for pasting into other applications.
///
/// The syntax is similar to `font-codepoint-map`:
/// - Single codepoint: `U+1234=U+ABCD` or `U+1234=replacement_text`
/// - Codepoint range: `U+1234-U+5678=U+ABCD`
///
/// Examples:
/// - `clipboard-codepoint-map = U+2500=U+002D` (box drawing horizontal hyphen)
/// - `clipboard-codepoint-map = U+2502=U+007C` (box drawing vertical pipe)
/// - `clipboard-codepoint-map = U+03A3=SUM` (Greek sigma "SUM")
///
/// This configuration can be repeated multiple times to specify multiple
/// mappings. Later entries take priority over earlier ones for overlapping
/// ranges.
///
/// Note: This only applies to text copying operations, not URL copying.
@"clipboard-codepoint-map": RepeatableClipboardCodepointMap = .{},
/// Draw fonts with a thicker stroke, if supported. /// Draw fonts with a thicker stroke, if supported.
/// This is currently only supported on macOS. /// This is currently only supported on macOS.
@"font-thicken": bool = false, @"font-thicken": bool = false,
@ -6868,6 +6893,193 @@ pub const RepeatableCodepointMap = struct {
} }
}; };
/// See "clipboard-codepoint-map" for documentation.
pub const RepeatableClipboardCodepointMap = struct {
const Self = @This();
map: ClipboardCodepointMap = .{},
pub fn parseCLI(self: *Self, alloc: Allocator, input_: ?[]const u8) !void {
const input = input_ orelse return error.ValueRequired;
const eql_idx = std.mem.indexOf(u8, input, "=") orelse return error.InvalidValue;
const whitespace = " \t";
const key = std.mem.trim(u8, input[0..eql_idx], whitespace);
const value = std.mem.trim(u8, input[eql_idx + 1 ..], whitespace);
// Parse the replacement value - either a codepoint or string
const replacement: ClipboardCodepointMap.Replacement = if (std.mem.startsWith(u8, value, "U+")) blk: {
// Parse as codepoint
const cp_str = value[2..]; // Skip "U+"
const cp = std.fmt.parseInt(u21, cp_str, 16) catch return error.InvalidValue;
break :blk .{ .codepoint = cp };
} else blk: {
// Parse as UTF-8 string - validate it's valid UTF-8
if (!std.unicode.utf8ValidateSlice(value)) return error.InvalidValue;
const value_copy = try alloc.dupe(u8, value);
break :blk .{ .string = value_copy };
};
var p: UnicodeRangeParser = .{ .input = key };
while (try p.next()) |range| {
try self.map.add(alloc, .{
.range = range,
.replacement = replacement,
});
}
}
/// Deep copy of the struct. Required by Config.
pub fn clone(self: *const Self, alloc: Allocator) Allocator.Error!Self {
return .{ .map = try self.map.clone(alloc) };
}
/// Compare if two of our value are equal. Required by Config.
pub fn equal(self: Self, other: Self) bool {
const itemsA = self.map.list.slice();
const itemsB = other.map.list.slice();
if (itemsA.len != itemsB.len) return false;
for (0..itemsA.len) |i| {
const a = itemsA.get(i);
const b = itemsB.get(i);
if (!std.meta.eql(a.range, b.range)) return false;
switch (a.replacement) {
.codepoint => |cp_a| switch (b.replacement) {
.codepoint => |cp_b| if (cp_a != cp_b) return false,
.string => return false,
},
.string => |str_a| switch (b.replacement) {
.string => |str_b| if (!std.mem.eql(u8, str_a, str_b)) return false,
.codepoint => return false,
},
}
}
return true;
}
/// Used by Formatter
pub fn formatEntry(
self: Self,
formatter: anytype,
) !void {
if (self.map.list.len == 0) {
try formatter.formatEntry(void, {});
return;
}
var buf: [1024]u8 = undefined;
var value_buf: [32]u8 = undefined;
const ranges = self.map.list.items(.range);
const replacements = self.map.list.items(.replacement);
for (ranges, replacements) |range, replacement| {
const value_str = switch (replacement) {
.codepoint => |cp| try std.fmt.bufPrint(&value_buf, "U+{X:0>4}", .{cp}),
.string => |s| s,
};
if (range[0] == range[1]) {
try formatter.formatEntry(
[]const u8,
std.fmt.bufPrint(
&buf,
"U+{X:0>4}={s}",
.{ range[0], value_str },
) catch return error.OutOfMemory,
);
} else {
try formatter.formatEntry(
[]const u8,
std.fmt.bufPrint(
&buf,
"U+{X:0>4}-U+{X:0>4}={s}",
.{ range[0], range[1], value_str },
) catch return error.OutOfMemory,
);
}
}
}
/// Reuse the same UnicodeRangeParser from RepeatableCodepointMap
const UnicodeRangeParser = RepeatableCodepointMap.UnicodeRangeParser;
test "parseCLI codepoint replacement" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var list: Self = .{};
try list.parseCLI(alloc, "U+2500=U+002D"); // box drawing hyphen
try testing.expectEqual(@as(usize, 1), list.map.list.len);
const entry = list.map.list.get(0);
try testing.expectEqual([2]u21{ 0x2500, 0x2500 }, entry.range);
try testing.expect(entry.replacement == .codepoint);
try testing.expectEqual(@as(u21, 0x002D), entry.replacement.codepoint);
}
test "parseCLI string replacement" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var list: Self = .{};
try list.parseCLI(alloc, "U+03A3=SUM"); // Greek sigma "SUM"
try testing.expectEqual(@as(usize, 1), list.map.list.len);
const entry = list.map.list.get(0);
try testing.expectEqual([2]u21{ 0x03A3, 0x03A3 }, entry.range);
try testing.expect(entry.replacement == .string);
try testing.expectEqualStrings("SUM", entry.replacement.string);
}
test "parseCLI range replacement" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var list: Self = .{};
try list.parseCLI(alloc, "U+2500-U+2503=|"); // box drawing range pipe
try testing.expectEqual(@as(usize, 1), list.map.list.len);
const entry = list.map.list.get(0);
try testing.expectEqual([2]u21{ 0x2500, 0x2503 }, entry.range);
try testing.expect(entry.replacement == .string);
try testing.expectEqualStrings("|", entry.replacement.string);
}
test "formatConfig codepoint" {
const testing = std.testing;
var buf: std.Io.Writer.Allocating = .init(testing.allocator);
defer buf.deinit();
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var list: Self = .{};
try list.parseCLI(alloc, "U+2500=U+002D");
try list.formatEntry(formatterpkg.entryFormatter("a", &buf.writer));
try std.testing.expectEqualSlices(u8, "a = U+2500=U+002D\n", buf.written());
}
test "formatConfig string" {
const testing = std.testing;
var buf: std.Io.Writer.Allocating = .init(testing.allocator);
defer buf.deinit();
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var list: Self = .{};
try list.parseCLI(alloc, "U+03A3=SUM");
try list.formatEntry(formatterpkg.entryFormatter("a", &buf.writer));
try std.testing.expectEqualSlices(u8, "a = U+03A3=SUM\n", buf.written());
}
};
pub const FontStyle = union(enum) { pub const FontStyle = union(enum) {
const Self = @This(); const Self = @This();

View File

@ -59,6 +59,24 @@ pub const Format = enum {
} }
}; };
pub const CodepointMap = struct {
/// Unicode codepoint range to replace.
/// Asserts: range[0] <= range[1]
range: [2]u21,
/// Replacement value for this range.
replacement: Replacement,
pub const Replacement = union(enum) {
/// A single replacement codepoint.
codepoint: u21,
/// A UTF-8 encoded string to replace with. Asserts the
/// UTF-8 encoding (must be valid).
string: []const u8,
};
};
/// Common encoding options regardless of what exact formatter is used. /// Common encoding options regardless of what exact formatter is used.
pub const Options = struct { pub const Options = struct {
/// The format to emit. /// The format to emit.
@ -74,6 +92,10 @@ pub const Options = struct {
/// is currently only space characters (0x20). /// is currently only space characters (0x20).
trim: bool = true, trim: bool = true,
/// Replace matching Unicode codepoints with some other values.
/// This will use the last matching range found in the list.
codepoint_map: ?std.MultiArrayList(CodepointMap) = .{},
/// Set a background and foreground color to use for the "screen". /// Set a background and foreground color to use for the "screen".
/// For styled formats, this will emit the proper sequences or styles. /// For styled formats, this will emit the proper sequences or styles.
background: ?color.RGB = null, background: ?color.RGB = null,
@ -1241,14 +1263,58 @@ pub const PageFormatter = struct {
writer: *std.Io.Writer, writer: *std.Io.Writer,
cell: *const Cell, cell: *const Cell,
) !void { ) !void {
try self.writeCodepoint(writer, cell.content.codepoint); try self.writeCodepointWithReplacement(writer, cell.content.codepoint);
if (comptime tag == .codepoint_grapheme) { if (comptime tag == .codepoint_grapheme) {
for (self.page.lookupGrapheme(cell).?) |cp| { for (self.page.lookupGrapheme(cell).?) |cp| {
try self.writeCodepoint(writer, cp); try self.writeCodepointWithReplacement(writer, cp);
} }
} }
} }
fn writeCodepointWithReplacement(
self: PageFormatter,
writer: *std.Io.Writer,
codepoint: u21,
) !void {
// Search for our replacement
const r_: ?CodepointMap.Replacement = replacement: {
const map = self.opts.codepoint_map orelse break :replacement null;
const items = map.items(.range);
for (0..items.len) |forward_i| {
const i = items.len - forward_i - 1;
const range = items[i];
if (range[0] <= codepoint and codepoint <= range[1]) {
const replacements = map.items(.replacement);
break :replacement replacements[i];
}
}
break :replacement null;
};
// If no replacement, write it directly.
const r = r_ orelse return try self.writeCodepoint(
writer,
codepoint,
);
switch (r) {
.codepoint => |v| try self.writeCodepoint(
writer,
v,
),
.string => |s| {
const view = std.unicode.Utf8View.init(s) catch unreachable;
var it = view.iterator();
while (it.nextCodepoint()) |cp| try self.writeCodepoint(
writer,
cp,
);
},
}
}
fn writeCodepoint( fn writeCodepoint(
self: PageFormatter, self: PageFormatter,
writer: *std.Io.Writer, writer: *std.Io.Writer,
@ -5302,3 +5368,382 @@ test "Page VT style reset properly closes styles" {
// The reset should properly close the bold style // The reset should properly close the bold style
try testing.expectEqualStrings("\x1b[0m\x1b[1mbold\x1b[0mnormal", output); try testing.expectEqualStrings("\x1b[0m\x1b[1mbold\x1b[0mnormal", output);
} }
test "Page codepoint_map single replacement" {
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;
// Replace 'o' with 'x'
var map: std.MultiArrayList(CodepointMap) = .{};
defer map.deinit(alloc);
try map.append(alloc, .{
.range = .{ 'o', 'o' },
.replacement = .{ .codepoint = 'x' },
});
var opts: Options = .plain;
opts.codepoint_map = map;
var formatter: PageFormatter = .init(page, opts);
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("hellx wxrld", output);
// Verify point map - each output byte should map to original cell position
try testing.expectEqual(output.len, point_map.items.len);
// "hello world" -> "hellx wxrld"
// h e l l o w o r l d
// 0 1 2 3 4 5 6 7 8 9 10
try testing.expectEqual(Coordinate{ .x = 0, .y = 0 }, point_map.items[0]); // h
try testing.expectEqual(Coordinate{ .x = 1, .y = 0 }, point_map.items[1]); // e
try testing.expectEqual(Coordinate{ .x = 2, .y = 0 }, point_map.items[2]); // l
try testing.expectEqual(Coordinate{ .x = 3, .y = 0 }, point_map.items[3]); // l
try testing.expectEqual(Coordinate{ .x = 4, .y = 0 }, point_map.items[4]); // x (was o)
try testing.expectEqual(Coordinate{ .x = 5, .y = 0 }, point_map.items[5]); // space
try testing.expectEqual(Coordinate{ .x = 6, .y = 0 }, point_map.items[6]); // w
try testing.expectEqual(Coordinate{ .x = 7, .y = 0 }, point_map.items[7]); // x (was o)
try testing.expectEqual(Coordinate{ .x = 8, .y = 0 }, point_map.items[8]); // r
try testing.expectEqual(Coordinate{ .x = 9, .y = 0 }, point_map.items[9]); // l
try testing.expectEqual(Coordinate{ .x = 10, .y = 0 }, point_map.items[10]); // d
}
test "Page codepoint_map conflicting replacement prefers last" {
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");
const pages = &t.screen.pages;
const page = &pages.pages.last.?.data;
// Replace 'o' with 'x', then with 'y' - should prefer last
var map: std.MultiArrayList(CodepointMap) = .{};
defer map.deinit(alloc);
try map.append(alloc, .{
.range = .{ 'o', 'o' },
.replacement = .{ .codepoint = 'x' },
});
try map.append(alloc, .{
.range = .{ 'o', 'o' },
.replacement = .{ .codepoint = 'y' },
});
var opts: Options = .plain;
opts.codepoint_map = map;
var formatter: PageFormatter = .init(page, opts);
try formatter.format(&builder.writer);
const output = builder.writer.buffered();
try testing.expectEqualStrings("helly", output);
}
test "Page codepoint_map replace with string" {
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");
const pages = &t.screen.pages;
const page = &pages.pages.last.?.data;
// Replace 'o' with a multi-byte string
var map: std.MultiArrayList(CodepointMap) = .{};
defer map.deinit(alloc);
try map.append(alloc, .{
.range = .{ 'o', 'o' },
.replacement = .{ .string = "XYZ" },
});
var opts: Options = .plain;
opts.codepoint_map = map;
var formatter: PageFormatter = .init(page, opts);
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("hellXYZ", output);
// Verify point map - string replacements should all map to the original cell
try testing.expectEqual(output.len, point_map.items.len);
// "hello" -> "hellXYZ"
// h e l l o
// 0 1 2 3 4
try testing.expectEqual(Coordinate{ .x = 0, .y = 0 }, point_map.items[0]); // h
try testing.expectEqual(Coordinate{ .x = 1, .y = 0 }, point_map.items[1]); // e
try testing.expectEqual(Coordinate{ .x = 2, .y = 0 }, point_map.items[2]); // l
try testing.expectEqual(Coordinate{ .x = 3, .y = 0 }, point_map.items[3]); // l
// All bytes of the replacement string "XYZ" should point to position 4 (where 'o' was)
try testing.expectEqual(Coordinate{ .x = 4, .y = 0 }, point_map.items[4]); // X
try testing.expectEqual(Coordinate{ .x = 4, .y = 0 }, point_map.items[5]); // Y
try testing.expectEqual(Coordinate{ .x = 4, .y = 0 }, point_map.items[6]); // Z
}
test "Page codepoint_map range replacement" {
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("abcdefg");
const pages = &t.screen.pages;
const page = &pages.pages.last.?.data;
// Replace 'b' through 'e' with 'X'
var map: std.MultiArrayList(CodepointMap) = .{};
defer map.deinit(alloc);
try map.append(alloc, .{
.range = .{ 'b', 'e' },
.replacement = .{ .codepoint = 'X' },
});
var opts: Options = .plain;
opts.codepoint_map = map;
var formatter: PageFormatter = .init(page, opts);
try formatter.format(&builder.writer);
const output = builder.writer.buffered();
try testing.expectEqualStrings("aXXXXfg", output);
}
test "Page codepoint_map multiple ranges" {
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;
// Replace 'a'-'m' with 'A' and 'n'-'z' with 'Z'
var map: std.MultiArrayList(CodepointMap) = .{};
defer map.deinit(alloc);
try map.append(alloc, .{
.range = .{ 'a', 'm' },
.replacement = .{ .codepoint = 'A' },
});
try map.append(alloc, .{
.range = .{ 'n', 'z' },
.replacement = .{ .codepoint = 'Z' },
});
var opts: Options = .plain;
opts.codepoint_map = map;
var formatter: PageFormatter = .init(page, opts);
try formatter.format(&builder.writer);
const output = builder.writer.buffered();
// h e l l o w o r l d
// A A A A Z Z Z Z A A
try testing.expectEqualStrings("AAAAZ ZZZAA", output);
}
test "Page codepoint_map unicode replacement" {
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;
// Replace lightning bolt with fire emoji
var map: std.MultiArrayList(CodepointMap) = .{};
defer map.deinit(alloc);
try map.append(alloc, .{
.range = .{ '⚡', '⚡' },
.replacement = .{ .string = "🔥" },
});
var opts: Options = .plain;
opts.codepoint_map = map;
var formatter: PageFormatter = .init(page, opts);
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("hello 🔥 world", output);
// Verify point map
try testing.expectEqual(output.len, point_map.items.len);
// "hello ⚡ world"
// h e l l o w o r l d
// 0 1 2 3 4 5 6 8 9 10 11 12
// Note: is a wide character occupying cells 6-7
for (0..6) |i| try testing.expectEqual(
Coordinate{ .x = @intCast(i), .y = 0 },
point_map.items[i],
);
// 🔥 is 4 UTF-8 bytes, all should map to cell 6 (where was)
const fire_start = 6; // "hello " is 6 bytes
for (0..4) |i| try testing.expectEqual(
Coordinate{ .x = 6, .y = 0 },
point_map.items[fire_start + i],
);
// " world" follows
const world_start = fire_start + 4;
for (0..6) |i| try testing.expectEqual(
Coordinate{ .x = @intCast(8 + i), .y = 0 },
point_map.items[world_start + i],
);
}
test "Page codepoint_map with styled formats" {
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 = 10,
.rows = 24,
});
defer t.deinit(alloc);
var s = t.vtStream();
defer s.deinit();
try s.nextSlice("\x1b[31mred text\x1b[0m");
const pages = &t.screen.pages;
const page = &pages.pages.last.?.data;
// Replace 'e' with 'X' in styled text
var map: std.MultiArrayList(CodepointMap) = .{};
defer map.deinit(alloc);
try map.append(alloc, .{
.range = .{ 'e', 'e' },
.replacement = .{ .codepoint = 'X' },
});
var opts: Options = .vt;
opts.codepoint_map = map;
var formatter: PageFormatter = .init(page, opts);
try formatter.format(&builder.writer);
const output = builder.writer.buffered();
// Should preserve styles while replacing text
// "red text" becomes "rXd tXxt"
// VT format uses \x1b[38;5;1m for palette color 1
try testing.expectEqualStrings("\x1b[0m\x1b[38;5;1mrXd tXxt\x1b[0m", output);
}
test "Page codepoint_map empty map" {
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;
// Empty map should not change anything
var map: std.MultiArrayList(CodepointMap) = .{};
defer map.deinit(alloc);
var opts: Options = .plain;
opts.codepoint_map = map;
var formatter: PageFormatter = .init(page, opts);
try formatter.format(&builder.writer);
const output = builder.writer.buffered();
try testing.expectEqualStrings("hello world", output);
}