terminal: add ScreenFormatter.pin_offsets

When you want to know where specific Pins land in formatter output but
don't need the full per-byte Pin array, pin_offsets is a lighter
alternative to pin_map. The caller supplies a list of Pins to watch and
the formatter fills in the byte offset where each one first appears in
the emitted output.

Internally, PageListFormatter scans the per-chunk Coordinate scratch
buffer it already builds for pin_map consumers, so PageFormatter itself
is unchanged. pin_map and pin_offsets can be set independently or
together; when neither is set, no scratch buffer is allocated.

A future caller that needs only a handful of positions can avoid the
cumulative pin_map allocation. For a 10MB scrollback that's ~160MB of
transient Pin storage traded for ~360KB of per-chunk scratch.
pull/12882/head
Jon Parise 2026-05-31 15:46:48 -04:00
parent 16f2fdc90c
commit 1d5272b464
1 changed files with 179 additions and 18 deletions

View File

@ -124,6 +124,27 @@ pub const PinMap = struct {
map: *std.ArrayList(Pin),
};
/// Records the byte offset where each watched Pin first appears in the
/// emitted output. The `offset` field of each `PinOffset` is filled in
/// by the formatter; entries left as null mean that Pin was not
/// emitted.
///
/// The formatter still allocates a per-chunk scratch buffer of source
/// coordinates (one entry per emitted byte, reused across chunks via
/// `clearRetainingCapacity`), which is why an allocator is required.
/// Peak memory is bounded by a single chunk's emitted-byte count
/// dramatically less than `PinMap`'s cumulative per-byte Pin array,
/// but not zero.
pub const PinOffsets = struct {
alloc: Allocator,
entries: []PinOffset,
};
pub const PinOffset = struct {
pin: Pin,
offset: ?usize = null,
};
/// Terminal formatter formats the active terminal screen.
///
/// This will always only emit data related to the currently active screen.
@ -447,6 +468,11 @@ pub const ScreenFormatter = struct {
/// Warning: there is a significant performance hit to track this
pin_map: ?PinMap,
/// If non-null, fills in `offsets` with the byte position where each
/// `pins` entry first appears in the emitted output. Lighter than
/// `pin_map` when you only need a handful of positions.
pin_offsets: ?PinOffsets,
pub const Content = union(enum) {
/// Emit no content, only terminal state such as modes, palette, etc.
/// via extra.
@ -528,6 +554,7 @@ pub const ScreenFormatter = struct {
.content = .{ .selection = null },
.extra = .none,
.pin_map = null,
.pin_offsets = null,
};
}
@ -542,6 +569,7 @@ pub const ScreenFormatter = struct {
// Emit our pagelist contents according to our selection.
var list_formatter: PageListFormatter = .init(&self.screen.pages, self.opts);
list_formatter.pin_map = self.pin_map;
list_formatter.pin_offsets = self.pin_offsets;
if (selection_) |sel| {
list_formatter.top_left = sel.topLeft(self.screen);
list_formatter.bottom_right = sel.bottomRight(self.screen);
@ -716,6 +744,11 @@ pub const PageListFormatter = struct {
/// Warning: there is a significant performance hit to track this
pin_map: ?PinMap,
/// If non-null, fills in `offsets` with the byte position where each
/// `pins` entry first appears in the emitted output. Lighter than
/// `pin_map` when you only need a handful of positions.
pin_offsets: ?PinOffsets,
pub fn init(
list: *const PageList,
opts: Options,
@ -727,6 +760,7 @@ pub const PageListFormatter = struct {
.bottom_right = null,
.rectangle = false,
.pin_map = null,
.pin_offsets = null,
};
}
@ -737,9 +771,26 @@ pub const PageListFormatter = struct {
const tl: PageList.Pin = self.top_left orelse self.list.getTopLeft(.screen);
const br: PageList.Pin = self.bottom_right orelse self.list.getBottomRight(.screen).?;
// If we keep track of pins, we'll need this.
var point_map: std.ArrayList(Coordinate) = .empty;
defer if (self.pin_map) |*m| point_map.deinit(m.alloc);
// PageFormatter only knows about a single Page, so it can't
// track Pins directly. When the caller has asked for a pin_map
// or pin_offsets we feed PageFormatter a per-byte Coordinate
// scratch buffer and convert to Pins at the chunk boundary.
const PointMap = struct {
alloc: Allocator,
map: std.ArrayList(Coordinate),
};
var point_map: ?PointMap = point_map: {
const alloc = if (self.pin_map) |m|
m.alloc
else if (self.pin_offsets) |po|
po.alloc
else
break :point_map null;
break :point_map .{ .alloc = alloc, .map = .empty };
};
defer if (point_map) |*pm| pm.map.deinit(pm.alloc);
var running_byte_offset: usize = 0;
var page_state: ?PageFormatter.TrailingState = null;
var iter = tl.pageIterator(.right_down, br);
@ -763,26 +814,35 @@ pub const PageListFormatter = struct {
if (chunk.node == br.node) formatter.end_x = br.x;
}
// If we're tracking pins, then we setup a point map for the
// page formatter (cause it can't track pins). And then we convert
// this to pins later.
if (self.pin_map) |*m| {
point_map.clearRetainingCapacity();
formatter.point_map = .{ .alloc = m.alloc, .map = &point_map };
if (point_map) |*pm| {
pm.map.clearRetainingCapacity();
formatter.point_map = .{ .alloc = pm.alloc, .map = &pm.map };
}
page_state = try formatter.formatWithState(writer);
// If we're tracking pins then grab our points and write them
// to our pin map.
if (self.pin_map) |*m| {
for (point_map.items) |coord| {
m.map.append(m.alloc, .{
.node = chunk.node,
.x = coord.x,
.y = @intCast(coord.y),
}) catch return error.WriteFailed;
if (point_map) |*pm| {
if (self.pin_map) |*m| {
for (pm.map.items) |coord| {
m.map.append(m.alloc, .{
.node = chunk.node,
.x = coord.x,
.y = @intCast(coord.y),
}) catch return error.WriteFailed;
}
}
if (self.pin_offsets) |po| {
for (po.entries) |*entry| {
if (entry.offset != null or entry.pin.node != chunk.node) continue;
for (pm.map.items, 0..) |coord, i| {
if (coord.x == entry.pin.x and coord.y == entry.pin.y) {
entry.offset = running_byte_offset + i;
break;
}
}
}
}
running_byte_offset += pm.map.items.len;
}
}
}
@ -6277,3 +6337,104 @@ test "Page HTML hyperlink point map maps closing to previous cell" {
try testing.expectEqual(expected_coord, point_map.items[i]);
}
}
test "PageListFormatter pin_offsets records emitted bytes" {
const alloc = std.testing.allocator;
var builder: std.Io.Writer.Allocating = .init(alloc);
defer builder.deinit();
var t = try Terminal.init(alloc, .{ .cols = 2, .rows = 2 });
defer t.deinit(alloc);
var s = t.vtStream();
defer s.deinit();
s.nextSlice("AB\r\nCD");
const pages = &t.screens.active.pages;
var entries = [_]PinOffset{
.{ .pin = pages.pin(.{ .active = .{ .x = 0, .y = 0 } }).? }, // 'A'
.{ .pin = pages.pin(.{ .active = .{ .x = 1, .y = 0 } }).? }, // 'B'
.{ .pin = pages.pin(.{ .active = .{ .x = 0, .y = 1 } }).? }, // 'C'
.{ .pin = pages.pin(.{ .active = .{ .x = 1, .y = 1 } }).? }, // 'D'
};
var formatter: PageListFormatter = .init(pages, .plain);
formatter.top_left = pages.getTopLeft(.active);
formatter.bottom_right = pages.getBottomRight(.active).?;
formatter.pin_offsets = .{ .alloc = alloc, .entries = &entries };
try formatter.format(&builder.writer);
const output = builder.writer.buffered();
try std.testing.expectEqualStrings("AB\nCD", output);
try std.testing.expectEqual(@as(?usize, 0), entries[0].offset);
try std.testing.expectEqual(@as(?usize, 1), entries[1].offset);
try std.testing.expectEqual(@as(?usize, 3), entries[2].offset);
try std.testing.expectEqual(@as(?usize, 4), entries[3].offset);
}
test "PageListFormatter pin_offsets null for absent pin" {
const alloc = std.testing.allocator;
var builder: std.Io.Writer.Allocating = .init(alloc);
defer builder.deinit();
var t = try Terminal.init(alloc, .{ .cols = 10, .rows = 3 });
defer t.deinit(alloc);
var s = t.vtStream();
defer s.deinit();
s.nextSlice("AB");
const pages = &t.screens.active.pages;
var entries = [_]PinOffset{
.{ .pin = pages.pin(.{ .active = .{ .x = 0, .y = 2 } }).? },
};
var formatter: PageListFormatter = .init(pages, .plain);
formatter.top_left = pages.getTopLeft(.active);
formatter.bottom_right = pages.getBottomRight(.active).?;
formatter.pin_offsets = .{ .alloc = alloc, .entries = &entries };
try formatter.format(&builder.writer);
try std.testing.expectEqual(@as(?usize, null), entries[0].offset);
}
test "PageListFormatter pin_offsets matches with or without pin_map" {
const alloc = std.testing.allocator;
var t = try Terminal.init(alloc, .{ .cols = 10, .rows = 3 });
defer t.deinit(alloc);
var s = t.vtStream();
defer s.deinit();
s.nextSlice("AB\nCD");
const pages = &t.screens.active.pages;
const watched = pages.pin(.{ .active = .{ .x = 1, .y = 1 } }).?;
const with_pin_map: ?usize = blk: {
var builder: std.Io.Writer.Allocating = .init(alloc);
defer builder.deinit();
var pin_map: std.ArrayList(Pin) = .empty;
defer pin_map.deinit(alloc);
var entries = [_]PinOffset{.{ .pin = watched }};
var formatter: PageListFormatter = .init(pages, .plain);
formatter.top_left = pages.getTopLeft(.active);
formatter.bottom_right = pages.getBottomRight(.active).?;
formatter.pin_map = .{ .alloc = alloc, .map = &pin_map };
formatter.pin_offsets = .{ .alloc = alloc, .entries = &entries };
try formatter.format(&builder.writer);
break :blk entries[0].offset;
};
var builder: std.Io.Writer.Allocating = .init(alloc);
defer builder.deinit();
var entries = [_]PinOffset{.{ .pin = watched }};
var formatter: PageListFormatter = .init(pages, .plain);
formatter.top_left = pages.getTopLeft(.active);
formatter.bottom_right = pages.getBottomRight(.active).?;
formatter.pin_offsets = .{ .alloc = alloc, .entries = &entries };
try formatter.format(&builder.writer);
try std.testing.expectEqual(with_pin_map, entries[0].offset);
}