feat: add clipboard-codepoint-map configuration parsing
parent
0d26bace25
commit
a162fa8f55
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue