1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
|
const std = @import("std");
const assert = @import("../../quirks.zig").inlineAssert;
const testing = std.testing;
const Allocator = std.mem.Allocator;
const point = @import("../point.zig");
const size = @import("../size.zig");
const FlattenedHighlight = @import("../highlight.zig").Flattened;
const PageList = @import("../PageList.zig");
const SlidingWindow = @import("sliding_window.zig").SlidingWindow;
const Terminal = @import("../Terminal.zig");
/// Searches for a substring within the viewport of a PageList.
///
/// This contains logic to efficiently detect when the viewport changes
/// and only re-search when necessary.
///
/// The specialization for "viewport" is because the viewport is the
/// only part of the search where the user can actively see the results,
/// usually. In that case, it is more efficient to re-search only the
/// viewport rather than store all the results for the entire screen.
///
/// Note that this searches all the pages that viewport covers, so
/// this can include extra matches outside the viewport if the data
/// lives in the same page.
pub const ViewportSearch = struct {
window: SlidingWindow,
fingerprint: Fingerprint = .{},
/// If this is null, then active dirty tracking is disabled and if the
/// viewport overlaps the active area we always re-search. If this is
/// non-null, then we only re-search if the active area is dirty. Dirty
/// marking is up to the caller.
active_dirty: ?bool,
pub fn init(
alloc: Allocator,
needle_unowned: []const u8,
) Allocator.Error!ViewportSearch {
// We just do a forward search since the viewport is usually
// pretty small so search results are instant anyways. This avoids
// a small amount of work to reverse things.
var window: SlidingWindow = try .init(alloc, .forward, needle_unowned);
errdefer window.deinit();
return .{
.window = window,
.active_dirty = null,
};
}
pub fn deinit(self: *ViewportSearch) void {
self.fingerprint.deinit(self.window.alloc);
self.window.deinit();
}
/// Reset our fingerprint and results so that the next update will
/// always re-search.
pub fn reset(self: *ViewportSearch) void {
self.fingerprint.clearAndRetainCapacity();
self.window.clearAndRetainCapacity();
}
/// The needle that this search is using.
pub fn needle(self: *const ViewportSearch) []const u8 {
assert(self.window.direction == .forward);
return self.window.needle;
}
/// Update the sliding window to reflect the current viewport. This
/// will do nothing if the viewport hasn't changed since the last
/// search.
///
/// The PageList must be safe to read throughout the lifetime of this
/// function.
///
/// Returns true if the viewport changed and a re-search is needed.
/// Returns false if the viewport is unchanged.
pub fn update(
self: *ViewportSearch,
list: *PageList,
) Allocator.Error!bool {
// See if our viewport changed. We retain the fingerprint buffer so
// unchanged viewports don't allocate and changed viewports usually
// reuse the previous capacity.
const fingerprint = &self.fingerprint;
if (!try fingerprint.update(self.window.alloc, list)) match: {
// Determine if we need to check if we overlap the active area.
// If we have dirty tracking on we also set it to false here.
const check_active: bool = active: {
const dirty = self.active_dirty orelse break :active true;
if (!dirty) break :active false;
self.active_dirty = false;
break :active true;
};
if (check_active) {
// If our fingerprint contains the active area, then we always
// re-search since the active area is mutable.
const active_tl = list.getTopLeft(.active);
const active_br = list.getBottomRight(.active).?;
// If our viewport contains the start or end of the active area,
// we are in the active area. We purposely do this first because
// our viewport is always larger than the active area.
for (fingerprint.entries.items) |entry| {
if (entry.node == active_tl.node) break :match;
if (entry.node == active_br.node) break :match;
}
}
// No change
return false;
}
// If our active area was set as dirty, we always unset it here
// because we're re-searching now.
if (self.active_dirty) |*v| v.* = false;
// Clear our previous sliding window
self.window.clearAndRetainCapacity();
// Add enough overlap to cover needle.len - 1 bytes (if it
// exists) so we can cover the overlap. We only do this for the
// soft-wrapped prior pages.
const overlap_len = self.window.needle.len -| 1;
const entries = fingerprint.entries.items;
var node_ = entries[0].node.prev;
var added: usize = 0;
while (node_) |node| : (node_ = node.prev) {
// We could be more accurate here and count bytes since the
// last wrap but its complicated and unlikely multiple pages
// wrap so this should be fine.
const appended = try self.window.appendIfWrapped(node) orelse break;
added += appended.content_len;
if (added >= overlap_len) break;
}
// We can use our fingerprint nodes to initialize our sliding
// window, since we already traversed the viewport once.
var end_append: SlidingWindow.AppendResult = undefined;
for (entries) |entry| {
end_append = try self.window.append(entry.node);
}
// Add any trailing overlap as well.
trailing: {
const end: *PageList.List.Node = entries[entries.len - 1].node;
if (!end_append.last_row_wrapped) break :trailing;
node_ = end.next;
added = 0;
while (node_) |node| : (node_ = node.next) {
const appended = try self.window.append(node);
added += appended.content_len;
if (added >= overlap_len) break;
// If this row doesn't wrap, then we can quit
if (!appended.last_row_wrapped) break;
}
}
return true;
}
/// Find the next match for the needle in the active area. This returns
/// null when there are no more matches.
pub fn next(self: *ViewportSearch) ?FlattenedHighlight {
return self.window.next();
}
/// Viewport fingerprint so we can detect when the viewport moves.
const Fingerprint = struct {
/// The node addresses and generations that make up the viewport. We
/// can't safely dereference cached node pointers because the nodes may
/// be invalid, so equality only compares these captured values.
entries: std.ArrayListUnmanaged(Entry) = .empty,
const Entry = struct {
node: *PageList.List.Node,
serial: u64,
};
pub fn deinit(self: *Fingerprint, alloc: Allocator) void {
self.entries.deinit(alloc);
}
pub fn clearAndRetainCapacity(self: *Fingerprint) void {
self.entries.clearRetainingCapacity();
}
/// Update this fingerprint to match the current viewport.
///
/// Returns true if the viewport changed. The viewport can have at
/// most one entry per visible row, so we reserve that upper bound
/// before mutating the retained buffer. This lets us rebuild in one
/// pass while still preserving the old fingerprint if allocation
/// fails.
pub fn update(self: *Fingerprint, alloc: Allocator, pages: *PageList) Allocator.Error!bool {
try self.entries.ensureTotalCapacity(alloc, pages.rows);
const old_len = self.entries.items.len;
var changed = false;
var i: usize = 0;
var it = pages.pageIterator(.right_down, .{ .viewport = .{} }, null);
while (it.next()) |chunk| : (i += 1) {
const entry: Entry = .{
.node = chunk.node,
.serial = chunk.node.serial,
};
if (!changed) {
changed = i >= old_len or
self.entries.items[i].node != entry.node or
self.entries.items[i].serial != entry.serial;
}
if (i < self.entries.items.len) {
self.entries.items[i] = entry;
} else {
self.entries.appendAssumeCapacity(entry);
}
}
self.entries.shrinkRetainingCapacity(i);
return changed or i != old_len;
}
};
};
test "simple search" {
const alloc = testing.allocator;
const io = testing.io;
var t: Terminal = try .init(io, alloc, .{ .cols = 10, .rows = 10 });
defer t.deinit(alloc);
var s = t.vtStream();
defer s.deinit();
s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang");
var search: ViewportSearch = try .init(alloc, "Fizz");
defer search.deinit();
try testing.expect(try search.update(&t.screens.active.pages));
// Viewport contains active so update should always re-search.
try testing.expect(try search.update(&t.screens.active.pages));
{
const h = search.next().?;
const sel = h.untracked();
try testing.expectEqual(point.Point{ .active = .{
.x = 0,
.y = 0,
} }, t.screens.active.pages.pointFromPin(.active, sel.start).?);
try testing.expectEqual(point.Point{ .active = .{
.x = 3,
.y = 0,
} }, t.screens.active.pages.pointFromPin(.active, sel.end).?);
}
{
const h = search.next().?;
const sel = h.untracked();
try testing.expectEqual(point.Point{ .active = .{
.x = 0,
.y = 2,
} }, t.screens.active.pages.pointFromPin(.active, sel.start).?);
try testing.expectEqual(point.Point{ .active = .{
.x = 3,
.y = 2,
} }, t.screens.active.pages.pointFromPin(.active, sel.end).?);
}
try testing.expect(search.next() == null);
}
test "fingerprint detects node generation changes" {
const alloc = testing.allocator;
const io = testing.io;
var t: Terminal = try .init(io, alloc, .{ .cols = 10, .rows = 10 });
defer t.deinit(alloc);
var search: ViewportSearch = try .init(alloc, "needle");
defer search.deinit();
search.active_dirty = false;
try testing.expect(try search.update(&t.screens.active.pages));
try testing.expect(!try search.update(&t.screens.active.pages));
const old_entry = search.fingerprint.entries.items[0];
const node = t.screens.active.pages.pages.first.?;
try testing.expectEqual(node, old_entry.node);
const original_serial = node.serial;
defer node.serial = original_serial;
node.serial += 1;
// The viewport still contains the same pointer, but its generation has
// changed, so the cached window must be rebuilt.
try testing.expect(try search.update(&t.screens.active.pages));
const new_entry = search.fingerprint.entries.items[0];
try testing.expectEqual(old_entry.node, new_entry.node);
try testing.expect(old_entry.serial != new_entry.serial);
}
test "fingerprint only allocates on changes" {
const alloc = testing.allocator;
const io = testing.io;
var t: Terminal = try .init(io, alloc, .{ .cols = 10, .rows = 10 });
defer t.deinit(alloc);
var failing = testing.FailingAllocator.init(alloc, .{});
const failing_alloc = failing.allocator();
var search: ViewportSearch = try .init(failing_alloc, "needle");
defer search.deinit();
search.active_dirty = false;
try testing.expect(try search.update(&t.screens.active.pages));
// Fail the next allocation. An unchanged viewport with active dirty
// tracking disabled should reuse the existing fingerprint and return
// unchanged before rebuilding the search window, so this update should
// succeed without reporting a viewport change.
failing.fail_index = failing.alloc_index;
try testing.expect(!try search.update(&t.screens.active.pages));
}
test "clear screen and search" {
const alloc = testing.allocator;
const io = testing.io;
var t: Terminal = try .init(io, alloc, .{ .cols = 10, .rows = 10 });
defer t.deinit(alloc);
var s = t.vtStream();
defer s.deinit();
s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang");
var search: ViewportSearch = try .init(alloc, "Fizz");
defer search.deinit();
try testing.expect(try search.update(&t.screens.active.pages));
s.nextSlice("\x1b[2J"); // Clear screen
s.nextSlice("\x1b[H"); // Move cursor home
s.nextSlice("Buzz\r\nFizz\r\nBuzz");
try testing.expect(try search.update(&t.screens.active.pages));
{
const h = search.next().?;
const sel = h.untracked();
try testing.expectEqual(point.Point{ .active = .{
.x = 0,
.y = 1,
} }, t.screens.active.pages.pointFromPin(.active, sel.start).?);
try testing.expectEqual(point.Point{ .active = .{
.x = 3,
.y = 1,
} }, t.screens.active.pages.pointFromPin(.active, sel.end).?);
}
try testing.expect(search.next() == null);
}
test "clear screen and search dirty tracking" {
const alloc = testing.allocator;
const io = testing.io;
var t: Terminal = try .init(io, alloc, .{ .cols = 10, .rows = 10 });
defer t.deinit(alloc);
var s = t.vtStream();
defer s.deinit();
s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang");
var search: ViewportSearch = try .init(alloc, "Fizz");
defer search.deinit();
// Turn on dirty tracking
search.active_dirty = false;
// Should update since we've never searched before
try testing.expect(try search.update(&t.screens.active.pages));
// Should not update since nothing changed
try testing.expect(!try search.update(&t.screens.active.pages));
s.nextSlice("\x1b[2J"); // Clear screen
s.nextSlice("\x1b[H"); // Move cursor home
s.nextSlice("Buzz\r\nFizz\r\nBuzz");
// Should still not update since active area isn't dirty
try testing.expect(!try search.update(&t.screens.active.pages));
// Mark
search.active_dirty = true;
try testing.expect(try search.update(&t.screens.active.pages));
{
const h = search.next().?;
const sel = h.untracked();
try testing.expectEqual(point.Point{ .active = .{
.x = 0,
.y = 1,
} }, t.screens.active.pages.pointFromPin(.active, sel.start).?);
try testing.expectEqual(point.Point{ .active = .{
.x = 3,
.y = 1,
} }, t.screens.active.pages.pointFromPin(.active, sel.end).?);
}
try testing.expect(search.next() == null);
}
test "history search, no active area" {
const alloc = testing.allocator;
const io = testing.io;
var t: Terminal = try .init(io, alloc, .{ .cols = 10, .rows = 2 });
defer t.deinit(alloc);
var s = t.vtStream();
defer s.deinit();
// Fill up first page
const first_page_rows = t.screens.active.pages.pages.first.?.capacity().rows;
s.nextSlice("Fizz\r\n");
for (1..first_page_rows - 1) |_| s.nextSlice("\r\n");
try testing.expect(t.screens.active.pages.pages.first == t.screens.active.pages.pages.last);
// Create second page
s.nextSlice("\r\n");
try testing.expect(t.screens.active.pages.pages.first != t.screens.active.pages.pages.last);
s.nextSlice("Buzz\r\nFizz");
t.scrollViewport(.top);
var search: ViewportSearch = try .init(alloc, "Fizz");
defer search.deinit();
try testing.expect(try search.update(&t.screens.active.pages));
{
const h = search.next().?;
const sel = h.untracked();
try testing.expectEqual(point.Point{ .screen = .{
.x = 0,
.y = 0,
} }, t.screens.active.pages.pointFromPin(.screen, sel.start).?);
try testing.expectEqual(point.Point{ .screen = .{
.x = 3,
.y = 0,
} }, t.screens.active.pages.pointFromPin(.screen, sel.end).?);
}
try testing.expect(search.next() == null);
// Viewport doesn't contain active
try testing.expect(!try search.update(&t.screens.active.pages));
try testing.expect(search.next() == null);
}
|