lib/gui/src/properties/text.zig

daab053ee43316e1809a84551d573ddd1e5bf3d2

  1 const std = @import("std");
  2 const gui = @import("gui");
  3 const hypothesis = @import("hypothesis");
  4 
  5 const Allocator = std.mem.Allocator;
  6 const Atlas = gui.paint.text.Atlas;
  7 const BitmapGlyph = gui.paint.text.BitmapGlyph;
  8 
  9 const codepoints = [_]u21{ 'a', 'b', 0x00e9, 0x754c, 0x1f642 };
 10 const max_codepoints = 24;
 11 const max_text_bytes = max_codepoints * 4;
 12 const property_atlas_cache_limits = gui.paint.TextAtlasCacheLimits{
 13     .measure_entries = 64,
 14     .measure_payload_bytes = 16 * 1024,
 15     .shape_entries = 64,
 16     .shape_payload_bytes = 64 * 1024,
 17 };
 18 
 19 fn settings() hypothesis.Settings {
 20     const base = hypothesis.Settings{
 21         .max_examples = 400,
 22         .max_choices = 4096,
 23         .max_shrinks = 10_000,
 24         .target_examples = 400,
 25     };
 26     return base
 27         .withSeed(0x6775_6954_6578_7401)
 28         .withDatabase("zig-out/hypothesis-failures/gui-text");
 29 }
 30 
 31 fn drawInt(data: *hypothesis.ConjectureData, min: usize, max: usize, shrink_towards: usize) !usize {
 32     return @intCast(try data.drawInteger(min, max, shrink_towards));
 33 }
 34 
 35 fn bitmapAtlas(allocator: Allocator) !Atlas {
 36     const glyphs = [_]BitmapGlyph{
 37         .{ .codepoint = 'a', .rows = &.{0xff} },
 38         .{ .codepoint = 'b', .rows = &.{0xff} },
 39         .{ .codepoint = 0x00e9, .rows = &.{0xff} },
 40         .{ .codepoint = 0x754c, .rows = &.{0xff} },
 41         .{ .codepoint = 0x1f642, .rows = &.{0xff} },
 42     };
 43     return Atlas.initFromBitmapGlyphs(
 44         allocator,
 45         &glyphs,
 46         .{ .width = 8, .height = 1, .stride = 1 },
 47         16,
 48         property_atlas_cache_limits,
 49     );
 50 }
 51 
 52 const CaretRoundTripProperty = struct {
 53     pub fn property(data: *hypothesis.ConjectureData, allocator: Allocator) !void {
 54         var atlas = try bitmapAtlas(allocator);
 55         defer atlas.deinit();
 56         const count = try drawInt(data, 0, max_codepoints, 8);
 57         var text_buffer: [max_text_bytes]u8 = undefined;
 58         var boundaries: [max_codepoints + 1]usize = undefined;
 59         boundaries[0] = 0;
 60         var text_len: usize = 0;
 61         for (0..count) |index| {
 62             const codepoint = codepoints[try drawInt(data, 0, codepoints.len - 1, 0)];
 63             var encoded: [4]u8 = undefined;
 64             const encoded_len = try std.unicode.utf8Encode(codepoint, &encoded);
 65             @memcpy(text_buffer[text_len .. text_len + encoded_len], encoded[0..encoded_len]);
 66             text_len += encoded_len;
 67             boundaries[index + 1] = text_len;
 68         }
 69         const text = text_buffer[0..text_len];
 70 
 71         for (boundaries[0 .. count + 1]) |boundary| {
 72             const advance = try atlas.advanceForByteOffset(text, boundary);
 73             const hit = try atlas.hitTestAdvance(text, advance);
 74             try std.testing.expectEqual(boundary, hit.byte_offset);
 75             try std.testing.expectApproxEqAbs(advance, hit.advance, 0.001);
 76         }
 77     }
 78 };
 79 
 80 const SelectionPartitionProperty = struct {
 81     pub fn property(data: *hypothesis.ConjectureData, allocator: Allocator) !void {
 82         const glyphs = [_]BitmapGlyph{
 83             .{ .codepoint = 'A', .rows = &.{0xff} },
 84             .{ .codepoint = ' ', .rows = &.{0x00} },
 85         };
 86         var atlas = try Atlas.initFromBitmapGlyphs(
 87             allocator,
 88             &glyphs,
 89             .{ .width = 8, .height = 1, .stride = 1 },
 90             16,
 91             property_atlas_cache_limits,
 92         );
 93         defer atlas.deinit();
 94         const content = "AA AA AA AA";
 95         const first = try drawInt(data, 0, content.len, 0);
 96         const second = try drawInt(data, 0, content.len, content.len);
 97         const wrap_cells = try drawInt(data, 2, 5, 3);
 98         const wrap_width: f32 = @floatFromInt(wrap_cells * 8);
 99         const child = [_]gui.model.UiNode{.{
100             .widget_id = 2,
101             .kind = .text_input,
102             .text = .{
103                 .content = content,
104                 .point_size = 16,
105                 .line_height = 1,
106                 .wrap_width = @floatCast(wrap_width),
107             },
108             .text_selection = .{
109                 .selection_active = true,
110                 .selection_anchor_byte_offset = first,
111                 .selection_focus_byte_offset = second,
112             },
113             .paint = .{ .foreground = .{ .r = 12, .g = 24, .b = 36, .a = 255 } },
114             .size = .{ .width = wrap_width, .height = 96 },
115         }};
116         const surface = gui.model.UiSurfaceTree{
117             .available_size = .{ .width = wrap_width, .height = 96 },
118             .root = .{ .widget_id = 1, .children = &child },
119         };
120         var workspace = gui.frame.Workspace.init(allocator);
121         defer workspace.deinit();
122         const frame = try workspace.buildSurface(&surface, .{});
123         var commands = gui.paint.CommandBuffer.init(allocator);
124         defer commands.deinit();
125         const entries = [_]gui.paint.text.AtlasSet.Entry{.{
126             .face = 0,
127             .image_index = 0,
128             .atlas = &atlas,
129         }};
130         const atlases = gui.paint.text.AtlasSet{ .entries = &entries };
131         try gui.paint.text.appendFrameCommands(&commands, frame, &atlases, 1, 0);
132 
133         var selected_width: f32 = 0;
134         for (commands.items()) |command| {
135             if (command.kind == .fill and command.color.a == 64) {
136                 selected_width += command.rect.width;
137             }
138         }
139         const selected_bytes = @max(first, second) - @min(first, second);
140         const expected_width: f32 = @floatFromInt(selected_bytes * 8);
141         try std.testing.expectApproxEqAbs(expected_width, selected_width, 0.001);
142     }
143 };
144 
145 const TextRunValidationProperty = struct {
146     pub fn property(data: *hypothesis.ConjectureData, allocator: Allocator) !void {
147         _ = allocator;
148         const count = try drawInt(data, 1, max_codepoints, 8);
149         var text_buffer: [max_text_bytes]u8 = undefined;
150         var boundaries: [max_codepoints + 1]usize = undefined;
151         boundaries[0] = 0;
152         var text_len: usize = 0;
153         for (0..count) |index| {
154             const codepoint = codepoints[try drawInt(data, 0, codepoints.len - 1, 0)];
155             var encoded: [4]u8 = undefined;
156             const encoded_len = try std.unicode.utf8Encode(codepoint, &encoded);
157             @memcpy(text_buffer[text_len .. text_len + encoded_len], encoded[0..encoded_len]);
158             text_len += encoded_len;
159             boundaries[index + 1] = text_len;
160         }
161         const content = text_buffer[0..text_len];
162         const start_index = try drawInt(data, 0, count - 1, 0);
163         const end_index = try drawInt(data, start_index + 1, count, count);
164         const valid = gui.model.UiTextRun{
165             .byte_start = boundaries[start_index],
166             .byte_end = boundaries[end_index],
167         };
168         try gui.model.validateTextRuns(content, &.{valid});
169 
170         var empty = valid;
171         empty.byte_end = empty.byte_start;
172         try std.testing.expectError(
173             error.InvalidTextRun,
174             gui.model.validateTextRuns(content, &.{empty}),
175         );
176         var past_end = valid;
177         past_end.byte_end = content.len + 1;
178         try std.testing.expectError(
179             error.InvalidTextRun,
180             gui.model.validateTextRuns(content, &.{past_end}),
181         );
182         try std.testing.expectError(
183             error.InvalidTextRun,
184             gui.model.validateTextRuns(content, &.{ valid, valid }),
185         );
186         if (count > 1) {
187             const reversed = [_]gui.model.UiTextRun{
188                 .{ .byte_start = boundaries[1], .byte_end = boundaries[2] },
189                 .{ .byte_start = boundaries[0], .byte_end = boundaries[1] },
190             };
191             try std.testing.expectError(
192                 error.InvalidTextRun,
193                 gui.model.validateTextRuns(content, &reversed),
194             );
195         }
196         for (0..count) |index| {
197             if (boundaries[index + 1] - boundaries[index] <= 1) continue;
198             const split = gui.model.UiTextRun{
199                 .byte_start = boundaries[index],
200                 .byte_end = boundaries[index] + 1,
201             };
202             try std.testing.expectError(
203                 error.InvalidTextRun,
204                 gui.model.validateTextRuns(content, &.{split}),
205             );
206             break;
207         }
208     }
209 };
210 
211 const StyledGeometryProperty = struct {
212     pub fn property(data: *hypothesis.ConjectureData, allocator: Allocator) !void {
213         var atlas = try bitmapAtlas(allocator);
214         defer atlas.deinit();
215         const count = try drawInt(data, 1, max_codepoints, 8);
216         var text_buffer: [max_text_bytes]u8 = undefined;
217         var boundaries: [max_codepoints + 1]usize = undefined;
218         boundaries[0] = 0;
219         var text_len: usize = 0;
220         for (0..count) |index| {
221             const codepoint = codepoints[try drawInt(data, 0, codepoints.len - 1, 0)];
222             var encoded: [4]u8 = undefined;
223             const encoded_len = try std.unicode.utf8Encode(codepoint, &encoded);
224             @memcpy(text_buffer[text_len .. text_len + encoded_len], encoded[0..encoded_len]);
225             text_len += encoded_len;
226             boundaries[index + 1] = text_len;
227         }
228         const content = text_buffer[0..text_len];
229         const start_index = try drawInt(data, 0, count - 1, 0);
230         const end_index = try drawInt(data, start_index + 1, count, count);
231         const wrap_cells = try drawInt(data, 1, count, @min(count, 4));
232         const wrap_width: f32 = @floatFromInt(wrap_cells * 8);
233         const runs = [_]gui.model.UiTextRun{.{
234             .byte_start = boundaries[start_index],
235             .byte_end = boundaries[end_index],
236             .foreground = .{ .r = 220, .g = 30, .b = 20, .a = 255 },
237             .background = .{ .r = 10, .g = 20, .b = 40, .a = 160 },
238             .underline = true,
239             .strikethrough = end_index - start_index > 1,
240         }};
241         const plain = gui.model.UiText{
242             .content = content,
243             .point_size = 16,
244             .line_height = 1,
245             .wrap_width = wrap_width,
246         };
247         const styled = gui.model.UiText{
248             .content = content,
249             .runs = &runs,
250             .point_size = 16,
251             .line_height = 1,
252             .wrap_width = wrap_width,
253         };
254         try gui.model.validateTextRuns(content, &runs);
255         try std.testing.expectEqual(
256             try gui.paint.text.measure(&atlas, plain),
257             try gui.paint.text.measure(&atlas, styled),
258         );
259         for ([_]usize{ boundaries[start_index], boundaries[end_index] }) |boundary| {
260             const advance = try atlas.advanceForByteOffset(content, boundary);
261             const hit = try atlas.hitTestAdvance(content, advance);
262             try std.testing.expectEqual(boundary, hit.byte_offset);
263             try std.testing.expectApproxEqAbs(advance, hit.advance, 0.001);
264         }
265 
266         const selection = gui.model.UiTextSelection{
267             .cursor_visible = true,
268             .cursor_byte_offset = boundaries[end_index],
269             .selection_active = true,
270             .selection_anchor_byte_offset = boundaries[start_index],
271             .selection_focus_byte_offset = boundaries[end_index],
272         };
273         const nodes = [_]gui.model.UiNode{
274             .{
275                 .widget_id = 2,
276                 .kind = .text_input,
277                 .text = plain,
278                 .text_selection = selection,
279                 .size = .{ .width = wrap_width, .height = max_codepoints * 16 },
280             },
281             .{
282                 .widget_id = 2,
283                 .kind = .text_input,
284                 .text = styled,
285                 .text_selection = selection,
286                 .size = .{ .width = wrap_width, .height = max_codepoints * 16 },
287             },
288         };
289         var workspaces = [_]gui.frame.Workspace{
290             gui.frame.Workspace.init(allocator),
291             gui.frame.Workspace.init(allocator),
292         };
293         defer for (&workspaces) |*workspace| workspace.deinit();
294         var buffers = [_]gui.paint.CommandBuffer{
295             gui.paint.CommandBuffer.init(allocator),
296             gui.paint.CommandBuffer.init(allocator),
297         };
298         defer for (&buffers) |*buffer| buffer.deinit();
299         const entries = [_]gui.paint.text.AtlasSet.Entry{.{
300             .face = 0,
301             .image_index = 0,
302             .atlas = &atlas,
303         }};
304         const atlases = gui.paint.text.AtlasSet{ .entries = &entries };
305         const device_scale: f32 = @floatFromInt(try drawInt(data, 1, 2, 1));
306         var widget_geometry: [2]gui.model.WidgetFrame = undefined;
307         for (0..nodes.len) |index| {
308             const surface = gui.model.UiSurfaceTree{
309                 .available_size = .{ .width = wrap_width, .height = max_codepoints * 16 },
310                 .root = .{ .widget_id = 1, .children = nodes[index .. index + 1] },
311             };
312             const frame = try workspaces[index].buildSurface(&surface, .{});
313             widget_geometry[index] = frame.widgets[1];
314             try gui.paint.text.appendFrameCommands(&buffers[index], frame, &atlases, device_scale, 0);
315         }
316         try std.testing.expectEqual(widget_geometry[0].rect, widget_geometry[1].rect);
317         try std.testing.expectEqual(widget_geometry[0].visible_rect, widget_geometry[1].visible_rect);
318         try std.testing.expectEqual(widget_geometry[0].constraints, widget_geometry[1].constraints);
319         try std.testing.expectEqual(widget_geometry[0].content_size, widget_geometry[1].content_size);
320 
321         var plain_index: usize = 0;
322         var styled_index: usize = 0;
323         while (true) {
324             while (plain_index < buffers[0].items().len and buffers[0].items()[plain_index].kind != .glyph) {
325                 plain_index += 1;
326             }
327             while (styled_index < buffers[1].items().len and buffers[1].items()[styled_index].kind != .glyph) {
328                 styled_index += 1;
329             }
330             const plain_done = plain_index == buffers[0].items().len;
331             const styled_done = styled_index == buffers[1].items().len;
332             try std.testing.expectEqual(plain_done, styled_done);
333             if (plain_done) break;
334             const plain_glyph = buffers[0].items()[plain_index];
335             const styled_glyph = buffers[1].items()[styled_index];
336             try std.testing.expectEqual(plain_glyph.rect, styled_glyph.rect);
337             try std.testing.expectEqual(plain_glyph.clip, styled_glyph.clip);
338             try std.testing.expectEqual(plain_glyph.source, styled_glyph.source);
339             plain_index += 1;
340             styled_index += 1;
341         }
342 
343         var selection_rects: [2][max_codepoints]gui.layout.Rect = undefined;
344         var selection_counts: [2]usize = @splat(0);
345         for (&buffers, 0..) |*buffer, buffer_index| {
346             for (buffer.items()) |item| {
347                 if (item.kind != .fill or item.color.a != 64) continue;
348                 selection_rects[buffer_index][selection_counts[buffer_index]] = item.rect;
349                 selection_counts[buffer_index] += 1;
350             }
351         }
352         try std.testing.expectEqual(selection_counts[0], selection_counts[1]);
353         try std.testing.expectEqualSlices(
354             gui.layout.Rect,
355             selection_rects[0][0..selection_counts[0]],
356             selection_rects[1][0..selection_counts[1]],
357         );
358         const plain_caret = buffers[0].items()[buffers[0].items().len - 1];
359         const styled_caret = buffers[1].items()[buffers[1].items().len - 1];
360         try std.testing.expectEqual(gui.paint.command.Kind.fill, plain_caret.kind);
361         try std.testing.expectEqual(gui.paint.command.Kind.fill, styled_caret.kind);
362         try std.testing.expectEqual(plain_caret.rect, styled_caret.rect);
363     }
364 };
365 
366 const MixedMetricGeometryProperty = struct {
367     pub fn property(data: *hypothesis.ConjectureData, allocator: Allocator) !void {
368         var base_atlas = try bitmapAtlas(allocator);
369         defer base_atlas.deinit();
370         var large_atlas = try bitmapAtlas(allocator);
371         defer large_atlas.deinit();
372         const count = try drawInt(data, 2, max_codepoints, 8);
373         var text_buffer: [max_text_bytes]u8 = undefined;
374         var boundaries: [max_codepoints + 1]usize = undefined;
375         boundaries[0] = 0;
376         var text_len: usize = 0;
377         for (0..count) |index| {
378             const codepoint = codepoints[try drawInt(data, 0, codepoints.len - 1, 0)];
379             var encoded: [4]u8 = undefined;
380             const encoded_len = try std.unicode.utf8Encode(codepoint, &encoded);
381             @memcpy(text_buffer[text_len .. text_len + encoded_len], encoded[0..encoded_len]);
382             text_len += encoded_len;
383             boundaries[index + 1] = text_len;
384         }
385         const style_start = try drawInt(data, 0, count - 1, 0);
386         const style_end = try drawInt(data, style_start + 1, count, count);
387         const styles = [_]gui.model.UiTextStyle{.{
388             .font_asset_id = 2,
389             .point_size = 24,
390         }};
391         const runs = [_]gui.model.UiTextRun{.{
392             .byte_start = boundaries[style_start],
393             .byte_end = boundaries[style_end],
394             .style_slot = 1,
395             .background = .{ .r = 8, .g = 16, .b = 32, .a = 160 },
396             .underline = true,
397         }};
398         const wrap_cells = try drawInt(data, 2, 8, 4);
399         const wrap_width: f32 = @floatFromInt(wrap_cells * 8);
400         const text = gui.model.UiText{
401             .content = text_buffer[0..text_len],
402             .runs = &runs,
403             .styles = &styles,
404             .font_asset_id = 1,
405             .point_size = 16,
406             .line_height = 1,
407             .wrap_width = wrap_width,
408         };
409         try gui.model.validateText(text);
410         const entries = [_]gui.paint.text.AtlasSet.Entry{
411             .{ .face = 1, .image_index = 3, .atlas = &base_atlas },
412             .{ .face = 2, .image_index = 9, .atlas = &large_atlas },
413         };
414         const atlases = gui.paint.text.AtlasSet{ .entries = &entries };
415         for (boundaries[0 .. count + 1]) |boundary| {
416             const advance = try gui.paint.text.textAdvanceForByteOffset(
417                 &atlases,
418                 text,
419                 boundary,
420             );
421             const hit = try gui.paint.text.textHitTestAdvance(&atlases, text, advance);
422             try std.testing.expectEqual(boundary, hit.byte_offset);
423             try std.testing.expectApproxEqAbs(advance, hit.advance, 0.001);
424         }
425         const first = try drawInt(data, 0, count, 0);
426         const second = try drawInt(data, 0, count, count);
427         const child = [_]gui.model.UiNode{.{
428             .widget_id = 2,
429             .kind = .text_input,
430             .text = text,
431             .text_selection = .{
432                 .selection_active = true,
433                 .selection_anchor_byte_offset = boundaries[first],
434                 .selection_focus_byte_offset = boundaries[second],
435             },
436             .size = .{ .width = wrap_width, .height = max_codepoints * 24 },
437         }};
438         const surface = gui.model.UiSurfaceTree{
439             .available_size = .{ .width = wrap_width, .height = max_codepoints * 24 },
440             .root = .{ .widget_id = 1, .children = &child },
441         };
442         var workspace = gui.frame.Workspace.init(allocator);
443         defer workspace.deinit();
444         const frame = try workspace.buildSurface(&surface, .{
445             .resolvers = gui.paint.text.frameResolvers(&atlases),
446         });
447         var commands = gui.paint.CommandBuffer.init(allocator);
448         defer commands.deinit();
449         const device_scale: f32 = @floatFromInt(try drawInt(data, 1, 2, 1));
450         try gui.paint.text.appendFrameCommands(
451             &commands,
452             frame,
453             &atlases,
454             device_scale,
455             0,
456         );
457         var selected_width: f32 = 0;
458         for (commands.items()) |item| {
459             if (item.kind == .fill and item.color.a == 64) selected_width += item.rect.width;
460         }
461         const selected_start = @min(first, second);
462         const selected_end = @max(first, second);
463         var expected_width: f32 = 0;
464         for (selected_start..selected_end) |index| {
465             expected_width += if (index >= style_start and index < style_end) 12 else 8;
466         }
467         try std.testing.expectApproxEqAbs(
468             expected_width * device_scale,
469             selected_width,
470             0.001,
471         );
472     }
473 };
474 
475 const MultilineCaretRoundTripProperty = struct {
476     pub fn property(data: *hypothesis.ConjectureData, allocator: Allocator) !void {
477         var atlas = try bitmapAtlas(allocator);
478         defer atlas.deinit();
479         const count = try drawInt(data, 0, max_codepoints, 8);
480         var text_buffer: [max_text_bytes]u8 = undefined;
481         var boundaries: [max_codepoints + 1]usize = undefined;
482         boundaries[0] = 0;
483         var text_len: usize = 0;
484         for (0..count) |index| {
485             const token = try drawInt(data, 0, codepoints.len, 0);
486             if (token == codepoints.len) {
487                 text_buffer[text_len] = '\n';
488                 text_len += 1;
489             } else {
490                 var encoded: [4]u8 = undefined;
491                 const encoded_len = try std.unicode.utf8Encode(
492                     codepoints[token],
493                     &encoded,
494                 );
495                 @memcpy(
496                     text_buffer[text_len .. text_len + encoded_len],
497                     encoded[0..encoded_len],
498                 );
499                 text_len += encoded_len;
500             }
501             boundaries[index + 1] = text_len;
502         }
503         const wrap_cells = try drawInt(data, 1, 6, 3);
504         const wrap_width: f32 = @floatFromInt(wrap_cells * 8);
505         const horizontal: gui.model.UiTextAlign = @fromBackingInt(@intCast(
506             try drawInt(data, 0, 2, 0),
507         ));
508         const vertical: gui.model.UiTextAlign = @fromBackingInt(@intCast(
509             try drawInt(data, 0, 2, 0),
510         ));
511         const text = gui.model.UiText{
512             .content = text_buffer[0..text_len],
513             .point_size = 16,
514             .line_height = 1,
515             .wrap_width = wrap_width,
516             .horizontal_align = horizontal,
517             .vertical_align = vertical,
518         };
519         const box = gui.layout.Size{
520             .width = wrap_width + @as(
521                 f32,
522                 @floatFromInt(try drawInt(data, 0, 6, 2) * 8),
523             ),
524             .height = @floatFromInt((count + 4) * 16),
525         };
526         const entries = [_]gui.paint.text.AtlasSet.Entry{.{
527             .face = 0,
528             .image_index = 0,
529             .atlas = &atlas,
530         }};
531         const atlases = gui.paint.text.AtlasSet{ .entries = &entries };
532         const boundary = boundaries[try drawInt(data, 0, count, 0)];
533 
534         const downstream = try gui.paint.text.textCaretGeometry(
535             &atlases,
536             text,
537             box,
538             boundary,
539             .downstream,
540         );
541         const downstream_hit = try gui.paint.text.textHitTestPoint(
542             &atlases,
543             text,
544             box,
545             .{
546                 .x = downstream.x,
547                 .y = downstream.y + downstream.height * 0.5,
548             },
549         );
550         try std.testing.expectEqual(boundary, downstream_hit.byte_offset);
551 
552         const upstream = try gui.paint.text.textCaretGeometry(
553             &atlases,
554             text,
555             box,
556             boundary,
557             .upstream,
558         );
559         const upstream_hit = try gui.paint.text.textHitTestPoint(
560             &atlases,
561             text,
562             box,
563             .{
564                 .x = upstream.x,
565                 .y = upstream.y + upstream.height * 0.5,
566             },
567         );
568         try std.testing.expectEqual(boundary, upstream_hit.byte_offset);
569         if (upstream.line_index != downstream.line_index) {
570             try std.testing.expectEqual(
571                 gui.paint.text.TextCaretAffinity.upstream,
572                 upstream_hit.affinity,
573             );
574         }
575 
576         const first = try gui.paint.text.textHitTestPoint(
577             &atlases,
578             text,
579             box,
580             .{ .x = -1000, .y = -1000 },
581         );
582         try std.testing.expectEqual(@as(usize, 0), first.byte_offset);
583         try std.testing.expectEqual(@as(usize, 0), first.line_index);
584         const last = try gui.paint.text.textHitTestPoint(
585             &atlases,
586             text,
587             box,
588             .{ .x = 10000, .y = 10000 },
589         );
590         try std.testing.expectEqual(text.content.len, last.byte_offset);
591     }
592 };
593 
594 test "property: cached caret and hit queries round trip at grapheme boundaries" {
595     try hypothesis.checkNamed(
596         CaretRoundTripProperty,
597         "gui-text-caret-round-trip",
598         settings(),
599     );
600 }
601 
602 test "property: wrapped visual lines partition selection width" {
603     var property_settings = settings();
604     property_settings.max_examples = 200;
605     property_settings.target_examples = 200;
606     try hypothesis.checkNamed(
607         SelectionPartitionProperty,
608         "gui-text-selection-partition",
609         property_settings,
610     );
611 }
612 
613 test "property: text run validation rejects malformed generated ranges" {
614     var property_settings = settings();
615     property_settings.max_examples = 200;
616     property_settings.target_examples = 200;
617     try hypothesis.checkNamed(
618         TextRunValidationProperty,
619         "gui-text-run-validation",
620         property_settings,
621     );
622 }
623 
624 test "property: text run styles preserve wrapped caret hit and selection geometry" {
625     var property_settings = settings();
626     property_settings.max_examples = 200;
627     property_settings.target_examples = 200;
628     try hypothesis.checkNamed(
629         StyledGeometryProperty,
630         "gui-text-run-geometry",
631         property_settings,
632     );
633 }
634 
635 test "property: mixed metric runs preserve caret hits and wrapped selection partitions" {
636     var property_settings = settings();
637     property_settings.max_examples = 150;
638     property_settings.target_examples = 150;
639     try hypothesis.checkNamed(
640         MixedMetricGeometryProperty,
641         "gui-text-mixed-metric-geometry",
642         property_settings,
643     );
644 }
645 
646 test "property: multiline caret geometry round trips hard and soft boundaries" {
647     var property_settings = settings();
648     property_settings.max_examples = 250;
649     property_settings.target_examples = 250;
650     try hypothesis.checkNamed(
651         MultilineCaretRoundTripProperty,
652         "gui-text-multiline-caret-round-trip",
653         property_settings,
654     );
655 }