feat: add clipboard-codepoint-map configuration parsing

pull/9499/head
benodiwal 2025-11-06 13:13:32 +05:30 committed by Mitchell Hashimoto
parent 0d26bace25
commit a162fa8f55
2 changed files with 355 additions and 0 deletions

View File

@ -0,0 +1,143 @@
/// 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;
pub const Replacement = union(enum) {
/// Replace with a single codepoint
codepoint: u21,
/// Replace with a UTF-8 string
string: []const u8,
};
pub const Entry = struct {
/// Unicode codepoint range. Asserts range[0] <= range[1].
range: [2]u21,
/// The replacement value for this range.
replacement: 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);
}
/// Get a replacement for a codepoint.
pub fn get(self: *const ClipboardCodepointMap, cp: u21) ?Replacement {
const items = self.list.items(.range);
for (0..items.len) |forward_i| {
const i = items.len - forward_i - 1;
const range = items[i];
if (range[0] <= cp and cp <= range[1]) {
const replacements = self.list.items(.replacement);
return replacements[i];
}
}
return null;
}
/// Hash with the given hasher.
pub fn hash(self: *const ClipboardCodepointMap, hasher: anytype) void {
const autoHash = std.hash.autoHash;
autoHash(hasher, self.list.len);
const slice = self.list.slice();
for (0..slice.len) |i| {
const entry = slice.get(i);
autoHash(hasher, entry.range);
switch (entry.replacement) {
.codepoint => |cp| autoHash(hasher, cp),
.string => |s| autoHash(hasher, s),
}
}
}
/// Returns a hash code that can be used to uniquely identify this
/// action.
pub fn hashcode(self: *const ClipboardCodepointMap) u64 {
var hasher = std.hash.Wyhash.init(0);
self.hash(&hasher);
return hasher.final();
}
test "clipboard codepoint map" {
const testing = std.testing;
const alloc = testing.allocator;
var m: ClipboardCodepointMap = .{};
defer m.deinit(alloc);
// Test no matches initially
try testing.expect(m.get(1) == null);
// Add exact range with codepoint replacement
try m.add(alloc, .{
.range = .{ 1, 1 },
.replacement = .{ .codepoint = 65 }, // 'A'
});
{
const replacement = m.get(1).?;
try testing.expect(replacement == .codepoint);
try testing.expectEqual(@as(u21, 65), replacement.codepoint);
}
// Later entry takes priority
try m.add(alloc, .{
.range = .{ 1, 2 },
.replacement = .{ .string = "B" },
});
{
const replacement = m.get(1).?;
try testing.expect(replacement == .string);
try testing.expectEqualStrings("B", replacement.string);
}
// Non-matching
try testing.expect(m.get(0) == null);
try testing.expect(m.get(3) == null);
// Test range matching
try m.add(alloc, .{
.range = .{ 3, 5 },
.replacement = .{ .string = "range" },
});
{
const replacement = m.get(4).?;
try testing.expectEqualStrings("range", replacement.string);
}
try testing.expect(m.get(6) == null);
}

View File

@ -38,6 +38,7 @@ const RepeatableReadableIO = @import("io.zig").RepeatableReadableIO;
const RepeatableStringMap = @import("RepeatableStringMap.zig");
pub const Path = @import("path.zig").Path;
pub const RepeatablePath = @import("path.zig").RepeatablePath;
const ClipboardCodepointMap = @import("ClipboardCodepointMap.zig");
// We do this instead of importing all of terminal/main.zig to
// 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.
@"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.
/// This is currently only supported on macOS.
@"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) {
const Self = @This();