Skip to content

Commit 8ff32c8

Browse files
committed
Merge branch 'opentuah' of https://github.com/remorses/opentuah into opentuah
2 parents 8c7ce01 + 446d7f6 commit 8ff32c8

File tree

6 files changed

+259
-19
lines changed

6 files changed

+259
-19
lines changed

packages/core/src/zig/buffer.zig

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -453,18 +453,21 @@ pub const OptimizedBuffer = struct {
453453

454454
const span_start = index - @min(left, index - row_start);
455455
const span_end = index + @min(right, row_end - index);
456-
const span_len = span_end - span_start + 1;
457456

458457
var span_i: u32 = span_start;
459-
while (span_i < span_start + span_len) : (span_i += 1) {
458+
while (span_i <= span_end) : (span_i += 1) {
459+
const span_char = self.buffer.char[span_i];
460+
if (!(gp.isGraphemeChar(span_char) or gp.isContinuationChar(span_char))) continue;
461+
if (gp.graphemeIdFromChar(span_char) != id) continue;
462+
460463
const span_link_id = ansi.TextAttributes.getLinkId(self.buffer.attributes[span_i]);
461464
if (span_link_id != 0) {
462465
self.link_tracker.removeCellRef(span_link_id);
463466
}
464-
}
465467

466-
@memset(self.buffer.char[span_start .. span_start + span_len], @intCast(DEFAULT_SPACE_CHAR));
467-
@memset(self.buffer.attributes[span_start .. span_start + span_len], 0);
468+
self.buffer.char[span_i] = @intCast(DEFAULT_SPACE_CHAR);
469+
self.buffer.attributes[span_i] = 0;
470+
}
468471
}
469472

470473
if (gp.isGraphemeChar(cell.char)) {

packages/core/src/zig/grapheme.zig

Lines changed: 107 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ pub const GraphemePool = struct {
4949

5050
allocator: std.mem.Allocator,
5151
classes: [MAX_CLASSES]ClassPool,
52+
interned_live_ids: std.StringHashMapUnmanaged(IdPayload),
5253

5354
const SlotHeader = extern struct {
5455
len: u16,
@@ -68,16 +69,81 @@ pub const GraphemePool = struct {
6869
while (i < MAX_CLASSES) : (i += 1) {
6970
classes[i] = ClassPool.init(allocator, CLASS_SIZES[i], slots_per_page[i]);
7071
}
71-
return .{ .allocator = allocator, .classes = classes };
72+
return .{ .allocator = allocator, .classes = classes, .interned_live_ids = .{} };
7273
}
7374

7475
pub fn deinit(self: *GraphemePool) void {
76+
var key_it = self.interned_live_ids.keyIterator();
77+
while (key_it.next()) |key_ptr| {
78+
self.allocator.free(@constCast(key_ptr.*));
79+
}
80+
self.interned_live_ids.deinit(self.allocator);
81+
7582
var i: usize = 0;
7683
while (i < MAX_CLASSES) : (i += 1) {
7784
self.classes[i].deinit();
7885
}
7986
}
8087

88+
/// removeInternedLiveId removes an interned ID from the live set if it
89+
/// matches the expected ID.
90+
fn removeInternedLiveId(self: *GraphemePool, bytes: []const u8, expected_id: IdPayload) void {
91+
const live_id = self.interned_live_ids.get(bytes) orelse return;
92+
if (live_id != expected_id) return;
93+
if (self.interned_live_ids.fetchRemove(bytes)) |removed| {
94+
self.allocator.free(@constCast(removed.key));
95+
}
96+
}
97+
98+
/// lookupOrInvalidate checks if the given bytes are already interned and live, returning the existing ID if so.
99+
fn lookupOrInvalidate(self: *GraphemePool, bytes: []const u8) ?IdPayload {
100+
const live_id = self.interned_live_ids.get(bytes) orelse return null;
101+
102+
// Verify that the live ID is still valid and matches the bytes. If get
103+
// fails, the ID is no longer valid, so remove it from the interned map.
104+
const live_bytes = self.get(live_id) catch {
105+
self.removeInternedLiveId(bytes, live_id);
106+
return null;
107+
};
108+
109+
// If the bytes don't match, this means the ID was recycled and now points
110+
// to different data. Invalidate the interned ID.
111+
if (!std.mem.eql(u8, live_bytes, bytes)) {
112+
self.removeInternedLiveId(bytes, live_id);
113+
return null;
114+
}
115+
116+
// check refcount > 0 to ensure the ID is still live. If refcount is 0,
117+
// the slot is free but hasn't been reused yet, so we can treat it as
118+
// not found.
119+
const live_refcount = self.getRefcount(live_id) catch {
120+
self.removeInternedLiveId(bytes, live_id);
121+
return null;
122+
};
123+
if (live_refcount == 0) {
124+
self.removeInternedLiveId(bytes, live_id);
125+
return null;
126+
}
127+
128+
return live_id;
129+
}
130+
131+
/// internLiveId interns the grapheme bytes.
132+
fn internLiveId(self: *GraphemePool, id: IdPayload, bytes: []const u8) GraphemePoolError!void {
133+
if (self.lookupOrInvalidate(bytes) != null) {
134+
// Keep existing interned ID if it's still valid.
135+
return;
136+
}
137+
138+
const owned_key = self.allocator.dupe(u8, bytes) catch return GraphemePoolError.OutOfMemory;
139+
errdefer self.allocator.free(owned_key);
140+
141+
if (self.interned_live_ids.fetchPut(self.allocator, owned_key, id) catch return GraphemePoolError.OutOfMemory) |replaced| {
142+
// A previous key allocation was replaced.
143+
self.allocator.free(@constCast(replaced.key));
144+
}
145+
}
146+
81147
fn classForSize(size: usize) u32 {
82148
if (size <= 8) return 0;
83149
if (size <= 16) return 1;
@@ -94,6 +160,10 @@ pub const GraphemePool = struct {
94160
}
95161

96162
pub fn alloc(self: *GraphemePool, bytes: []const u8) GraphemePoolError!IdPayload {
163+
if (self.lookupOrInvalidate(bytes)) |live_id| {
164+
return live_id;
165+
}
166+
97167
const class_id: u32 = classForSize(bytes.len);
98168
const slot_index = try self.classes[class_id].allocInternal(bytes, true);
99169
const generation = self.classes[class_id].getGeneration(slot_index);
@@ -116,14 +186,35 @@ pub const GraphemePool = struct {
116186
if (class_id >= MAX_CLASSES) return GraphemePoolError.InvalidId;
117187
const slot_index: u32 = id & SLOT_MASK;
118188
const generation: u32 = (id >> SLOT_BITS) & GENERATION_MASK;
189+
const old_refcount = try self.classes[class_id].getRefcount(slot_index, generation);
119190
try self.classes[class_id].incref(slot_index, generation);
191+
192+
if (old_refcount == 0) {
193+
const is_owned = try self.classes[class_id].isOwned(slot_index, generation);
194+
if (is_owned) {
195+
// This is a transition from 0 to 1 for owned bytes, so intern it.
196+
const bytes = try self.classes[class_id].get(slot_index, generation);
197+
try self.internLiveId(id, bytes);
198+
}
199+
}
120200
}
121201

122202
pub fn decref(self: *GraphemePool, id: IdPayload) GraphemePoolError!void {
123203
const class_id: u32 = (id >> (GENERATION_BITS + SLOT_BITS)) & CLASS_MASK;
124204
if (class_id >= MAX_CLASSES) return GraphemePoolError.InvalidId;
125205
const slot_index: u32 = id & SLOT_MASK;
126206
const generation: u32 = (id >> SLOT_BITS) & GENERATION_MASK;
207+
208+
const old_refcount = try self.classes[class_id].getRefcount(slot_index, generation);
209+
if (old_refcount == 1) {
210+
const is_owned = try self.classes[class_id].isOwned(slot_index, generation);
211+
if (is_owned) {
212+
// This is a transition from 1 to 0 for owned bytes, remove map entry.
213+
const bytes = try self.classes[class_id].get(slot_index, generation);
214+
self.removeInternedLiveId(bytes, id);
215+
}
216+
}
217+
127218
try self.classes[class_id].decref(slot_index, generation);
128219
}
129220

@@ -135,6 +226,13 @@ pub const GraphemePool = struct {
135226
if (class_id >= MAX_CLASSES) return GraphemePoolError.InvalidId;
136227
const slot_index: u32 = id & SLOT_MASK;
137228
const generation: u32 = (id >> SLOT_BITS) & GENERATION_MASK;
229+
230+
const is_owned = try self.classes[class_id].isOwned(slot_index, generation);
231+
if (is_owned) {
232+
const bytes = try self.classes[class_id].get(slot_index, generation);
233+
self.removeInternedLiveId(bytes, id);
234+
}
235+
138236
try self.classes[class_id].freeUnreferenced(slot_index, generation);
139237
}
140238

@@ -313,6 +411,14 @@ pub const GraphemePool = struct {
313411
if (header_ptr.generation != expected_generation) return GraphemePoolError.WrongGeneration;
314412
return header_ptr.refcount;
315413
}
414+
415+
pub fn isOwned(self: *ClassPool, slot_index: u32, expected_generation: u32) GraphemePoolError!bool {
416+
if (slot_index >= self.num_slots) return GraphemePoolError.InvalidId;
417+
const p = self.slotPtr(slot_index);
418+
const header_ptr = @as(*SlotHeader, @ptrCast(@alignCast(p)));
419+
if (header_ptr.generation != expected_generation) return GraphemePoolError.WrongGeneration;
420+
return header_ptr.is_owned == 1;
421+
}
316422
};
317423
};
318424

packages/core/src/zig/renderer.zig

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -740,19 +740,9 @@ pub const CliRenderer = struct {
740740
}
741741
runLength += 1;
742742

743-
// Update the current buffer with the new cell
744-
self.currentRenderBuffer.setRaw(x, y, nextCell.?);
745-
746-
// If this is a grapheme start, also update all continuation cells
747-
if (gp.isGraphemeChar(nextCell.?.char)) {
748-
const rightExtent = gp.charRightExtent(nextCell.?.char);
749-
var k: u32 = 1;
750-
while (k <= rightExtent and x + k < self.width) : (k += 1) {
751-
if (self.nextRenderBuffer.get(x + k, y)) |contCell| {
752-
self.currentRenderBuffer.setRaw(x + k, y, contCell);
753-
}
754-
}
755-
}
743+
// Update grapheme/link trackers (and continuation cells), so current buffer
744+
// retains grapheme ownership after next buffer clear and IDs remain stable.
745+
self.currentRenderBuffer.set(x, y, nextCell.?);
756746

757747
cellsUpdated += 1;
758748
}

packages/core/src/zig/tests/buffer_test.zig

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1016,6 +1016,50 @@ test "OptimizedBuffer - renderer two-buffer swap pattern should not leak" {
10161016
}
10171017
}
10181018

1019+
test "OptimizedBuffer - set should not clear newly written adjacent grapheme continuation" {
1020+
var local_pool = gp.GraphemePool.initWithOptions(std.testing.allocator, .{});
1021+
defer local_pool.deinit();
1022+
1023+
var buf = try OptimizedBuffer.init(
1024+
std.testing.allocator,
1025+
8,
1026+
1,
1027+
.{ .pool = &local_pool, .id = "set-adjacent-grapheme" },
1028+
);
1029+
defer buf.deinit();
1030+
1031+
const bg = RGBA{ 0.0, 0.0, 0.0, 1.0 };
1032+
const fg = RGBA{ 1.0, 1.0, 1.0, 1.0 };
1033+
try buf.clear(bg, null);
1034+
1035+
const old_gid = try local_pool.alloc("🌟");
1036+
const old_start = gp.packGraphemeStart(old_gid & gp.GRAPHEME_ID_MASK, 2);
1037+
buf.set(3, 0, .{ .char = old_start, .fg = fg, .bg = bg, .attributes = 0 });
1038+
1039+
const new_gid = try local_pool.alloc("🔥");
1040+
const new_start = gp.packGraphemeStart(new_gid & gp.GRAPHEME_ID_MASK, 2);
1041+
1042+
// Simulate renderer's left-to-right in-place update:
1043+
// - x=2 writes a new grapheme (which writes continuation at x=3)
1044+
// - x=3 would be skipped by char-equality
1045+
// - x=4 overwrites an old continuation from the previous frame
1046+
// The overwrite at x=4 must not clear the new continuation at x=3.
1047+
buf.set(2, 0, .{ .char = new_start, .fg = fg, .bg = bg, .attributes = 0 });
1048+
buf.set(4, 0, .{ .char = ' ', .fg = fg, .bg = bg, .attributes = 0 });
1049+
1050+
const c2 = buf.get(2, 0).?;
1051+
const c3 = buf.get(3, 0).?;
1052+
const c4 = buf.get(4, 0).?;
1053+
1054+
try std.testing.expect(gp.isGraphemeChar(c2.char));
1055+
try std.testing.expect(gp.graphemeIdFromChar(c2.char) == (new_gid & gp.GRAPHEME_ID_MASK));
1056+
1057+
try std.testing.expect(gp.isContinuationChar(c3.char));
1058+
try std.testing.expect(gp.graphemeIdFromChar(c3.char) == (new_gid & gp.GRAPHEME_ID_MASK));
1059+
1060+
try std.testing.expect(c4.char == ' ');
1061+
}
1062+
10191063
test "OptimizedBuffer - sustained rendering should not leak" {
10201064
const tiny_slots = [_]u32{ 2, 2, 2, 2, 2 };
10211065
var local_pool = gp.GraphemePool.initWithOptions(std.testing.allocator, .{

packages/core/src/zig/tests/grapheme_test.zig

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1037,6 +1037,27 @@ test "GraphemePool - allocUnowned large text" {
10371037
try pool.decref(id);
10381038
}
10391039

1040+
test "GraphemePool - alloc does not reuse unowned IDs" {
1041+
var pool = GraphemePool.init(std.testing.allocator);
1042+
defer pool.deinit();
1043+
1044+
const external_text = "shared";
1045+
1046+
const unowned_id = try pool.allocUnowned(external_text);
1047+
try pool.incref(unowned_id);
1048+
defer pool.decref(unowned_id) catch {};
1049+
1050+
const owned_id = try pool.alloc(external_text);
1051+
try pool.incref(owned_id);
1052+
defer pool.decref(owned_id) catch {};
1053+
1054+
try std.testing.expect(owned_id != unowned_id);
1055+
1056+
const owned_bytes = try pool.get(owned_id);
1057+
try std.testing.expectEqualSlices(u8, external_text, owned_bytes);
1058+
try std.testing.expect(@intFromPtr(owned_bytes.ptr) != @intFromPtr(external_text.ptr));
1059+
}
1060+
10401061
test "GraphemeTracker - with unowned allocations" {
10411062
var pool = GraphemePool.init(std.testing.allocator);
10421063
defer pool.deinit();
@@ -1141,6 +1162,35 @@ test "GraphemePool - initWithOptions with small slots_per_page" {
11411162
try pool.decref(id2);
11421163
}
11431164

1165+
test "GraphemePool - alloc reuses live ID for same bytes" {
1166+
const tiny_slots = [_]u32{ 1, 1, 1, 1, 1 };
1167+
var pool = gp.GraphemePool.initWithOptions(std.testing.allocator, .{
1168+
.slots_per_page = tiny_slots,
1169+
});
1170+
defer pool.deinit();
1171+
1172+
const grapheme = "👋";
1173+
1174+
const id1 = try pool.alloc(grapheme);
1175+
try pool.incref(id1);
1176+
1177+
const id2 = try pool.alloc(grapheme);
1178+
try std.testing.expectEqual(id1, id2);
1179+
try std.testing.expectEqual(@as(u32, 1), try pool.getRefcount(id1));
1180+
1181+
try pool.decref(id1);
1182+
1183+
const id3 = try pool.alloc(grapheme);
1184+
try pool.incref(id3);
1185+
defer pool.decref(id3) catch @panic("Failed to decref id3");
1186+
1187+
try std.testing.expect(id3 != id1);
1188+
try std.testing.expectEqualSlices(u8, grapheme, try pool.get(id3));
1189+
1190+
const id4 = try pool.alloc(grapheme);
1191+
try std.testing.expectEqual(id3, id4);
1192+
}
1193+
11441194
test "GraphemePool - small pool exhaustion and growth" {
11451195
// Create a tiny pool that will need to grow
11461196
const tiny_slots = [_]u32{ 1, 1, 1, 1, 1 }; // Only 1 slot per page initially

packages/core/src/zig/tests/renderer_test.zig

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -571,6 +571,53 @@ test "renderer - grapheme pool refcounting with frame buffer fast path" {
571571
try std.testing.expect(rendered_cell != null);
572572
}
573573

574+
test "renderer - unchanged grapheme should not churn IDs across frames" {
575+
const pool = gp.initGlobalPool(std.testing.allocator);
576+
defer gp.deinitGlobalPool();
577+
578+
var cli_renderer = try CliRenderer.create(
579+
std.testing.allocator,
580+
4,
581+
1,
582+
pool,
583+
true,
584+
);
585+
defer cli_renderer.destroy();
586+
587+
const fg = RGBA{ 1.0, 1.0, 1.0, 1.0 };
588+
const bg = RGBA{ 0.0, 0.0, 0.0, 1.0 };
589+
590+
const first_next_buffer = cli_renderer.getNextBuffer();
591+
try first_next_buffer.drawText("👋", 0, 0, fg, bg, 0);
592+
cli_renderer.render(false);
593+
594+
const first_output = cli_renderer.getLastOutputForTest();
595+
try std.testing.expect(std.mem.indexOf(u8, first_output, "👋") != null);
596+
597+
const current_buffer = cli_renderer.getCurrentBuffer();
598+
const first_cell = current_buffer.get(0, 0);
599+
try std.testing.expect(first_cell != null);
600+
try std.testing.expect(gp.isGraphemeChar(first_cell.?.char));
601+
const first_gid = gp.graphemeIdFromChar(first_cell.?.char);
602+
603+
const second_next_buffer = cli_renderer.getNextBuffer();
604+
try second_next_buffer.drawText("👋", 0, 0, fg, bg, 0);
605+
606+
const second_cell = second_next_buffer.get(0, 0);
607+
try std.testing.expect(second_cell != null);
608+
try std.testing.expect(gp.isGraphemeChar(second_cell.?.char));
609+
const second_gid = gp.graphemeIdFromChar(second_cell.?.char);
610+
611+
// Same grapheme content in consecutive frames should keep a stable ID,
612+
// otherwise diff/write treats unchanged cells as modified every frame.
613+
try std.testing.expectEqual(first_gid, second_gid);
614+
615+
cli_renderer.render(false);
616+
617+
const second_output = cli_renderer.getLastOutputForTest();
618+
try std.testing.expect(std.mem.indexOf(u8, second_output, "👋") == null);
619+
}
620+
574621
test "renderer - hyperlinks enabled with OSC 8 output" {
575622
const pool = gp.initGlobalPool(std.testing.allocator);
576623
defer gp.deinitGlobalPool();

0 commit comments

Comments
 (0)