lib/gui/src/paint/text.zig
daab053ee43316e1809a84551d573ddd1e5bf3d2
1 const std = @import("std");
2 const alloc_phase = @import("alloc_phase");
3 const filigree = @import("filigree");
4
5 const command = @import("command.zig");
6 const gui = @import("../root.zig");
7 const cpu = @import("cpu/root.zig");
8 const fallback_mod = @import("fallback.zig");
9 const synth = @import("synth.zig");
10
11 const Allocator = std.mem.Allocator;
12 const Color = gui.model.UiColor;
13 const Image = command.Image;
14 const Region = cpu.Region;
15 const Size = gui.layout.Size;
16 const UiFrame = gui.model.UiFrame;
17 const UiNode = gui.model.UiNode;
18 const UiText = gui.model.UiText;
19 const UiTextRun = gui.model.UiTextRun;
20 const UiTextStyle = gui.model.UiTextStyle;
21 const UiTextSelection = gui.model.UiTextSelection;
22 const WidgetFrame = gui.model.WidgetFrame;
23 const Rect = gui.layout.Rect;
24
25 pub const OwnedAtlasImage = struct {
26 image: Image,
27 pixels: []u32,
28
29 pub fn deinit(self: *OwnedAtlasImage, allocator: Allocator) void {
30 allocator.free(self.pixels);
31 self.* = undefined;
32 }
33 };
34
35 pub const BitmapGlyph = struct {
36 codepoint: u21,
37 rows: []const u8,
38 };
39
40 pub const BitmapMetrics = struct {
41 width: u16,
42 height: u16,
43 stride: u16,
44 };
45
46 pub const AtlasScratch = struct {
47 capacity: Capacity,
48 bytes: []align(storage_alignment) u8,
49 fixed: std.heap.FixedBufferAllocator,
50 active: bool = false,
51 epoch_peak_bytes: usize = 0,
52 last_epoch_peak_bytes: usize = 0,
53 high_water_bytes: usize = 0,
54 epochs: usize = 0,
55 exhaustions: usize = 0,
56
57 pub const storage_alignment: usize = 64;
58
59 pub const Limits = struct {
60 bytes: usize,
61 };
62
63 pub const Capacity = struct {
64 bytes: usize,
65 total_bytes: usize,
66
67 pub fn derive(limits: Limits) Capacity {
68 return .{
69 .bytes = limits.bytes,
70 .total_bytes = limits.bytes,
71 };
72 }
73 };
74
75 pub const Status = struct {
76 capacity: Capacity,
77 last_epoch_peak_bytes: usize,
78 high_water_bytes: usize,
79 epochs: usize,
80 exhaustions: usize,
81 };
82
83 pub fn init(allocator: Allocator, limits: Limits) Allocator.Error!AtlasScratch {
84 const capacity = Capacity.derive(limits);
85 const bytes = try allocator.alignedAlloc(u8, .fromByteUnits(storage_alignment), capacity.bytes);
86 return .{
87 .capacity = capacity,
88 .bytes = bytes,
89 .fixed = std.heap.FixedBufferAllocator.init(bytes),
90 };
91 }
92
93 pub fn deinit(self: *AtlasScratch, allocator: Allocator) void {
94 std.debug.assert(!self.active);
95 allocator.free(self.bytes);
96 self.* = undefined;
97 }
98
99 pub fn begin(self: *AtlasScratch) Allocator {
100 std.debug.assert(!self.active);
101 self.fixed = std.heap.FixedBufferAllocator.init(self.bytes);
102 self.epoch_peak_bytes = 0;
103 self.active = true;
104 return .{
105 .ptr = self,
106 .vtable = &.{
107 .alloc = alloc,
108 .resize = resize,
109 .remap = remap,
110 .free = free,
111 },
112 };
113 }
114
115 pub fn end(self: *AtlasScratch) void {
116 std.debug.assert(self.active);
117 self.last_epoch_peak_bytes = self.epoch_peak_bytes;
118 self.high_water_bytes = @max(self.high_water_bytes, self.epoch_peak_bytes);
119 self.fixed = std.heap.FixedBufferAllocator.init(self.bytes);
120 self.active = false;
121 self.epochs +|= 1;
122 }
123
124 pub fn status(self: *const AtlasScratch) Status {
125 std.debug.assert(!self.active);
126 return .{
127 .capacity = self.capacity,
128 .last_epoch_peak_bytes = self.last_epoch_peak_bytes,
129 .high_water_bytes = self.high_water_bytes,
130 .epochs = self.epochs,
131 .exhaustions = self.exhaustions,
132 };
133 }
134
135 fn observe(self: *AtlasScratch) void {
136 self.epoch_peak_bytes = @max(self.epoch_peak_bytes, self.fixed.end_index);
137 }
138
139 fn alloc(context: *anyopaque, len: usize, alignment: std.mem.Alignment, return_address: usize) ?[*]u8 {
140 const self: *AtlasScratch = @ptrCast(@alignCast(context));
141 const result = std.heap.FixedBufferAllocator.alloc(&self.fixed, len, alignment, return_address) orelse {
142 self.exhaustions +|= 1;
143 return null;
144 };
145 self.observe();
146 return result;
147 }
148
149 fn resize(context: *anyopaque, memory: []u8, alignment: std.mem.Alignment, new_len: usize, return_address: usize) bool {
150 const self: *AtlasScratch = @ptrCast(@alignCast(context));
151 const resized = std.heap.FixedBufferAllocator.resize(&self.fixed, memory, alignment, new_len, return_address);
152 if (resized) self.observe();
153 return resized;
154 }
155
156 fn remap(context: *anyopaque, memory: []u8, alignment: std.mem.Alignment, new_len: usize, return_address: usize) ?[*]u8 {
157 const self: *AtlasScratch = @ptrCast(@alignCast(context));
158 const result = std.heap.FixedBufferAllocator.remap(&self.fixed, memory, alignment, new_len, return_address);
159 if (result != null) self.observe();
160 return result;
161 }
162
163 fn free(context: *anyopaque, memory: []u8, alignment: std.mem.Alignment, return_address: usize) void {
164 const self: *AtlasScratch = @ptrCast(@alignCast(context));
165 std.heap.FixedBufferAllocator.free(&self.fixed, memory, alignment, return_address);
166 }
167 };
168
169 pub const Atlas = struct {
170 allocator: Allocator,
171 pixel_size: i32,
172 atlas: filigree.GlyphAtlas,
173 image: OwnedAtlasImage,
174 glyph_index: std.AutoHashMapUnmanaged(u32, usize) = .empty,
175 cache: AtlasCacheStorage,
176 line_break_workspace: filigree.LineBreakWorkspace = .{},
177 composite_workspace: CompositeWorkspace = .{},
178 backend: Backend,
179
180 pub const Backend = union(enum) {
181 outline: Outline,
182 bitmap: Bitmap,
183 };
184
185 pub const Outline = struct {
186 font_bytes: []u8,
187 font: filigree.Font,
188 context: filigree.Context,
189 output: filigree.Output,
190 };
191
192 pub const Bitmap = struct {
193 advance: i32,
194 height: i32,
195 map: std.AutoHashMapUnmanaged(u21, u32) = .empty,
196 shaped: std.ArrayListUnmanaged(filigree.ShapedGlyph) = .empty,
197 clusters: std.ArrayListUnmanaged(filigree.Cluster) = .empty,
198 };
199
200 pub const CaretLine = struct {
201 content: []const u8,
202 run: filigree.GlyphRun,
203
204 pub inline fn advanceForByteOffset(self: CaretLine, byte_offset: usize) f32 {
205 return @call(.always_inline, filigree.caret.advanceForByteOffset, .{
206 self.run,
207 byte_offset,
208 self.content.len,
209 });
210 }
211
212 pub fn hitTestAdvance(self: CaretLine, advance: f32) filigree.caret.LineHit {
213 return filigree.caret.hitTestAdvance(self.run, advance, self.content);
214 }
215 };
216
217 pub fn initFromOwnedBytes(
218 allocator: Allocator,
219 scratch: *AtlasScratch,
220 bytes: []u8,
221 pixel_size: i32,
222 cache_limits: AtlasCacheStorage.Limits,
223 output_limits: filigree.Output.Limits,
224 ) !Atlas {
225 return initOutlineFromOwnedBytes(allocator, scratch, bytes, pixel_size, cache_limits, output_limits);
226 }
227
228 fn initOutlineFromOwnedBytes(
229 allocator: Allocator,
230 scratch: *AtlasScratch,
231 bytes: []u8,
232 pixel_size: i32,
233 cache_limits: AtlasCacheStorage.Limits,
234 output_limits: filigree.Output.Limits,
235 ) !Atlas {
236 errdefer allocator.free(bytes);
237 const scratch_allocator = scratch.begin();
238 defer scratch.end();
239 var font = filigree.Font.initFromBytes(bytes.ptr, bytes.len) orelse return error.InvalidFont;
240 errdefer font.deinit();
241 font.setScale(@floatFromInt(pixel_size), 72);
242
243 const glyph_count: u32 = font.face.num_glyphs;
244 const first_glyph_id: u32 = 1;
245 if (glyph_count <= first_glyph_id) return error.FontHasNoPrintableAscii;
246 const raster_glyph_count = glyph_count - first_glyph_id;
247 var glyph_ids: std.ArrayListUnmanaged(i32) = .empty;
248 defer glyph_ids.deinit(scratch_allocator);
249 var codepoints: std.ArrayListUnmanaged(i32) = .empty;
250 defer codepoints.deinit(scratch_allocator);
251 try glyph_ids.ensureTotalCapacity(scratch_allocator, raster_glyph_count);
252 try codepoints.ensureTotalCapacity(scratch_allocator, raster_glyph_count);
253 var glyph_id: u32 = first_glyph_id;
254 while (glyph_id < glyph_count) : (glyph_id += 1) {
255 glyph_ids.appendAssumeCapacity(std.math.cast(i32, glyph_id) orelse return error.GlyphIdTooLarge);
256 codepoints.appendAssumeCapacity(0);
257 }
258
259 var atlas = try filigree.glyphAtlasAlloc(allocator, scratch_allocator, bytes, pixel_size, glyph_ids.items, codepoints.items, 1);
260 errdefer atlas.deinit(allocator);
261 var image = try packAtlasImageAlloc(allocator, atlas);
262 errdefer image.deinit(allocator);
263 var glyph_index = try glyphIndexAlloc(allocator, atlas);
264 errdefer glyph_index.deinit(allocator);
265 var cache = try AtlasCacheStorage.init(allocator, cache_limits);
266 errdefer cache.deinit(allocator);
267 cache.activate();
268 return .{
269 .allocator = allocator,
270 .pixel_size = pixel_size,
271 .atlas = atlas,
272 .image = image,
273 .glyph_index = glyph_index,
274 .cache = cache,
275 .backend = .{ .outline = .{
276 .font_bytes = bytes,
277 .font = font,
278 .context = filigree.Context.init(allocator, .{}),
279 .output = try filigree.Output.init(allocator, output_limits),
280 } },
281 };
282 }
283
284 pub fn initFromBitmapGlyphs(
285 allocator: Allocator,
286 glyphs: []const BitmapGlyph,
287 cell: BitmapMetrics,
288 pixel_size: i32,
289 cache_limits: AtlasCacheStorage.Limits,
290 ) !Atlas {
291 if (glyphs.len == 0 or cell.width == 0 or cell.height == 0) return error.FontHasNoPrintableAscii;
292 const cell_width: usize = cell.width;
293 const cell_height: usize = cell.height;
294 const padded = cell_width + 1;
295 const atlas_width = std.math.mul(usize, padded, glyphs.len) catch return error.InvalidAtlas;
296 const pixel_total = try pixelCount(
297 std.math.cast(u32, atlas_width) orelse return error.InvalidAtlas,
298 std.math.cast(u32, cell_height) orelse return error.InvalidAtlas,
299 );
300
301 const rgba = try allocator.alloc(u8, pixel_total * 4);
302 errdefer allocator.free(rgba);
303 @memset(rgba, 0);
304 const atlas_glyphs = try allocator.alloc(filigree.GlyphAtlasGlyph, glyphs.len);
305 errdefer allocator.free(atlas_glyphs);
306 const recs = try allocator.alloc(filigree.GlyphAtlasRectangle, glyphs.len);
307 errdefer allocator.free(recs);
308
309 var map: std.AutoHashMapUnmanaged(u21, u32) = .empty;
310 errdefer map.deinit(allocator);
311
312 for (glyphs, 0..) |glyph, index| {
313 if (glyph.rows.len < @as(usize, cell.stride) * cell_height) return error.InvalidAtlas;
314 const origin_x = index * padded;
315 for (0..cell_height) |y| {
316 const row = glyph.rows[y * cell.stride ..][0..cell.stride];
317 for (0..cell_width) |x| {
318 const bit = (row[x / 8] >> @intCast(7 - (x % 8))) & 1;
319 if (bit == 0) continue;
320 const base = ((y * atlas_width) + origin_x + x) * 4;
321 rgba[base] = 255;
322 rgba[base + 1] = 255;
323 rgba[base + 2] = 255;
324 rgba[base + 3] = 255;
325 }
326 }
327 atlas_glyphs[index] = .{
328 .codepoint = std.math.cast(i32, glyph.codepoint) orelse return error.CodepointTooLarge,
329 .glyph_id = glyph.codepoint,
330 .width = @intCast(cell_width),
331 .height = @intCast(cell_height),
332 .offset_x = 0,
333 .offset_y = 0,
334 .advance_x = @intCast(cell_width),
335 };
336 recs[index] = .{
337 .x = @floatFromInt(origin_x),
338 .y = 0,
339 .width = @floatFromInt(cell_width),
340 .height = @floatFromInt(cell_height),
341 };
342 try map.put(allocator, glyph.codepoint, glyph.codepoint);
343 }
344
345 const atlas = filigree.GlyphAtlas{
346 .rgba = rgba,
347 .width = @intCast(atlas_width),
348 .height = @intCast(cell_height),
349 .glyphs = atlas_glyphs,
350 .recs = recs,
351 .base_size = @intCast(cell_height),
352 .glyph_padding = 1,
353 };
354 var image = try packAtlasImageAlloc(allocator, atlas);
355 errdefer image.deinit(allocator);
356 var glyph_index = try glyphIndexAlloc(allocator, atlas);
357 errdefer glyph_index.deinit(allocator);
358 var cache = try AtlasCacheStorage.init(allocator, cache_limits);
359 errdefer cache.deinit(allocator);
360 cache.activate();
361 return .{
362 .allocator = allocator,
363 .pixel_size = pixel_size,
364 .atlas = atlas,
365 .image = image,
366 .glyph_index = glyph_index,
367 .cache = cache,
368 .backend = .{ .bitmap = .{
369 .advance = @as(i32, @intCast(cell_width)) * 64,
370 .height = @intCast(cell_height),
371 .map = map,
372 } },
373 };
374 }
375
376 pub fn deinit(self: *Atlas) void {
377 self.cache.deinit(self.allocator);
378 self.line_break_workspace.deinit(self.allocator);
379 self.composite_workspace.deinit(self.allocator);
380 self.glyph_index.deinit(self.allocator);
381 self.image.deinit(self.allocator);
382 self.atlas.deinit(self.allocator);
383 switch (self.backend) {
384 .outline => |*outline| {
385 outline.output.deinit(self.allocator);
386 outline.context.deinit();
387 outline.font.deinit();
388 self.allocator.free(outline.font_bytes);
389 },
390 .bitmap => |*bitmap| {
391 bitmap.map.deinit(self.allocator);
392 bitmap.shaped.deinit(self.allocator);
393 bitmap.clusters.deinit(self.allocator);
394 },
395 }
396 self.* = undefined;
397 }
398
399 pub fn cacheStatus(self: *const Atlas) AtlasCacheStorage.Status {
400 return self.cache.status();
401 }
402
403 pub fn shape(self: *Atlas, content: []const u8) !filigree.GlyphRun {
404 if (self.cache.lookupShape(content)) |cached| return cached;
405 const fresh = try self.shapeUncached(content);
406 return self.cache.storeShape(content, fresh) catch fresh;
407 }
408
409 pub fn caretLine(self: *Atlas, content: []const u8) !CaretLine {
410 return .{ .content = content, .run = try self.shape(content) };
411 }
412
413 pub fn advanceForByteOffset(self: *Atlas, content: []const u8, byte_offset: usize) !f32 {
414 const line = try self.caretLine(content);
415 return line.advanceForByteOffset(byte_offset);
416 }
417
418 pub fn hitTestAdvance(self: *Atlas, content: []const u8, advance: f32) !filigree.caret.LineHit {
419 const line = try self.caretLine(content);
420 return line.hitTestAdvance(advance);
421 }
422
423 fn shapeUncached(self: *Atlas, content: []const u8) !filigree.GlyphRun {
424 switch (self.backend) {
425 .outline => |*outline| {
426 try outline.context.shapeRun(.{
427 .font = &outline.font,
428 .text = .{ .utf8 = content },
429 }, &outline.output);
430 return outline.output.run();
431 },
432 .bitmap => |*bitmap| return shapeBitmap(self.allocator, bitmap, content),
433 }
434 }
435
436 fn outlineFont(self: *Atlas) ?*const filigree.Font {
437 return switch (self.backend) {
438 .outline => |*outline| &outline.font,
439 .bitmap => null,
440 };
441 }
442
443 fn coversUtf8(self: *Atlas, content: []const u8) !bool {
444 const selected = self.outlineFont() orelse return true;
445 var index: usize = 0;
446 while (index < content.len) {
447 const sequence_len = std.unicode.utf8ByteSequenceLength(content[index]) catch return error.InvalidUtf8;
448 if (index + sequence_len > content.len) return error.InvalidUtf8;
449 const codepoint = std.unicode.utf8Decode(content[index .. index + sequence_len]) catch return error.InvalidUtf8;
450 if (selected.face.glyphId(codepoint) == 0) return false;
451 index += sequence_len;
452 }
453 return true;
454 }
455
456 pub fn metrics(self: *const Atlas) Metrics {
457 switch (self.backend) {
458 .outline => |*outline| {
459 const face = outline.font.face;
460 const upem: f32 = @floatFromInt(if (face.units_per_em == 0) 1000 else face.units_per_em);
461 const to_pixels = @as(f32, @floatFromInt(self.pixel_size)) / upem;
462 return .{
463 .ascent = @as(f32, @floatFromInt(face.ascender)) * to_pixels,
464 .descent = @as(f32, @floatFromInt(-@as(i32, face.descender))) * to_pixels,
465 .line_gap = @as(f32, @floatFromInt(face.line_gap)) * to_pixels,
466 };
467 },
468 .bitmap => |*bitmap| return .{
469 .ascent = @floatFromInt(bitmap.height),
470 .descent = 0,
471 .line_gap = 0,
472 },
473 }
474 }
475 };
476
477 pub const Metrics = struct {
478 ascent: f32,
479 descent: f32,
480 line_gap: f32,
481
482 pub fn height(self: Metrics) f32 {
483 return self.ascent + self.descent;
484 }
485 };
486
487 const CompositeGlyphSource = struct {
488 segment_index: u32,
489 };
490
491 const CompositeMetricSpan = struct {
492 byte_start: u32,
493 byte_end: u32,
494 atlas: *const Atlas,
495 image_index: u32,
496 scale_ratio: f32,
497 point_size: f32,
498 ascent: f32,
499 descent: f32,
500 };
501
502 const CompositeWorkspace = struct {
503 glyphs: std.ArrayListUnmanaged(filigree.ShapedGlyph) = .empty,
504 clusters: std.ArrayListUnmanaged(filigree.Cluster) = .empty,
505 ligature_carets: std.ArrayListUnmanaged(filigree.LigatureCaret) = .empty,
506 glyph_sources: std.ArrayListUnmanaged(CompositeGlyphSource) = .empty,
507 spans: std.ArrayListUnmanaged(CompositeMetricSpan) = .empty,
508
509 fn clear(self: *CompositeWorkspace) void {
510 self.glyphs.clearRetainingCapacity();
511 self.clusters.clearRetainingCapacity();
512 self.ligature_carets.clearRetainingCapacity();
513 self.glyph_sources.clearRetainingCapacity();
514 self.spans.clearRetainingCapacity();
515 }
516
517 fn deinit(self: *CompositeWorkspace, allocator: Allocator) void {
518 self.spans.deinit(allocator);
519 self.glyph_sources.deinit(allocator);
520 self.ligature_carets.deinit(allocator);
521 self.clusters.deinit(allocator);
522 self.glyphs.deinit(allocator);
523 self.* = .{};
524 }
525 };
526
527 const MeasureKey = struct {
528 content: []const u8,
529 styles: []const UiTextStyle,
530 runs: []const UiTextRun,
531 atlas_entries: []const AtlasSet.Entry,
532 fallback_entries: []const AtlasSet.Entry,
533 fallback_identity: usize,
534 font_asset_id: u64,
535 point_size_bits: u64,
536 line_height_bits: u64,
537 wrap_width_bits: u64,
538 device_scale_bits: u32,
539
540 fn fromText(text: UiText, atlases: ?*const AtlasSet) MeasureKey {
541 const include_metric_plan = atlases != null;
542 return .{
543 .content = text.content,
544 .styles = if (include_metric_plan) text.styles else &.{},
545 .runs = if (include_metric_plan) text.runs else &.{},
546 .atlas_entries = if (atlases) |set| set.entries else &.{},
547 .fallback_entries = if (atlases) |set| set.fallback_entries else &.{},
548 .fallback_identity = if (atlases) |set| if (set.fallback) |value| @intFromPtr(value) else 0 else 0,
549 .font_asset_id = if (include_metric_plan) text.font_asset_id else 0,
550 .point_size_bits = floatBits(text.point_size),
551 .line_height_bits = floatBits(text.line_height),
552 .wrap_width_bits = floatBits(text.wrap_width),
553 .device_scale_bits = if (atlases) |set| @bitCast(set.device_scale) else 0,
554 };
555 }
556 };
557
558 const MeasureContext = struct {
559 pub fn hash(_: MeasureContext, key: MeasureKey) u64 {
560 var hasher = std.hash.Wyhash.init(0);
561 hasher.update(key.content);
562 std.hash.autoHash(&hasher, key.font_asset_id);
563 hasher.update(std.mem.asBytes(&key.point_size_bits));
564 hasher.update(std.mem.asBytes(&key.line_height_bits));
565 hasher.update(std.mem.asBytes(&key.wrap_width_bits));
566 hasher.update(std.mem.asBytes(&key.device_scale_bits));
567 for (key.styles) |style| {
568 std.hash.autoHash(&hasher, style.font_asset_id);
569 std.hash.autoHash(&hasher, floatBits(style.point_size));
570 }
571 for (key.runs) |run| hashTextRun(&hasher, run);
572 for (key.atlas_entries) |entry| {
573 std.hash.autoHash(&hasher, entry.face);
574 std.hash.autoHash(&hasher, entry.image_index);
575 std.hash.autoHash(&hasher, @intFromPtr(entry.atlas));
576 }
577 for (key.fallback_entries) |entry| {
578 std.hash.autoHash(&hasher, entry.face);
579 std.hash.autoHash(&hasher, entry.image_index);
580 std.hash.autoHash(&hasher, @intFromPtr(entry.atlas));
581 }
582 std.hash.autoHash(&hasher, key.fallback_identity);
583 return hasher.final();
584 }
585
586 pub fn eql(_: MeasureContext, left: MeasureKey, right: MeasureKey) bool {
587 if (left.font_asset_id != right.font_asset_id or
588 left.point_size_bits != right.point_size_bits or
589 left.line_height_bits != right.line_height_bits or
590 left.wrap_width_bits != right.wrap_width_bits or
591 left.device_scale_bits != right.device_scale_bits or
592 left.fallback_identity != right.fallback_identity)
593 {
594 return false;
595 }
596 if (!std.mem.eql(u8, left.content, right.content)) return false;
597 if (left.styles.len != right.styles.len or
598 left.runs.len != right.runs.len or
599 left.atlas_entries.len != right.atlas_entries.len or
600 left.fallback_entries.len != right.fallback_entries.len)
601 {
602 return false;
603 }
604 for (left.styles, right.styles) |left_style, right_style| {
605 if (!gui.model.textStylesEqual(left_style, right_style)) return false;
606 }
607 for (left.runs, right.runs) |left_run, right_run| {
608 if (!textRunsEqual(left_run, right_run)) return false;
609 }
610 for (left.atlas_entries, right.atlas_entries) |left_entry, right_entry| {
611 if (left_entry.face != right_entry.face or
612 left_entry.image_index != right_entry.image_index or
613 left_entry.atlas != right_entry.atlas)
614 {
615 return false;
616 }
617 }
618 for (left.fallback_entries, right.fallback_entries) |left_entry, right_entry| {
619 if (left_entry.face != right_entry.face or
620 left_entry.image_index != right_entry.image_index or
621 left_entry.atlas != right_entry.atlas)
622 {
623 return false;
624 }
625 }
626 return true;
627 }
628 };
629
630 fn hashTextRun(hasher: anytype, run: UiTextRun) void {
631 std.hash.autoHash(hasher, run.byte_start);
632 std.hash.autoHash(hasher, run.byte_end);
633 std.hash.autoHash(hasher, run.style_slot);
634 hashOptionalTextColor(hasher, run.foreground);
635 hashOptionalTextColor(hasher, run.background);
636 std.hash.autoHash(hasher, run.underline);
637 std.hash.autoHash(hasher, run.strikethrough);
638 }
639
640 fn hashOptionalTextColor(hasher: anytype, color: ?Color) void {
641 std.hash.autoHash(hasher, color != null);
642 if (color) |value| {
643 std.hash.autoHash(hasher, value.r);
644 std.hash.autoHash(hasher, value.g);
645 std.hash.autoHash(hasher, value.b);
646 std.hash.autoHash(hasher, value.a);
647 }
648 }
649
650 fn textRunsEqual(left: UiTextRun, right: UiTextRun) bool {
651 return left.byte_start == right.byte_start and
652 left.byte_end == right.byte_end and
653 left.style_slot == right.style_slot and
654 std.meta.eql(left.foreground, right.foreground) and
655 std.meta.eql(left.background, right.background) and
656 left.underline == right.underline and
657 left.strikethrough == right.strikethrough;
658 }
659
660 const ShapedRun = struct {
661 glyphs: []const filigree.ShapedGlyph,
662 clusters: []const filigree.Cluster,
663 ligature_carets: []const filigree.LigatureCaret,
664 total_x_advance: i32,
665 total_y_advance: i32,
666 direction: filigree.Direction,
667 writing_mode: filigree.WritingMode,
668 output_order: filigree.OutputOrder,
669
670 fn run(self: ShapedRun) filigree.GlyphRun {
671 return .{
672 .glyphs = self.glyphs,
673 .clusters = self.clusters,
674 .ligature_carets = self.ligature_carets,
675 .total_x_advance = self.total_x_advance,
676 .total_y_advance = self.total_y_advance,
677 .direction = self.direction,
678 .writing_mode = self.writing_mode,
679 .output_order = self.output_order,
680 };
681 }
682 };
683
684 const MeasureCacheEntry = struct {
685 hash: u64,
686 key: MeasureKey,
687 value: Size,
688 };
689
690 const ShapeCacheEntry = struct {
691 hash: u64,
692 content: []const u8,
693 shaped: ShapedRun,
694 };
695
696 const CacheEpochUsage = struct {
697 entries: usize = 0,
698 payload_bytes: usize = 0,
699 high_water_entries: usize = 0,
700 high_water_payload_bytes: usize = 0,
701 high_water_physical_payload_bytes: usize = 0,
702 rollovers: u64 = 0,
703 disabled_bypasses: u64 = 0,
704 oversize_bypasses: u64 = 0,
705
706 fn inserted(self: *CacheEpochUsage, raw_bytes: usize, physical_bytes: usize) void {
707 self.entries += 1;
708 self.payload_bytes += raw_bytes;
709 self.high_water_entries = @max(self.high_water_entries, self.entries);
710 self.high_water_payload_bytes = @max(self.high_water_payload_bytes, self.payload_bytes);
711 self.high_water_physical_payload_bytes = @max(
712 self.high_water_physical_payload_bytes,
713 physical_bytes,
714 );
715 }
716
717 fn replaced(self: *CacheEpochUsage) void {
718 self.entries = 0;
719 self.payload_bytes = 0;
720 self.rollovers +|= 1;
721 }
722 };
723
724 const CachePayload = struct {
725 bytes: []u8,
726 cursor: usize = 0,
727
728 fn reset(self: *CachePayload) void {
729 self.cursor = 0;
730 }
731
732 fn dupe(self: *CachePayload, comptime T: type, source: []const T) []T {
733 const start = cacheAligned(self.cursor, @alignOf(T)) catch unreachable;
734 const byte_count = cacheMultiplied(source.len, @sizeOf(T)) catch unreachable;
735 const end = cacheAdded(start, byte_count) catch unreachable;
736 std.debug.assert(end <= self.bytes.len);
737 const region: []align(@alignOf(T)) u8 = @alignCast(self.bytes[start..end]);
738 const owned = std.mem.bytesAsSlice(T, region);
739 @memcpy(owned, source);
740 self.cursor = end;
741 return owned;
742 }
743 };
744
745 pub const AtlasCacheStorage = struct {
746 phase: alloc_phase.capacity.Phase,
747 capacity: Capacity,
748 bytes: []align(storage_alignment) u8,
749 measure_slots: []u32,
750 measure_entries: []MeasureCacheEntry,
751 measure_payload: CachePayload,
752 measure_usage: CacheEpochUsage = .{},
753 shape_slots: []u32,
754 shape_entries: []ShapeCacheEntry,
755 shape_payload: CachePayload,
756 shape_usage: CacheEpochUsage = .{},
757
758 pub const storage_alignment: usize = @max(
759 @alignOf(MeasureCacheEntry),
760 @max(
761 @alignOf(ShapeCacheEntry),
762 @max(measure_payload_alignment, shape_payload_alignment),
763 ),
764 );
765
766 pub const Limits = struct {
767 measure_entries: usize,
768 measure_payload_bytes: usize,
769 shape_entries: usize,
770 shape_payload_bytes: usize,
771 };
772
773 pub const DeriveError = error{
774 InvalidMeasureLimits,
775 InvalidShapeLimits,
776 EntryLimitTooLarge,
777 CapacityOverflow,
778 };
779
780 pub const Exhaustion = error{
781 CacheDisabled,
782 EntryTooLarge,
783 };
784
785 pub const Capacity = struct {
786 limits: Limits,
787 measure_index_slots: usize,
788 measure_index_offset: usize,
789 measure_entries_offset: usize,
790 measure_payload_offset: usize,
791 measure_payload_storage_bytes: usize,
792 shape_index_slots: usize,
793 shape_index_offset: usize,
794 shape_entries_offset: usize,
795 shape_payload_offset: usize,
796 shape_payload_storage_bytes: usize,
797 storage_bytes: usize,
798
799 pub fn derive(limits: Limits) DeriveError!Capacity {
800 try cacheLimitsValid(limits);
801 const measure_index_slots = try cacheIndexSlots(limits.measure_entries);
802 const shape_index_slots = try cacheIndexSlots(limits.shape_entries);
803 const measure_padding = try cacheMultiplied(
804 limits.measure_entries,
805 measure_padding_per_entry,
806 );
807 const shape_padding = try cacheMultiplied(
808 limits.shape_entries,
809 shape_padding_per_entry,
810 );
811 const measure_payload_bytes = try cacheAdded(
812 limits.measure_payload_bytes,
813 measure_padding,
814 );
815 const shape_payload_bytes = try cacheAdded(
816 limits.shape_payload_bytes,
817 shape_padding,
818 );
819 const measure_index = try cachePlaced(u32, 0, measure_index_slots);
820 const measure_entries = try cachePlaced(
821 MeasureCacheEntry,
822 measure_index.end,
823 limits.measure_entries,
824 );
825 const measure_payload = try cachePlacedBytes(
826 measure_entries.end,
827 measure_payload_alignment,
828 measure_payload_bytes,
829 );
830 const shape_index = try cachePlaced(u32, measure_payload.end, shape_index_slots);
831 const shape_entries = try cachePlaced(
832 ShapeCacheEntry,
833 shape_index.end,
834 limits.shape_entries,
835 );
836 const shape_payload = try cachePlacedBytes(
837 shape_entries.end,
838 shape_payload_alignment,
839 shape_payload_bytes,
840 );
841 return .{
842 .limits = limits,
843 .measure_index_slots = measure_index_slots,
844 .measure_index_offset = measure_index.start,
845 .measure_entries_offset = measure_entries.start,
846 .measure_payload_offset = measure_payload.start,
847 .measure_payload_storage_bytes = measure_payload.bytes,
848 .shape_index_slots = shape_index_slots,
849 .shape_index_offset = shape_index.start,
850 .shape_entries_offset = shape_entries.start,
851 .shape_payload_offset = shape_payload.start,
852 .shape_payload_storage_bytes = shape_payload.bytes,
853 .storage_bytes = shape_payload.end,
854 };
855 }
856 };
857
858 pub const EpochStatus = struct {
859 entry_capacity: usize,
860 payload_capacity_bytes: usize,
861 physical_payload_capacity_bytes: usize,
862 entries: usize,
863 payload_bytes: usize,
864 physical_payload_bytes: usize,
865 high_water_entries: usize,
866 high_water_payload_bytes: usize,
867 high_water_physical_payload_bytes: usize,
868 rollovers: u64,
869 disabled_bypasses: u64,
870 oversize_bypasses: u64,
871 };
872
873 pub const Status = struct {
874 phase: alloc_phase.capacity.Phase,
875 capacity: Capacity,
876 storage_bytes: usize,
877 measure: EpochStatus,
878 shape: EpochStatus,
879 };
880
881 pub const claim: alloc_phase.capacity.Declaration = .{
882 .source = .{
883 .id = "gui.text_atlas_cache_storage",
884 .kind = .phase_static,
885 .limit_source = .caller,
886 .storage = .{
887 .covered = &.{
888 .{
889 .id = "measure_cache_fixed_index_and_dense_entries",
890 .lifetime = .steady,
891 .detail = "measure cache fixed index and dense entries",
892 },
893 .{
894 .id = "measure_cache_aligned_payload_bytes",
895 .lifetime = .steady,
896 .detail = "measure cache aligned payload bytes",
897 },
898 .{
899 .id = "shape_cache_fixed_index_and_dense_entries",
900 .lifetime = .steady,
901 .detail = "shape cache fixed index and dense entries",
902 },
903 .{
904 .id = "shape_cache_aligned_payload_bytes",
905 .lifetime = .steady,
906 .detail = "shape cache aligned payload bytes",
907 },
908 },
909 .excluded = &.{
910 "outline shaping context, output, and foreign backend storage",
911 "bitmap shaping, line-break, and composite workspaces",
912 "font bytes, glyph atlas, image, glyph index, and bitmap map",
913 "caller-owned text, returned data, and Renderer storage",
914 },
915 },
916 .capacity = .{
917 .inputs = &.{
918 alloc_phase.capacity.bindInput(Limits, "measure_entries", "measure_entries"),
919 alloc_phase.capacity.bindInput(Limits, "shape_entries", "shape_entries"),
920 alloc_phase.capacity.bindInput(Limits, "measure_payload_bytes", "measure_payload_bytes"),
921 alloc_phase.capacity.bindInput(Limits, "shape_payload_bytes", "shape_payload_bytes"),
922 },
923 .type_selectors = &.{
924 alloc_phase.capacity.bindType(u32, "u32"),
925 alloc_phase.capacity.bindType(MeasureCacheEntry, "measureentry"),
926 alloc_phase.capacity.bindType(ShapeCacheEntry, "shapeentry"),
927 },
928 .nodes = &.{
929 .{ .input = 0 },
930 .{ .scale = .{ .node = 0, .coefficient = .{ .literal = 2 } } },
931 .{ .next_power_of_two = 1 },
932 .{ .input = 1 },
933 .{ .scale = .{ .node = 3, .coefficient = .{ .literal = 2 } } },
934 .{ .next_power_of_two = 4 },
935 .{ .scale = .{ .node = 2, .coefficient = .{ .size_of_concrete_type = 0 } } },
936 .{ .alignment = .{ .node = 6, .alignment = .{ .literal = 16 } } },
937 .{ .scale = .{ .node = 0, .coefficient = .{ .size_of_concrete_type = 1 } } },
938 .{ .alignment = .{ .node = 8, .alignment = .{ .literal = 16 } } },
939 .{ .input = 2 },
940 .{ .alignment = .{ .node = 10, .alignment = .{ .literal = 16 } } },
941 .{ .scale = .{ .node = 5, .coefficient = .{ .size_of_concrete_type = 0 } } },
942 .{ .alignment = .{ .node = 12, .alignment = .{ .literal = 16 } } },
943 .{ .scale = .{ .node = 3, .coefficient = .{ .size_of_concrete_type = 2 } } },
944 .{ .alignment = .{ .node = 14, .alignment = .{ .literal = 16 } } },
945 .{ .input = 3 },
946 .{ .alignment = .{ .node = 16, .alignment = .{ .literal = 16 } } },
947 .{ .add = .{ .left = 7, .right = 9 } },
948 .{ .add = .{ .left = 18, .right = 11 } },
949 .{ .add = .{ .left = 19, .right = 13 } },
950 .{ .add = .{ .left = 20, .right = 15 } },
951 .{ .add = .{ .left = 21, .right = 17 } },
952 },
953 .assertions = &.{.{
954 .scope = .closure_total,
955 .measure = .retained,
956 .relation = .exact,
957 .expression = 22,
958 }},
959 },
960 .overload = .{
961 .kind = .replace,
962 .detail = "aggregate entry or payload exhaustion replaces one epoch; disabled and individually oversized records bypass before mutation",
963 },
964 .risks = .{
965 .transitive = .{
966 .status = .excluded,
967 .detail = "uncached Atlas shaping and measurement can allocate in backend and workspace owners outside this cache-only claim",
968 },
969 .foreign = .{
970 .status = .excluded,
971 .detail = "cache lookup, copying, and replacement cross no foreign boundary; outline shaping remains excluded",
972 },
973 },
974 .obligations = &.{
975 .{ .key = "gui_text_atlas_cache_capacity", .role = .capacity_model },
976 .{ .key = "gui_text_atlas_cache_acquisition", .role = .custom },
977 .{ .key = "gui_text_atlas_cache_oom", .role = .custom },
978 .{ .key = "gui_text_atlas_cache_boundaries", .role = .overload },
979 .{ .key = "gui_text_atlas_cache_sealed_transitive_risk", .role = .transitive_risk },
980 .{ .key = "gui_text_atlas_cache_sealed_foreign_risk", .role = .foreign_risk },
981 .{ .key = "gui_text_atlas_cache_collisions", .role = .custom },
982 .{ .key = "gui_text_atlas_cache_stability", .role = .custom },
983 .{ .key = "gui_text_atlas_cache_differential", .role = .overload },
984 .{ .key = "gui_text_atlas_cache_root", .role = .custom },
985 },
986 },
987 .bindings = .{
988 .owner = @This(),
989 .seal = .{
990 .family = alloc_phase.capacity.selector(@This().activate),
991 .premise = .{
992 .class = .checked_semantic_fact,
993 .authority = .checker,
994 },
995 },
996 .teardown = .{
997 .family = alloc_phase.capacity.selector(@This().deinit),
998 .premise = .{
999 .class = .checked_semantic_fact,
1000 .authority = .checker,
1001 },
1002 },
1003 },
1004 };
1005
1006 pub fn init(allocator: Allocator, limits: Limits) (Allocator.Error || DeriveError)!AtlasCacheStorage {
1007 const capacity = try Capacity.derive(limits);
1008 const bytes = try allocator.alignedAlloc(
1009 u8,
1010 .fromByteUnits(storage_alignment),
1011 capacity.storage_bytes,
1012 );
1013 return .{
1014 .phase = .initialization,
1015 .capacity = capacity,
1016 .bytes = bytes,
1017 .measure_slots = cacheTypedSlice(
1018 u32,
1019 bytes,
1020 capacity.measure_index_offset,
1021 capacity.measure_index_slots,
1022 ),
1023 .measure_entries = cacheTypedSlice(
1024 MeasureCacheEntry,
1025 bytes,
1026 capacity.measure_entries_offset,
1027 limits.measure_entries,
1028 ),
1029 .measure_payload = .{ .bytes = bytes[capacity.measure_payload_offset..][0..capacity.measure_payload_storage_bytes] },
1030 .shape_slots = cacheTypedSlice(
1031 u32,
1032 bytes,
1033 capacity.shape_index_offset,
1034 capacity.shape_index_slots,
1035 ),
1036 .shape_entries = cacheTypedSlice(
1037 ShapeCacheEntry,
1038 bytes,
1039 capacity.shape_entries_offset,
1040 limits.shape_entries,
1041 ),
1042 .shape_payload = .{ .bytes = bytes[capacity.shape_payload_offset..][0..capacity.shape_payload_storage_bytes] },
1043 };
1044 }
1045
1046 pub fn activate(self: *AtlasCacheStorage) void {
1047 self.assertStorage();
1048 std.debug.assert(self.phase == .initialization);
1049 @memset(self.measure_slots, 0);
1050 @memset(self.shape_slots, 0);
1051 self.phase = .steady;
1052 self.assertStorage();
1053 }
1054
1055 pub fn deinit(self: *AtlasCacheStorage, allocator: Allocator) void {
1056 self.assertStorage();
1057 std.debug.assert(self.phase != .teardown);
1058 self.phase = .teardown;
1059 allocator.free(self.bytes);
1060 self.bytes = &.{};
1061 self.measure_slots = &.{};
1062 self.measure_entries = &.{};
1063 self.measure_payload = .{ .bytes = &.{} };
1064 self.shape_slots = &.{};
1065 self.shape_entries = &.{};
1066 self.shape_payload = .{ .bytes = &.{} };
1067 }
1068
1069 pub fn status(self: *const AtlasCacheStorage) Status {
1070 self.assertStorage();
1071 return .{
1072 .phase = self.phase,
1073 .capacity = self.capacity,
1074 .storage_bytes = self.capacity.storage_bytes,
1075 .measure = cacheEpochStatus(
1076 self.capacity.limits.measure_entries,
1077 self.capacity.limits.measure_payload_bytes,
1078 self.capacity.measure_payload_storage_bytes,
1079 self.measure_payload.cursor,
1080 self.measure_usage,
1081 ),
1082 .shape = cacheEpochStatus(
1083 self.capacity.limits.shape_entries,
1084 self.capacity.limits.shape_payload_bytes,
1085 self.capacity.shape_payload_storage_bytes,
1086 self.shape_payload.cursor,
1087 self.shape_usage,
1088 ),
1089 };
1090 }
1091
1092 fn lookupMeasure(self: *const AtlasCacheStorage, key: MeasureKey) ?Size {
1093 self.assertStorage();
1094 std.debug.assert(self.phase == .steady);
1095 if (self.measure_slots.len == 0) return null;
1096 const hash = MeasureContext.hash(.{}, key);
1097 var slot = cacheStartSlot(hash, self.measure_slots.len);
1098 var probes: usize = 0;
1099 while (probes < self.measure_slots.len) : (probes += 1) {
1100 const encoded = self.measure_slots[slot];
1101 if (encoded == 0) return null;
1102 const entry = self.measure_entries[encoded - 1];
1103 if (entry.hash == hash and MeasureContext.eql(.{}, entry.key, key)) {
1104 return entry.value;
1105 }
1106 slot = cacheNextSlot(slot, self.measure_slots.len);
1107 }
1108 unreachable;
1109 }
1110
1111 fn lookupShape(self: *const AtlasCacheStorage, content: []const u8) ?filigree.GlyphRun {
1112 self.assertStorage();
1113 std.debug.assert(self.phase == .steady);
1114 if (self.shape_slots.len == 0) return null;
1115 const hash = std.hash_map.hashString(content);
1116 var slot = cacheStartSlot(hash, self.shape_slots.len);
1117 var probes: usize = 0;
1118 while (probes < self.shape_slots.len) : (probes += 1) {
1119 const encoded = self.shape_slots[slot];
1120 if (encoded == 0) return null;
1121 const entry = self.shape_entries[encoded - 1];
1122 if (entry.hash == hash and std.mem.eql(u8, entry.content, content)) {
1123 return entry.shaped.run();
1124 }
1125 slot = cacheNextSlot(slot, self.shape_slots.len);
1126 }
1127 unreachable;
1128 }
1129
1130 fn storeMeasure(
1131 self: *AtlasCacheStorage,
1132 key: MeasureKey,
1133 value: Size,
1134 ) Exhaustion!Size {
1135 self.assertStorage();
1136 defer self.assertStorage();
1137 std.debug.assert(self.phase == .steady);
1138 std.debug.assert(self.lookupMeasure(key) == null);
1139 const payload_bytes = measurePayloadBytes(key) orelse {
1140 self.measure_usage.oversize_bypasses +|= 1;
1141 return error.EntryTooLarge;
1142 };
1143 try self.prepareMeasure(payload_bytes);
1144 const hash = MeasureContext.hash(.{}, key);
1145 const slot = cacheEmptySlot(self.measure_slots, hash);
1146 const entry_index = self.measure_usage.entries;
1147 var owned_key = key;
1148 owned_key.content = self.measure_payload.dupe(u8, key.content);
1149 owned_key.styles = self.measure_payload.dupe(UiTextStyle, key.styles);
1150 owned_key.runs = self.measure_payload.dupe(UiTextRun, key.runs);
1151 owned_key.atlas_entries = self.measure_payload.dupe(AtlasSet.Entry, key.atlas_entries);
1152 owned_key.fallback_entries = self.measure_payload.dupe(AtlasSet.Entry, key.fallback_entries);
1153 self.measure_entries[entry_index] = .{ .hash = hash, .key = owned_key, .value = value };
1154 self.measure_slots[slot] = @intCast(entry_index + 1);
1155 self.measure_usage.inserted(payload_bytes, self.measure_payload.cursor);
1156 return value;
1157 }
1158
1159 fn storeShape(
1160 self: *AtlasCacheStorage,
1161 content: []const u8,
1162 fresh: filigree.GlyphRun,
1163 ) Exhaustion!filigree.GlyphRun {
1164 self.assertStorage();
1165 defer self.assertStorage();
1166 std.debug.assert(self.phase == .steady);
1167 std.debug.assert(self.lookupShape(content) == null);
1168 const payload_bytes = shapePayloadBytes(content, fresh) orelse {
1169 self.shape_usage.oversize_bypasses +|= 1;
1170 return error.EntryTooLarge;
1171 };
1172 try self.prepareShape(payload_bytes);
1173 const hash = std.hash_map.hashString(content);
1174 const slot = cacheEmptySlot(self.shape_slots, hash);
1175 const entry_index = self.shape_usage.entries;
1176 const owned_content = self.shape_payload.dupe(u8, content);
1177 const shaped = ShapedRun{
1178 .glyphs = self.shape_payload.dupe(filigree.ShapedGlyph, fresh.glyphs),
1179 .clusters = self.shape_payload.dupe(filigree.Cluster, fresh.clusters),
1180 .ligature_carets = self.shape_payload.dupe(
1181 filigree.LigatureCaret,
1182 fresh.ligature_carets,
1183 ),
1184 .total_x_advance = fresh.total_x_advance,
1185 .total_y_advance = fresh.total_y_advance,
1186 .direction = fresh.direction,
1187 .writing_mode = fresh.writing_mode,
1188 .output_order = fresh.output_order,
1189 };
1190 self.shape_entries[entry_index] = .{
1191 .hash = hash,
1192 .content = owned_content,
1193 .shaped = shaped,
1194 };
1195 self.shape_slots[slot] = @intCast(entry_index + 1);
1196 self.shape_usage.inserted(payload_bytes, self.shape_payload.cursor);
1197 return shaped.run();
1198 }
1199
1200 pub fn admitShapePayload(
1201 self: *const AtlasCacheStorage,
1202 payload_bytes: usize,
1203 ) Exhaustion!void {
1204 self.assertStorage();
1205 std.debug.assert(self.phase == .steady);
1206 const limits = self.capacity.limits;
1207 if (limits.shape_entries == 0) return error.CacheDisabled;
1208 if (payload_bytes > limits.shape_payload_bytes) return error.EntryTooLarge;
1209 }
1210
1211 fn prepareMeasure(self: *AtlasCacheStorage, payload_bytes: usize) Exhaustion!void {
1212 const limits = self.capacity.limits;
1213 if (limits.measure_entries == 0) {
1214 self.measure_usage.disabled_bypasses +|= 1;
1215 return error.CacheDisabled;
1216 }
1217 if (payload_bytes > limits.measure_payload_bytes) {
1218 self.measure_usage.oversize_bypasses +|= 1;
1219 return error.EntryTooLarge;
1220 }
1221 std.debug.assert(self.measure_usage.payload_bytes <= limits.measure_payload_bytes);
1222 const payload_full = payload_bytes >
1223 limits.measure_payload_bytes - self.measure_usage.payload_bytes;
1224 if (self.measure_usage.entries == limits.measure_entries or payload_full) {
1225 self.replaceMeasure();
1226 }
1227 }
1228
1229 fn prepareShape(self: *AtlasCacheStorage, payload_bytes: usize) Exhaustion!void {
1230 const limits = self.capacity.limits;
1231 self.admitShapePayload(payload_bytes) catch |err| {
1232 switch (err) {
1233 error.CacheDisabled => self.shape_usage.disabled_bypasses +|= 1,
1234 error.EntryTooLarge => self.shape_usage.oversize_bypasses +|= 1,
1235 }
1236 return err;
1237 };
1238 std.debug.assert(self.shape_usage.payload_bytes <= limits.shape_payload_bytes);
1239 const payload_full = payload_bytes >
1240 limits.shape_payload_bytes - self.shape_usage.payload_bytes;
1241 if (self.shape_usage.entries == limits.shape_entries or payload_full) {
1242 self.replaceShape();
1243 }
1244 }
1245
1246 fn replaceMeasure(self: *AtlasCacheStorage) void {
1247 self.assertStorage();
1248 defer self.assertStorage();
1249 @memset(self.measure_slots, 0);
1250 self.measure_payload.reset();
1251 self.measure_usage.replaced();
1252 }
1253
1254 fn replaceShape(self: *AtlasCacheStorage) void {
1255 self.assertStorage();
1256 defer self.assertStorage();
1257 @memset(self.shape_slots, 0);
1258 self.shape_payload.reset();
1259 self.shape_usage.replaced();
1260 }
1261
1262 fn assertStorage(self: *const AtlasCacheStorage) void {
1263 const limits = self.capacity.limits;
1264 std.debug.assert(self.bytes.len == self.capacity.storage_bytes);
1265 std.debug.assert(self.measure_slots.len == self.capacity.measure_index_slots);
1266 std.debug.assert(self.measure_entries.len == limits.measure_entries);
1267 std.debug.assert(
1268 self.measure_payload.bytes.len == self.capacity.measure_payload_storage_bytes,
1269 );
1270 std.debug.assert(self.shape_slots.len == self.capacity.shape_index_slots);
1271 std.debug.assert(self.shape_entries.len == limits.shape_entries);
1272 std.debug.assert(
1273 self.shape_payload.bytes.len == self.capacity.shape_payload_storage_bytes,
1274 );
1275 const base = @intFromPtr(self.bytes.ptr);
1276 assertCacheAddress(base, self.capacity.measure_index_offset, self.measure_slots);
1277 assertCacheAddress(base, self.capacity.measure_entries_offset, self.measure_entries);
1278 assertCacheAddress(base, self.capacity.measure_payload_offset, self.measure_payload.bytes);
1279 assertCacheAddress(base, self.capacity.shape_index_offset, self.shape_slots);
1280 assertCacheAddress(base, self.capacity.shape_entries_offset, self.shape_entries);
1281 assertCacheAddress(base, self.capacity.shape_payload_offset, self.shape_payload.bytes);
1282 std.debug.assert(self.measure_usage.entries <= limits.measure_entries);
1283 std.debug.assert(self.measure_usage.payload_bytes <= limits.measure_payload_bytes);
1284 std.debug.assert(self.measure_payload.cursor <= self.measure_payload.bytes.len);
1285 std.debug.assert(self.shape_usage.entries <= limits.shape_entries);
1286 std.debug.assert(self.shape_usage.payload_bytes <= limits.shape_payload_bytes);
1287 std.debug.assert(self.shape_payload.cursor <= self.shape_payload.bytes.len);
1288 }
1289 };
1290
1291 const measure_payload_alignment: usize = @max(
1292 @alignOf(UiTextStyle),
1293 @max(@alignOf(UiTextRun), @alignOf(AtlasSet.Entry)),
1294 );
1295 const shape_payload_alignment: usize = @max(
1296 @alignOf(filigree.ShapedGlyph),
1297 @max(@alignOf(filigree.Cluster), @alignOf(filigree.LigatureCaret)),
1298 );
1299 const measure_padding_per_entry: usize = @alignOf(UiTextStyle) - 1 +
1300 @alignOf(UiTextRun) - 1 + @alignOf(AtlasSet.Entry) - 1;
1301 const shape_padding_per_entry: usize = @alignOf(filigree.ShapedGlyph) - 1 +
1302 @alignOf(filigree.Cluster) - 1 + @alignOf(filigree.LigatureCaret) - 1;
1303
1304 const CacheRegion = struct {
1305 start: usize,
1306 bytes: usize,
1307 end: usize,
1308 };
1309
1310 fn cacheLimitsValid(limits: AtlasCacheStorage.Limits) AtlasCacheStorage.DeriveError!void {
1311 const measure_disabled = limits.measure_entries == 0 and limits.measure_payload_bytes == 0;
1312 const measure_enabled = limits.measure_entries != 0 and limits.measure_payload_bytes != 0;
1313 if (!measure_disabled and !measure_enabled) return error.InvalidMeasureLimits;
1314 const shape_disabled = limits.shape_entries == 0 and limits.shape_payload_bytes == 0;
1315 const shape_enabled = limits.shape_entries != 0 and limits.shape_payload_bytes != 0;
1316 if (!shape_disabled and !shape_enabled) return error.InvalidShapeLimits;
1317 if (limits.measure_entries > std.math.maxInt(u32)) return error.EntryLimitTooLarge;
1318 if (limits.shape_entries > std.math.maxInt(u32)) return error.EntryLimitTooLarge;
1319 }
1320
1321 fn cacheIndexSlots(entries: usize) AtlasCacheStorage.DeriveError!usize {
1322 if (entries == 0) return 0;
1323 const doubled = try cacheMultiplied(entries, 2);
1324 return std.math.ceilPowerOfTwo(usize, doubled) catch error.CapacityOverflow;
1325 }
1326
1327 fn cacheAdded(left: usize, right: usize) AtlasCacheStorage.DeriveError!usize {
1328 return std.math.add(usize, left, right) catch error.CapacityOverflow;
1329 }
1330
1331 fn cacheMultiplied(left: usize, right: usize) AtlasCacheStorage.DeriveError!usize {
1332 return std.math.mul(usize, left, right) catch error.CapacityOverflow;
1333 }
1334
1335 fn cacheAligned(offset: usize, alignment: usize) AtlasCacheStorage.DeriveError!usize {
1336 const mask = alignment - 1;
1337 return (try cacheAdded(offset, mask)) & ~mask;
1338 }
1339
1340 fn cachePlaced(comptime T: type, offset: usize, count: usize) AtlasCacheStorage.DeriveError!CacheRegion {
1341 return cachePlacedBytes(offset, @alignOf(T), try cacheMultiplied(count, @sizeOf(T)));
1342 }
1343
1344 fn cachePlacedBytes(
1345 offset: usize,
1346 alignment: usize,
1347 byte_count: usize,
1348 ) AtlasCacheStorage.DeriveError!CacheRegion {
1349 const start = try cacheAligned(offset, alignment);
1350 return .{ .start = start, .bytes = byte_count, .end = try cacheAdded(start, byte_count) };
1351 }
1352
1353 fn cacheTypedSlice(
1354 comptime T: type,
1355 bytes: []align(AtlasCacheStorage.storage_alignment) u8,
1356 offset: usize,
1357 count: usize,
1358 ) []T {
1359 if (count == 0) return &.{};
1360 const byte_count = count * @sizeOf(T);
1361 const region: []align(@alignOf(T)) u8 = @alignCast(bytes[offset..][0..byte_count]);
1362 return std.mem.bytesAsSlice(T, region);
1363 }
1364
1365 fn assertCacheAddress(base: usize, offset: usize, region: anytype) void {
1366 if (region.len == 0) return;
1367 std.debug.assert(@intFromPtr(region.ptr) == base + offset);
1368 }
1369
1370 fn cacheStartSlot(hash: u64, slot_count: usize) usize {
1371 std.debug.assert(std.math.isPowerOfTwo(slot_count));
1372 const truncated: usize = @truncate(hash);
1373 return truncated & (slot_count - 1);
1374 }
1375
1376 fn cacheNextSlot(slot: usize, slot_count: usize) usize {
1377 std.debug.assert(std.math.isPowerOfTwo(slot_count));
1378 return (slot + 1) & (slot_count - 1);
1379 }
1380
1381 fn cacheEmptySlot(slots: []const u32, hash: u64) usize {
1382 var slot = cacheStartSlot(hash, slots.len);
1383 var probes: usize = 0;
1384 while (probes < slots.len) : (probes += 1) {
1385 if (slots[slot] == 0) return slot;
1386 slot = cacheNextSlot(slot, slots.len);
1387 }
1388 unreachable;
1389 }
1390
1391 fn cacheEpochStatus(
1392 entry_capacity: usize,
1393 payload_capacity_bytes: usize,
1394 physical_payload_capacity_bytes: usize,
1395 physical_payload_bytes: usize,
1396 usage: CacheEpochUsage,
1397 ) AtlasCacheStorage.EpochStatus {
1398 return .{
1399 .entry_capacity = entry_capacity,
1400 .payload_capacity_bytes = payload_capacity_bytes,
1401 .physical_payload_capacity_bytes = physical_payload_capacity_bytes,
1402 .entries = usage.entries,
1403 .payload_bytes = usage.payload_bytes,
1404 .physical_payload_bytes = physical_payload_bytes,
1405 .high_water_entries = usage.high_water_entries,
1406 .high_water_payload_bytes = usage.high_water_payload_bytes,
1407 .high_water_physical_payload_bytes = usage.high_water_physical_payload_bytes,
1408 .rollovers = usage.rollovers,
1409 .disabled_bypasses = usage.disabled_bypasses,
1410 .oversize_bypasses = usage.oversize_bypasses,
1411 };
1412 }
1413
1414 const CacheModelRegion = struct {
1415 start: u128,
1416 bytes: u128,
1417 end: u128,
1418 };
1419
1420 const CacheModelLayout = struct {
1421 measure_slots: u128,
1422 measure_index: CacheModelRegion,
1423 measure_entries: CacheModelRegion,
1424 measure_payload: CacheModelRegion,
1425 shape_slots: u128,
1426 shape_index: CacheModelRegion,
1427 shape_entries: CacheModelRegion,
1428 shape_payload: CacheModelRegion,
1429
1430 fn capacity(
1431 self: CacheModelLayout,
1432 limits: AtlasCacheStorage.Limits,
1433 ) AtlasCacheStorage.DeriveError!AtlasCacheStorage.Capacity {
1434 const values = [_]u128{
1435 self.measure_slots,
1436 self.measure_index.start,
1437 self.measure_entries.start,
1438 self.measure_payload.start,
1439 self.measure_payload.bytes,
1440 self.shape_slots,
1441 self.shape_index.start,
1442 self.shape_entries.start,
1443 self.shape_payload.start,
1444 self.shape_payload.bytes,
1445 self.shape_payload.end,
1446 };
1447 for (values) |value| {
1448 if (value > std.math.maxInt(usize)) return error.CapacityOverflow;
1449 }
1450 return .{
1451 .limits = limits,
1452 .measure_index_slots = @intCast(self.measure_slots),
1453 .measure_index_offset = @intCast(self.measure_index.start),
1454 .measure_entries_offset = @intCast(self.measure_entries.start),
1455 .measure_payload_offset = @intCast(self.measure_payload.start),
1456 .measure_payload_storage_bytes = @intCast(self.measure_payload.bytes),
1457 .shape_index_slots = @intCast(self.shape_slots),
1458 .shape_index_offset = @intCast(self.shape_index.start),
1459 .shape_entries_offset = @intCast(self.shape_entries.start),
1460 .shape_payload_offset = @intCast(self.shape_payload.start),
1461 .shape_payload_storage_bytes = @intCast(self.shape_payload.bytes),
1462 .storage_bytes = @intCast(self.shape_payload.end),
1463 };
1464 }
1465 };
1466
1467 fn modelAtlasCacheLimitsValid(
1468 limits: AtlasCacheStorage.Limits,
1469 ) AtlasCacheStorage.DeriveError!void {
1470 const measure_disabled = limits.measure_entries == 0 and limits.measure_payload_bytes == 0;
1471 const measure_enabled = limits.measure_entries != 0 and limits.measure_payload_bytes != 0;
1472 if (!measure_disabled and !measure_enabled) return error.InvalidMeasureLimits;
1473 const shape_disabled = limits.shape_entries == 0 and limits.shape_payload_bytes == 0;
1474 const shape_enabled = limits.shape_entries != 0 and limits.shape_payload_bytes != 0;
1475 if (!shape_disabled and !shape_enabled) return error.InvalidShapeLimits;
1476 if (limits.measure_entries > std.math.maxInt(u32)) return error.EntryLimitTooLarge;
1477 if (limits.shape_entries > std.math.maxInt(u32)) return error.EntryLimitTooLarge;
1478 }
1479
1480 fn modelAtlasCacheCapacity(
1481 limits: AtlasCacheStorage.Limits,
1482 ) AtlasCacheStorage.DeriveError!AtlasCacheStorage.Capacity {
1483 try modelAtlasCacheLimitsValid(limits);
1484 const measure_slots = modelCacheIndexSlots(limits.measure_entries);
1485 const shape_slots = modelCacheIndexSlots(limits.shape_entries);
1486 const measure_index = modelCachePlaced(0, @alignOf(u32), measure_slots, @sizeOf(u32));
1487 const measure_entries = modelCachePlaced(
1488 measure_index.end,
1489 @alignOf(MeasureCacheEntry),
1490 limits.measure_entries,
1491 @sizeOf(MeasureCacheEntry),
1492 );
1493 const measure_payload_bytes = @as(u128, limits.measure_payload_bytes) +
1494 @as(u128, limits.measure_entries) * measure_padding_per_entry;
1495 const measure_payload = modelCachePlaced(
1496 measure_entries.end,
1497 measure_payload_alignment,
1498 measure_payload_bytes,
1499 1,
1500 );
1501 const shape_index = modelCachePlaced(
1502 measure_payload.end,
1503 @alignOf(u32),
1504 shape_slots,
1505 @sizeOf(u32),
1506 );
1507 const shape_entries = modelCachePlaced(
1508 shape_index.end,
1509 @alignOf(ShapeCacheEntry),
1510 limits.shape_entries,
1511 @sizeOf(ShapeCacheEntry),
1512 );
1513 const shape_payload_bytes = @as(u128, limits.shape_payload_bytes) +
1514 @as(u128, limits.shape_entries) * shape_padding_per_entry;
1515 const shape_payload = modelCachePlaced(
1516 shape_entries.end,
1517 shape_payload_alignment,
1518 shape_payload_bytes,
1519 1,
1520 );
1521 return (CacheModelLayout{
1522 .measure_slots = measure_slots,
1523 .measure_index = measure_index,
1524 .measure_entries = measure_entries,
1525 .measure_payload = measure_payload,
1526 .shape_slots = shape_slots,
1527 .shape_index = shape_index,
1528 .shape_entries = shape_entries,
1529 .shape_payload = shape_payload,
1530 }).capacity(limits);
1531 }
1532
1533 fn modelCacheIndexSlots(entries: usize) u128 {
1534 if (entries == 0) return 0;
1535 const minimum = @as(u128, entries) * 2;
1536 var slots: u128 = 1;
1537 var steps: usize = 0;
1538 while (steps < 64 and slots < minimum) : (steps += 1) slots *= 2;
1539 std.debug.assert(slots >= minimum);
1540 return slots;
1541 }
1542
1543 fn modelCachePlaced(offset: u128, alignment: usize, count: u128, size: usize) CacheModelRegion {
1544 const mask = @as(u128, alignment - 1);
1545 const start = (offset + mask) & ~mask;
1546 const bytes = count * size;
1547 return .{ .start = start, .bytes = bytes, .end = start + bytes };
1548 }
1549
1550 comptime {
1551 alloc_phase.capacity.requireAllocatorRejectingOwnerShape(AtlasCacheStorage);
1552 }
1553
1554 fn shapePayloadBytes(content: []const u8, run: filigree.GlyphRun) ?usize {
1555 var bytes = content.len;
1556 inline for (.{
1557 .{ filigree.ShapedGlyph, run.glyphs.len },
1558 .{ filigree.Cluster, run.clusters.len },
1559 .{ filigree.LigatureCaret, run.ligature_carets.len },
1560 }) |slice| {
1561 const slice_bytes = std.math.mul(usize, @sizeOf(slice[0]), slice[1]) catch return null;
1562 bytes = std.math.add(usize, bytes, slice_bytes) catch return null;
1563 }
1564 return bytes;
1565 }
1566
1567 fn floatBits(value: f64) u64 {
1568 return @bitCast(value);
1569 }
1570
1571 pub const AtlasSet = struct {
1572 entries: []const Entry = &.{},
1573 fallback_entries: []const Entry = &.{},
1574 fallback: ?*fallback_mod.Engine = null,
1575 device_scale: f32 = 1,
1576
1577 pub const Entry = struct {
1578 face: u64,
1579 image_index: u32,
1580 atlas: *Atlas,
1581 };
1582
1583 pub fn forText(self: *const AtlasSet, text: UiText) ?Entry {
1584 return self.forStyle(gui.model.resolvedTextStyle(text, 0));
1585 }
1586
1587 pub fn forStyle(self: *const AtlasSet, style: UiTextStyle) ?Entry {
1588 if (self.entries.len == 0) return null;
1589 const point: f32 = @floatCast(style.point_size);
1590 const scaled_point = point * @max(self.device_scale, 1);
1591 var best: ?Entry = null;
1592 var best_face = false;
1593 var best_cost: f32 = std.math.floatMax(f32);
1594 for (self.entries) |entry| {
1595 const face_match = entry.face == style.font_asset_id;
1596 if (best_face and !face_match) continue;
1597 const cost = sizeCost(entry.atlas.pixel_size, scaled_point);
1598 if (face_match and !best_face) {
1599 best = entry;
1600 best_face = true;
1601 best_cost = cost;
1602 continue;
1603 }
1604 if (cost < best_cost) {
1605 best = entry;
1606 best_cost = cost;
1607 }
1608 }
1609 return best;
1610 }
1611
1612 fn fallbackFor(self: *const AtlasSet, primary: Entry) ?Entry {
1613 if (self.fallback == null or primary.atlas.outlineFont() == null) return null;
1614 var best: ?Entry = null;
1615 var best_cost: u64 = std.math.maxInt(u64);
1616 for (self.fallback_entries) |entry| {
1617 if (entry.atlas.outlineFont() == null) continue;
1618 const cost = @abs(@as(i64, entry.atlas.pixel_size) - primary.atlas.pixel_size);
1619 if (cost < best_cost) {
1620 best = entry;
1621 best_cost = cost;
1622 }
1623 }
1624 return best;
1625 }
1626
1627 fn requiresFallback(self: *const AtlasSet, primary: Entry, content: []const u8) !bool {
1628 if (self.fallbackFor(primary) == null) return false;
1629 return !(try primary.atlas.coversUtf8(content));
1630 }
1631
1632 fn faceSegments(self: *const AtlasSet, primary: Entry, content: []const u8) !?FaceSegments {
1633 const fallback_entry = self.fallbackFor(primary) orelse return null;
1634 if (try primary.atlas.coversUtf8(content)) return null;
1635 const primary_font = primary.atlas.outlineFont().?;
1636 const fallback_font = fallback_entry.atlas.outlineFont().?;
1637 return .{
1638 .fallback_entry = fallback_entry,
1639 .items = try self.fallback.?.segments(primary_font, fallback_font, content),
1640 };
1641 }
1642
1643 fn sizeCost(pixel_size: i32, point: f32) f32 {
1644 const size: f32 = @floatFromInt(pixel_size);
1645 if (size >= point) return size - point;
1646 return (point - size) * 4;
1647 }
1648 };
1649
1650 const FaceSegments = struct {
1651 fallback_entry: AtlasSet.Entry,
1652 items: []const fallback_mod.FaceSegment,
1653 };
1654
1655 fn requestedPointSize(text: UiText) f32 {
1656 if (std.math.isFinite(text.point_size) and text.point_size > 0) return @floatCast(text.point_size);
1657 return 16;
1658 }
1659
1660 pub fn frameResolvers(atlases: *const AtlasSet) gui.frame.FrameResolvers {
1661 return .{
1662 .context = @constCast(atlases),
1663 .text_size = measureFrameText,
1664 };
1665 }
1666
1667 pub fn measureFrameText(context: ?*anyopaque, node: *const UiNode) anyerror!?Size {
1668 const text = node.text orelse return null;
1669 if (text.content.len == 0) return null;
1670 const atlases: *const AtlasSet = @ptrCast(@alignCast(context orelse return null));
1671 return try measureSet(atlases, text);
1672 }
1673
1674 pub fn measure(atlas: *Atlas, text: UiText) !Size {
1675 try gui.model.validateText(text);
1676 if (hasMixedTextMetrics(text)) return error.MixedTextRequiresAtlasSet;
1677 return measureCached(atlas, text, .single);
1678 }
1679
1680 pub fn measureSet(atlases: *const AtlasSet, text: UiText) !Size {
1681 try gui.model.validateText(text);
1682 const entry = atlases.forText(text) orelse return error.MissingTextAtlas;
1683 const composite = hasMixedTextMetrics(text) or try atlases.requiresFallback(entry, text.content);
1684 return measureCached(
1685 entry.atlas,
1686 text,
1687 if (composite) .{ .composite = atlases } else .single,
1688 );
1689 }
1690
1691 pub fn textAdvanceForByteOffset(
1692 atlases: *const AtlasSet,
1693 text: UiText,
1694 byte_offset: usize,
1695 ) !f32 {
1696 const line = try textCaretPlan(atlases, text);
1697 return filigree.caret.advanceForByteOffset(
1698 line.plan.run,
1699 byte_offset,
1700 text.content.len,
1701 ) * line.scale;
1702 }
1703
1704 pub fn textHitTestAdvance(
1705 atlases: *const AtlasSet,
1706 text: UiText,
1707 advance: f32,
1708 ) !filigree.caret.LineHit {
1709 const line = try textCaretPlan(atlases, text);
1710 var hit = filigree.caret.hitTestAdvance(
1711 line.plan.run,
1712 advance / line.scale,
1713 text.content,
1714 );
1715 hit.advance *= line.scale;
1716 return hit;
1717 }
1718
1719 pub const TextCaretAffinity = enum {
1720 upstream,
1721 downstream,
1722 };
1723
1724 pub const TextCaretGeometry = struct {
1725 byte_offset: usize,
1726 affinity: TextCaretAffinity,
1727 line_index: usize,
1728 x: f32,
1729 y: f32,
1730 height: f32,
1731 };
1732
1733 pub fn textCaretGeometry(
1734 atlases: *const AtlasSet,
1735 text: UiText,
1736 box: Size,
1737 byte_offset: usize,
1738 affinity: TextCaretAffinity,
1739 ) !TextCaretGeometry {
1740 const layout = try TextLayout.init(atlases, text, .{
1741 .width = box.width,
1742 .height = box.height,
1743 }, 1);
1744 const target = @min(byte_offset, text.content.len);
1745 var iterator = layout.iterator();
1746 while (try iterator.next()) |line| {
1747 if (target < line.hard_line_start) continue;
1748 if (!line.visual.containsCaret(target - line.hard_line_start, affinity)) continue;
1749 return line.caret(target, affinity);
1750 }
1751 return error.MissingTextCaret;
1752 }
1753
1754 pub fn textHitTestPoint(
1755 atlases: *const AtlasSet,
1756 text: UiText,
1757 box: Size,
1758 point: gui.model.UiPoint,
1759 ) !TextCaretGeometry {
1760 const layout = try TextLayout.init(atlases, text, .{
1761 .width = box.width,
1762 .height = box.height,
1763 }, 1);
1764 var iterator = layout.iterator();
1765 var candidate: ?TextCaretGeometry = null;
1766 while (try iterator.next()) |line| {
1767 const hit = line.hit(point.x);
1768 if (point.y < line.y + line.height) return hit;
1769 candidate = hit;
1770 }
1771 return candidate orelse error.MissingTextCaret;
1772 }
1773
1774 pub fn textHitTestWidgetPoint(
1775 atlases: *const AtlasSet,
1776 widget: WidgetFrame,
1777 point: gui.model.UiPoint,
1778 ) !?TextCaretGeometry {
1779 if (!gui.model.pointInRect(widget.rect, point.x, point.y)) return null;
1780 if (!gui.model.pointInRect(widget.visible_rect, point.x, point.y)) return null;
1781 return try textHitTestWidgetLocalPoint(atlases, widget, point);
1782 }
1783
1784 pub fn textHitTestWidgetPointClamped(
1785 atlases: *const AtlasSet,
1786 widget: WidgetFrame,
1787 point: gui.model.UiPoint,
1788 ) !?TextCaretGeometry {
1789 if (!std.math.isFinite(point.x) or !std.math.isFinite(point.y)) return null;
1790 const left = @max(widget.rect.x, widget.visible_rect.x);
1791 const top = @max(widget.rect.y, widget.visible_rect.y);
1792 const right = @min(
1793 widget.rect.x + widget.rect.width,
1794 widget.visible_rect.x + widget.visible_rect.width,
1795 );
1796 const bottom = @min(
1797 widget.rect.y + widget.rect.height,
1798 widget.visible_rect.y + widget.visible_rect.height,
1799 );
1800 if (right <= left or bottom <= top) return null;
1801 return try textHitTestWidgetLocalPoint(atlases, widget, .{
1802 .x = std.math.clamp(point.x, left, right),
1803 .y = std.math.clamp(point.y, top, bottom),
1804 });
1805 }
1806
1807 fn textHitTestWidgetLocalPoint(
1808 atlases: *const AtlasSet,
1809 widget: WidgetFrame,
1810 point: gui.model.UiPoint,
1811 ) !?TextCaretGeometry {
1812 const text = widget.text orelse return null;
1813 return try textHitTestPoint(
1814 atlases,
1815 text,
1816 .{ .width = widget.rect.width, .height = widget.rect.height },
1817 .{
1818 .x = point.x - widget.rect.x,
1819 .y = point.y - widget.rect.y,
1820 },
1821 );
1822 }
1823
1824 const TextCaretPlan = struct {
1825 plan: LinePlan,
1826 scale: f32,
1827 };
1828
1829 fn textCaretPlan(atlases: *const AtlasSet, text: UiText) !TextCaretPlan {
1830 try gui.model.validateText(text);
1831 if (std.mem.indexOfScalar(u8, text.content, '\n') != null) return error.MultilineText;
1832 const entry = atlases.forText(text) orelse return error.MissingTextAtlas;
1833 const scale = textScale(entry.atlas, text);
1834 const composite = hasMixedTextMetrics(text) or try atlases.requiresFallback(entry, text.content);
1835 const plan = if (composite)
1836 try LinePlan.initComposite(
1837 entry.atlas,
1838 atlases,
1839 text,
1840 text.content,
1841 0,
1842 0,
1843 )
1844 else
1845 try LinePlan.init(entry.atlas, text.content, 0);
1846 return .{ .plan = plan, .scale = scale };
1847 }
1848
1849 const MeasureMode = union(enum) {
1850 single,
1851 composite: *const AtlasSet,
1852 };
1853
1854 fn measureCached(atlas: *Atlas, text: UiText, mode: MeasureMode) !Size {
1855 const metric_atlases: ?*const AtlasSet = switch (mode) {
1856 .single => null,
1857 .composite => |atlases| atlases,
1858 };
1859 const key = MeasureKey.fromText(text, metric_atlases);
1860 if (atlas.cache.lookupMeasure(key)) |cached| return cached;
1861 const measured = switch (mode) {
1862 .single => try measureUncached(atlas, text),
1863 .composite => |atlases| try measureCompositeUncached(atlas, atlases, text),
1864 };
1865 return atlas.cache.storeMeasure(key, measured) catch measured;
1866 }
1867
1868 fn measurePayloadBytes(key: MeasureKey) ?usize {
1869 var bytes = key.content.len;
1870 bytes = std.math.add(usize, bytes, std.math.mul(usize, key.styles.len, @sizeOf(UiTextStyle)) catch return null) catch return null;
1871 bytes = std.math.add(usize, bytes, std.math.mul(usize, key.runs.len, @sizeOf(UiTextRun)) catch return null) catch return null;
1872 bytes = std.math.add(usize, bytes, std.math.mul(usize, key.atlas_entries.len, @sizeOf(AtlasSet.Entry)) catch return null) catch return null;
1873 bytes = std.math.add(usize, bytes, std.math.mul(usize, key.fallback_entries.len, @sizeOf(AtlasSet.Entry)) catch return null) catch return null;
1874 return bytes;
1875 }
1876
1877 fn hasMixedTextMetrics(text: UiText) bool {
1878 const base = gui.model.resolvedTextStyle(text, 0);
1879 for (text.runs) |run| {
1880 if (!gui.model.textStylesEqual(base, gui.model.resolvedTextStyle(text, run.style_slot))) return true;
1881 }
1882 return false;
1883 }
1884
1885 const VisualLine = struct {
1886 glyph_start: usize,
1887 glyph_end: usize,
1888 byte_start: usize,
1889 byte_end: usize,
1890 advance_start: f32,
1891 advance: f32,
1892 is_last: bool,
1893
1894 fn glyphs(self: VisualLine, run: filigree.GlyphRun) []const filigree.ShapedGlyph {
1895 return run.glyphs[self.glyph_start..self.glyph_end];
1896 }
1897
1898 fn advanceForByteOffset(self: VisualLine, run: filigree.GlyphRun, byte_offset: usize, text_len: usize) f32 {
1899 const absolute = filigree.caret.advanceForByteOffset(run, byte_offset, text_len);
1900 return std.math.clamp(absolute - self.advance_start, @as(f32, 0), self.advance);
1901 }
1902
1903 fn containsCaret(
1904 self: VisualLine,
1905 byte_offset: usize,
1906 affinity: TextCaretAffinity,
1907 ) bool {
1908 if (byte_offset < self.byte_start) return false;
1909 if (byte_offset < self.byte_end) return true;
1910 if (byte_offset != self.byte_end) return false;
1911 return self.is_last or affinity == .upstream;
1912 }
1913 };
1914
1915 const LinePlan = struct {
1916 content: []const u8,
1917 run: filigree.GlyphRun,
1918 glyph_ends: []const u32,
1919 wrapped: bool,
1920 glyph_sources: []const CompositeGlyphSource = &.{},
1921 metric_spans: []const CompositeMetricSpan = &.{},
1922 base_metric: ?CompositeMetricSpan = null,
1923
1924 fn init(atlas: *Atlas, content: []const u8, wrap_width: f32) !LinePlan {
1925 const run = try atlas.shape(content);
1926 if (wrap_width <= 0) {
1927 return .{
1928 .content = content,
1929 .run = run,
1930 .glyph_ends = &.{},
1931 .wrapped = false,
1932 };
1933 }
1934 const glyph_ends = try filigree.breakLinesInto(
1935 atlas.allocator,
1936 run.glyphs,
1937 .{ .utf8 = content },
1938 wrap_width,
1939 &atlas.line_break_workspace,
1940 );
1941 std.debug.assert(glyph_ends.len > 0);
1942 std.debug.assert(glyph_ends.len <= run.glyphs.len + 1);
1943 return .{
1944 .content = content,
1945 .run = run,
1946 .glyph_ends = glyph_ends,
1947 .wrapped = true,
1948 };
1949 }
1950
1951 fn initComposite(
1952 base_atlas: *Atlas,
1953 atlases: *const AtlasSet,
1954 text: UiText,
1955 content: []const u8,
1956 content_start: usize,
1957 wrap_width: f32,
1958 ) !LinePlan {
1959 const content_end = std.math.add(usize, content_start, content.len) catch
1960 return error.SourceTooLong;
1961 if (content_end > text.content.len) return error.SourceTooLong;
1962 if (content.len > std.math.maxInt(u32)) return error.SourceTooLong;
1963 const base_style = gui.model.resolvedTextStyle(text, 0);
1964 const base_entry = atlases.forStyle(base_style) orelse return error.MissingTextAtlas;
1965 const base_scale = textStyleScale(base_entry.atlas, base_style);
1966 if (!(base_scale > 0)) return error.InvalidTextScale;
1967 const base_metric = compositeMetricSpan(
1968 base_entry,
1969 base_style,
1970 base_scale,
1971 0,
1972 @intCast(content.len),
1973 );
1974 const workspace = &base_atlas.composite_workspace;
1975 workspace.clear();
1976 errdefer workspace.clear();
1977 var cursor = MetricSegmentCursor{
1978 .text = text,
1979 .position = content_start,
1980 .end = content_end,
1981 };
1982 while (cursor.next()) |segment| {
1983 const entry = atlases.forStyle(segment.style) orelse return error.MissingTextAtlas;
1984 const local_start = segment.byte_start - content_start;
1985 const local_end = segment.byte_end - content_start;
1986 if (local_start > std.math.maxInt(u32) or local_end > std.math.maxInt(u32)) {
1987 return error.SourceTooLong;
1988 }
1989 try appendMetricSegment(
1990 workspace,
1991 base_atlas.allocator,
1992 atlases,
1993 entry,
1994 segment.style,
1995 base_scale,
1996 content[local_start..local_end],
1997 local_start,
1998 );
1999 }
2000 var total_x: i32 = 0;
2001 var total_y: i32 = 0;
2002 for (workspace.glyphs.items) |glyph| {
2003 total_x = std.math.add(i32, total_x, glyph.x_advance) catch
2004 return error.TextGeometryOverflow;
2005 total_y = std.math.add(i32, total_y, glyph.y_advance) catch
2006 return error.TextGeometryOverflow;
2007 }
2008 const run = filigree.GlyphRun{
2009 .glyphs = workspace.glyphs.items,
2010 .clusters = workspace.clusters.items,
2011 .ligature_carets = workspace.ligature_carets.items,
2012 .total_x_advance = total_x,
2013 .total_y_advance = total_y,
2014 .direction = .ltr,
2015 .writing_mode = .horizontal,
2016 .output_order = .visual,
2017 };
2018 const glyph_ends = if (wrap_width > 0) try filigree.breakLinesInto(
2019 base_atlas.allocator,
2020 run.glyphs,
2021 .{ .utf8 = content },
2022 wrap_width,
2023 &base_atlas.line_break_workspace,
2024 ) else &.{};
2025 if (wrap_width > 0) {
2026 std.debug.assert(glyph_ends.len > 0);
2027 std.debug.assert(glyph_ends.len <= run.glyphs.len + 1);
2028 }
2029 return .{
2030 .content = content,
2031 .run = run,
2032 .glyph_ends = glyph_ends,
2033 .wrapped = wrap_width > 0,
2034 .glyph_sources = workspace.glyph_sources.items,
2035 .metric_spans = workspace.spans.items,
2036 .base_metric = base_metric,
2037 };
2038 }
2039
2040 fn iterator(_: *const LinePlan) Iterator {
2041 return .{};
2042 }
2043
2044 fn compositeLineBox(self: *const LinePlan, visual: VisualLine, multiplier: f32) CompositeLineBox {
2045 var ascent: f32 = 0;
2046 var descent: f32 = 0;
2047 var requested: f32 = 0;
2048 for (self.metric_spans) |span| {
2049 if (span.byte_end <= visual.byte_start or span.byte_start >= visual.byte_end) continue;
2050 ascent = @max(ascent, span.ascent);
2051 descent = @max(descent, span.descent);
2052 requested = @max(requested, span.point_size * multiplier);
2053 }
2054 if (ascent == 0 and descent == 0) {
2055 const base = self.base_metric.?;
2056 ascent = base.ascent;
2057 descent = base.descent;
2058 requested = base.point_size * multiplier;
2059 }
2060 const font_height = ascent + descent;
2061 const height = @max(requested, font_height);
2062 return .{
2063 .ascent = ascent,
2064 .descent = descent,
2065 .height = height,
2066 .half_leading = @max(0, height - font_height) / 2,
2067 };
2068 }
2069
2070 const Iterator = struct {
2071 break_index: usize = 0,
2072 glyph_start: usize = 0,
2073 advance_start: f32 = 0,
2074
2075 fn next(self: *Iterator, plan: *const LinePlan) ?VisualLine {
2076 const line_count = if (plan.wrapped) plan.glyph_ends.len else 1;
2077 if (self.break_index >= line_count) return null;
2078 const raw_end = if (plan.wrapped)
2079 plan.glyph_ends[self.break_index]
2080 else
2081 plan.run.glyphs.len;
2082 const glyph_end = @min(@as(usize, @intCast(raw_end)), plan.run.glyphs.len);
2083 std.debug.assert(glyph_end >= self.glyph_start);
2084 const advance = glyphSliceAdvance(plan.run.glyphs[self.glyph_start..glyph_end]);
2085 const byte_start = if (self.glyph_start == 0)
2086 0
2087 else
2088 sourceOffset(plan.run.glyphs[self.glyph_start].source_start, plan.content.len);
2089 const byte_end = if (glyph_end >= plan.run.glyphs.len)
2090 plan.content.len
2091 else
2092 sourceOffset(plan.run.glyphs[glyph_end].source_start, plan.content.len);
2093 const visual = VisualLine{
2094 .glyph_start = self.glyph_start,
2095 .glyph_end = glyph_end,
2096 .byte_start = byte_start,
2097 .byte_end = @max(byte_start, byte_end),
2098 .advance_start = self.advance_start,
2099 .advance = advance,
2100 .is_last = self.break_index + 1 == line_count,
2101 };
2102 self.glyph_start = glyph_end;
2103 self.advance_start += advance;
2104 self.break_index += 1;
2105 return visual;
2106 }
2107 };
2108 };
2109
2110 const CompositeLineBox = struct {
2111 ascent: f32,
2112 descent: f32,
2113 height: f32,
2114 half_leading: f32,
2115 };
2116
2117 const TextLayout = struct {
2118 atlases: *const AtlasSet,
2119 text: UiText,
2120 rect: Rect,
2121 base_atlas: *Atlas,
2122 scale: f32,
2123 single_line_height: f32,
2124 single_half_leading: f32,
2125 line_height_multiplier: f32,
2126 plan_wrap_width: f32,
2127 block_y: f32,
2128 composite: bool,
2129
2130 fn init(
2131 atlases: *const AtlasSet,
2132 text: UiText,
2133 rect: Rect,
2134 unit_scale: f32,
2135 ) !TextLayout {
2136 try gui.model.validateText(text);
2137 if (!std.math.isFinite(unit_scale) or unit_scale <= 0) {
2138 return error.InvalidTextScale;
2139 }
2140 const entry = atlases.forText(text) orelse return error.MissingTextAtlas;
2141 const base_atlas = entry.atlas;
2142 const scale = textScale(base_atlas, text) * unit_scale;
2143 if (!std.math.isFinite(scale) or scale <= 0) return error.InvalidTextScale;
2144 const line_height = textLineHeight(base_atlas, text) * unit_scale;
2145 const half_leading = @max(
2146 0,
2147 line_height - base_atlas.metrics().height() * scale,
2148 ) / 2;
2149 const wrap_width = textWrapWidth(text) * unit_scale;
2150 const measured_height = if (text.vertical_align == .start)
2151 0
2152 else
2153 (try measureSet(atlases, text)).height * unit_scale;
2154 const composite = hasMixedTextMetrics(text) or try atlases.requiresFallback(entry, text.content);
2155 return .{
2156 .atlases = atlases,
2157 .text = text,
2158 .rect = rect,
2159 .base_atlas = base_atlas,
2160 .scale = scale,
2161 .single_line_height = line_height,
2162 .single_half_leading = half_leading,
2163 .line_height_multiplier = textLineHeightMultiplier(text),
2164 .plan_wrap_width = if (wrap_width > 0) wrap_width / scale else 0,
2165 .block_y = if (text.vertical_align == .start)
2166 rect.y
2167 else
2168 alignedStart(
2169 rect.y,
2170 rect.height,
2171 measured_height,
2172 text.vertical_align,
2173 ),
2174 .composite = composite,
2175 };
2176 }
2177
2178 fn iterator(self: *const TextLayout) Iterator {
2179 return .{
2180 .layout = self,
2181 .line_y = self.block_y,
2182 };
2183 }
2184
2185 const Iterator = struct {
2186 layout: *const TextLayout,
2187 next_hard_line_start: usize = 0,
2188 hard_lines_done: bool = false,
2189 has_plan: bool = false,
2190 current_hard_line_start: usize = 0,
2191 current_hard_line: []const u8 = &.{},
2192 current_plan: LinePlan = undefined,
2193 visual_iterator: LinePlan.Iterator = .{},
2194 line_index: usize = 0,
2195 line_y: f32,
2196
2197 fn next(self: *Iterator) !?TextLayoutLine {
2198 while (true) {
2199 if (self.has_plan) {
2200 if (self.visual_iterator.next(&self.current_plan)) |visual| {
2201 const box = if (self.layout.composite)
2202 self.current_plan.compositeLineBox(
2203 visual,
2204 self.layout.line_height_multiplier,
2205 )
2206 else
2207 CompositeLineBox{
2208 .ascent = self.layout.base_atlas.metrics().ascent,
2209 .descent = self.layout.base_atlas.metrics().descent,
2210 .height = self.layout.single_line_height / self.layout.scale,
2211 .half_leading = self.layout.single_half_leading / self.layout.scale,
2212 };
2213 const line_height = if (self.layout.composite)
2214 box.height * self.layout.scale
2215 else
2216 self.layout.single_line_height;
2217 const line_width = visual.advance * self.layout.scale;
2218 const line = TextLayoutLine{
2219 .plan = &self.current_plan,
2220 .visual = visual,
2221 .hard_line = self.current_hard_line,
2222 .hard_line_start = self.current_hard_line_start,
2223 .line_index = self.line_index,
2224 .origin_x = alignedStart(
2225 self.layout.rect.x,
2226 self.layout.rect.width,
2227 line_width,
2228 self.layout.text.horizontal_align,
2229 ),
2230 .origin_y = self.line_y + box.half_leading * self.layout.scale,
2231 .y = self.line_y,
2232 .height = line_height,
2233 .scale = self.layout.scale,
2234 .ascent = box.ascent,
2235 .descent = box.descent,
2236 };
2237 self.line_y += line_height;
2238 self.line_index += 1;
2239 return line;
2240 }
2241 self.has_plan = false;
2242 }
2243 if (!try self.loadHardLine()) return null;
2244 }
2245 }
2246
2247 fn loadHardLine(self: *Iterator) !bool {
2248 if (self.hard_lines_done) return false;
2249 const content = self.layout.text.content;
2250 const start = self.next_hard_line_start;
2251 const relative_end = std.mem.indexOfScalar(
2252 u8,
2253 content[start..],
2254 '\n',
2255 );
2256 const end = if (relative_end) |offset| start + offset else content.len;
2257 if (end < content.len) {
2258 self.next_hard_line_start = end + 1;
2259 } else {
2260 self.hard_lines_done = true;
2261 }
2262 const hard_line = content[start..end];
2263 self.current_hard_line_start = start;
2264 self.current_hard_line = hard_line;
2265 self.current_plan = if (self.layout.composite)
2266 try LinePlan.initComposite(
2267 self.layout.base_atlas,
2268 self.layout.atlases,
2269 self.layout.text,
2270 hard_line,
2271 start,
2272 self.layout.plan_wrap_width,
2273 )
2274 else
2275 try LinePlan.init(
2276 self.layout.base_atlas,
2277 hard_line,
2278 self.layout.plan_wrap_width,
2279 );
2280 self.visual_iterator = .{};
2281 self.has_plan = true;
2282 return true;
2283 }
2284 };
2285 };
2286
2287 const TextLayoutLine = struct {
2288 plan: *const LinePlan,
2289 visual: VisualLine,
2290 hard_line: []const u8,
2291 hard_line_start: usize,
2292 line_index: usize,
2293 origin_x: f32,
2294 origin_y: f32,
2295 y: f32,
2296 height: f32,
2297 scale: f32,
2298 ascent: f32,
2299 descent: f32,
2300
2301 fn caret(
2302 self: TextLayoutLine,
2303 byte_offset: usize,
2304 affinity: TextCaretAffinity,
2305 ) TextCaretGeometry {
2306 const local_offset = byte_offset - self.hard_line_start;
2307 return .{
2308 .byte_offset = byte_offset,
2309 .affinity = affinity,
2310 .line_index = self.line_index,
2311 .x = self.origin_x + self.visual.advanceForByteOffset(
2312 self.plan.run,
2313 local_offset,
2314 self.plan.content.len,
2315 ) * self.scale,
2316 .y = self.y,
2317 .height = self.height,
2318 };
2319 }
2320
2321 fn hit(self: TextLayoutLine, x: f32) TextCaretGeometry {
2322 const absolute_advance = self.visual.advance_start +
2323 (x - self.origin_x) / self.scale;
2324 const raw = filigree.caret.hitTestAdvance(
2325 self.plan.run,
2326 absolute_advance,
2327 self.plan.content,
2328 );
2329 const local_offset = std.math.clamp(
2330 raw.byte_offset,
2331 self.visual.byte_start,
2332 self.visual.byte_end,
2333 );
2334 const affinity: TextCaretAffinity = if (!self.visual.is_last and
2335 local_offset == self.visual.byte_end)
2336 .upstream
2337 else
2338 .downstream;
2339 return self.caret(self.hard_line_start + local_offset, affinity);
2340 }
2341 };
2342
2343 const MetricSegment = struct {
2344 byte_start: usize,
2345 byte_end: usize,
2346 style: UiTextStyle,
2347 };
2348
2349 const MetricSegmentCursor = struct {
2350 text: UiText,
2351 position: usize,
2352 end: usize,
2353
2354 fn next(self: *MetricSegmentCursor) ?MetricSegment {
2355 if (self.position >= self.end) return null;
2356 const byte_start = self.position;
2357 const style = textStyleAt(self.text, byte_start);
2358 var byte_end = nextTextMetricBoundary(self.text, byte_start, self.end);
2359 while (byte_end < self.end and
2360 gui.model.textStylesEqual(style, textStyleAt(self.text, byte_end)))
2361 {
2362 byte_end = nextTextMetricBoundary(self.text, byte_end, self.end);
2363 }
2364 std.debug.assert(byte_end > byte_start);
2365 self.position = byte_end;
2366 return .{
2367 .byte_start = byte_start,
2368 .byte_end = byte_end,
2369 .style = style,
2370 };
2371 }
2372 };
2373
2374 fn textStyleAt(text: UiText, byte_offset: usize) UiTextStyle {
2375 const run = findTextRun(text.runs, byte_offset) orelse
2376 return gui.model.resolvedTextStyle(text, 0);
2377 return gui.model.resolvedTextStyle(text, run.style_slot);
2378 }
2379
2380 fn nextTextMetricBoundary(text: UiText, byte_offset: usize, end: usize) usize {
2381 const index = firstRunEndingAfter(text.runs, byte_offset);
2382 if (index >= text.runs.len) return end;
2383 const run = text.runs[index];
2384 if (byte_offset < run.byte_start) return @min(end, run.byte_start);
2385 return @min(end, run.byte_end);
2386 }
2387
2388 fn textStyleScale(atlas: *const Atlas, style: UiTextStyle) f32 {
2389 if (atlas.pixel_size <= 0) return 1;
2390 return @as(f32, @floatCast(style.point_size)) /
2391 @as(f32, @floatFromInt(atlas.pixel_size));
2392 }
2393
2394 fn appendMetricSegment(
2395 workspace: *CompositeWorkspace,
2396 allocator: Allocator,
2397 atlases: *const AtlasSet,
2398 primary: AtlasSet.Entry,
2399 style: UiTextStyle,
2400 base_scale: f32,
2401 content: []const u8,
2402 content_start: usize,
2403 ) !void {
2404 const segmented = try atlases.faceSegments(primary, content) orelse {
2405 return appendStyledFaceSegment(
2406 workspace,
2407 allocator,
2408 primary,
2409 style,
2410 base_scale,
2411 content,
2412 content_start,
2413 );
2414 };
2415 for (segmented.items) |segment| {
2416 const start: usize = @intCast(segment.source.start);
2417 const end: usize = @intCast(segment.source.end);
2418 if (start > end or end > content.len) return error.SourceTooLong;
2419 const entry = try entryForFaceSegment(
2420 primary,
2421 segmented.fallback_entry,
2422 segment.candidate_index,
2423 segment.missing_everywhere,
2424 );
2425 try appendLookupOrderedFaceSegment(
2426 workspace,
2427 allocator,
2428 primary,
2429 entry,
2430 style,
2431 base_scale,
2432 content[start..end],
2433 try std.math.add(usize, content_start, start),
2434 );
2435 }
2436 }
2437
2438 fn entryForFaceSegment(
2439 primary: AtlasSet.Entry,
2440 fallback: AtlasSet.Entry,
2441 candidate_index: usize,
2442 missing_everywhere: bool,
2443 ) !AtlasSet.Entry {
2444 if (candidate_index == 1) return fallback;
2445 if (candidate_index != 0) return error.InvalidFallbackCandidate;
2446 return if (missing_everywhere) fallback else primary;
2447 }
2448
2449 fn appendLookupOrderedFaceSegment(
2450 workspace: *CompositeWorkspace,
2451 allocator: Allocator,
2452 primary: AtlasSet.Entry,
2453 selected: AtlasSet.Entry,
2454 style: UiTextStyle,
2455 base_scale: f32,
2456 content: []const u8,
2457 content_start: usize,
2458 ) !void {
2459 if (selected.atlas == primary.atlas or content.len == 0) {
2460 return appendStyledFaceSegment(
2461 workspace,
2462 allocator,
2463 selected,
2464 style,
2465 base_scale,
2466 content,
2467 content_start,
2468 );
2469 }
2470 const append = OrderedFaceAppend{
2471 .workspace = workspace,
2472 .allocator = allocator,
2473 .primary = primary,
2474 .selected = selected,
2475 .style = style,
2476 .base_scale = base_scale,
2477 .content = content,
2478 .content_start = content_start,
2479 };
2480 var iterator = try filigree.unicode.SourceIterator.init(.{ .utf8 = content }, 0);
2481 var grapheme: filigree.unicode.GraphemeState = .{};
2482 var cluster_start: usize = 0;
2483 var cluster_first: u21 = 0;
2484 var cluster_count: usize = 0;
2485 var runs = OrderedRunState{};
2486 while (try iterator.next()) |scalar| {
2487 if (grapheme.consume(scalar.codepoint)) {
2488 cluster_count += 1;
2489 continue;
2490 }
2491 if (cluster_count > 0) {
2492 try runs.accept(append, cluster_start, cluster_count == 1 and synth.covered(cluster_first));
2493 }
2494 cluster_start = @intCast(scalar.source.start);
2495 cluster_first = scalar.codepoint;
2496 cluster_count = 1;
2497 }
2498 std.debug.assert(cluster_count > 0);
2499 try runs.accept(append, cluster_start, cluster_count == 1 and synth.covered(cluster_first));
2500 return append.run(runs.start, content.len, runs.synthetic.?);
2501 }
2502
2503 const OrderedFaceAppend = struct {
2504 workspace: *CompositeWorkspace,
2505 allocator: Allocator,
2506 primary: AtlasSet.Entry,
2507 selected: AtlasSet.Entry,
2508 style: UiTextStyle,
2509 base_scale: f32,
2510 content: []const u8,
2511 content_start: usize,
2512
2513 fn run(self: OrderedFaceAppend, start: usize, end: usize, synthetic: bool) !void {
2514 std.debug.assert(start < end);
2515 std.debug.assert(end <= self.content.len);
2516 return appendStyledFaceSegment(
2517 self.workspace,
2518 self.allocator,
2519 if (synthetic) self.primary else self.selected,
2520 self.style,
2521 self.base_scale,
2522 self.content[start..end],
2523 std.math.add(usize, self.content_start, start) catch return error.SourceTooLong,
2524 );
2525 }
2526 };
2527
2528 const OrderedRunState = struct {
2529 start: usize = 0,
2530 synthetic: ?bool = null,
2531
2532 fn accept(self: *OrderedRunState, append: OrderedFaceAppend, cluster_start: usize, synthetic: bool) !void {
2533 if (self.synthetic) |current| {
2534 if (current != synthetic) {
2535 try append.run(self.start, cluster_start, current);
2536 self.start = cluster_start;
2537 }
2538 }
2539 self.synthetic = synthetic;
2540 }
2541 };
2542
2543 fn appendStyledFaceSegment(
2544 workspace: *CompositeWorkspace,
2545 allocator: Allocator,
2546 entry: AtlasSet.Entry,
2547 style: UiTextStyle,
2548 base_scale: f32,
2549 content: []const u8,
2550 content_start: usize,
2551 ) !void {
2552 if (content_start > std.math.maxInt(u32) or content.len > std.math.maxInt(u32) - content_start) {
2553 return error.SourceTooLong;
2554 }
2555 const scale_ratio = textStyleScale(entry.atlas, style) / base_scale;
2556 if (!std.math.isFinite(scale_ratio) or scale_ratio <= 0) return error.InvalidTextScale;
2557 const span_index = workspace.spans.items.len;
2558 if (span_index > std.math.maxInt(u32)) return error.SourceTooLong;
2559 const content_end = content_start + content.len;
2560 try workspace.spans.append(allocator, compositeMetricSpan(
2561 entry,
2562 style,
2563 base_scale,
2564 @intCast(content_start),
2565 @intCast(content_end),
2566 ));
2567 try appendCompositeSegment(
2568 workspace,
2569 allocator,
2570 try entry.atlas.shape(content),
2571 @intCast(content_start),
2572 @intCast(span_index),
2573 scale_ratio,
2574 );
2575 }
2576
2577 fn compositeMetricSpan(
2578 entry: AtlasSet.Entry,
2579 style: UiTextStyle,
2580 base_scale: f32,
2581 byte_start: u32,
2582 byte_end: u32,
2583 ) CompositeMetricSpan {
2584 const scale_ratio = textStyleScale(entry.atlas, style) / base_scale;
2585 const metrics = entry.atlas.metrics();
2586 return .{
2587 .byte_start = byte_start,
2588 .byte_end = byte_end,
2589 .atlas = entry.atlas,
2590 .image_index = entry.image_index,
2591 .scale_ratio = scale_ratio,
2592 .point_size = @as(f32, @floatCast(style.point_size)) / base_scale,
2593 .ascent = metrics.ascent * scale_ratio,
2594 .descent = metrics.descent * scale_ratio,
2595 };
2596 }
2597
2598 fn appendCompositeSegment(
2599 workspace: *CompositeWorkspace,
2600 allocator: Allocator,
2601 run: filigree.GlyphRun,
2602 source_base: u32,
2603 segment_index: u32,
2604 scale_ratio: f32,
2605 ) !void {
2606 const glyph_base = workspace.glyphs.items.len;
2607 const cluster_base = workspace.clusters.items.len;
2608 const caret_base = workspace.ligature_carets.items.len;
2609 try workspace.glyphs.ensureUnusedCapacity(allocator, run.glyphs.len);
2610 try workspace.clusters.ensureUnusedCapacity(allocator, run.clusters.len);
2611 try workspace.ligature_carets.ensureUnusedCapacity(allocator, run.ligature_carets.len);
2612 try workspace.glyph_sources.ensureUnusedCapacity(allocator, run.glyphs.len);
2613 for (run.ligature_carets) |source| {
2614 var caret = source;
2615 caret.x_offset = try scaleCompositeValue(caret.x_offset, scale_ratio);
2616 workspace.ligature_carets.appendAssumeCapacity(caret);
2617 }
2618 for (run.clusters) |source| {
2619 var cluster = source;
2620 cluster.source.start = try addCompositeIndex(cluster.source.start, source_base);
2621 cluster.source.end = try addCompositeIndex(cluster.source.end, source_base);
2622 cluster.glyphs.start = try addCompositeIndex(cluster.glyphs.start, glyph_base);
2623 cluster.glyphs.end = try addCompositeIndex(cluster.glyphs.end, glyph_base);
2624 workspace.clusters.appendAssumeCapacity(cluster);
2625 }
2626 for (run.glyphs) |source| {
2627 var glyph = source;
2628 glyph.cluster = try addCompositeIndex(glyph.cluster, source_base);
2629 glyph.cluster_index = try addCompositeIndex(glyph.cluster_index, cluster_base);
2630 glyph.source_start = try addCompositeIndex(glyph.source_start, source_base);
2631 glyph.source_end = try addCompositeIndex(glyph.source_end, source_base);
2632 glyph.x_advance = try scaleCompositeValue(glyph.x_advance, scale_ratio);
2633 glyph.y_advance = try scaleCompositeValue(glyph.y_advance, scale_ratio);
2634 glyph.x_offset = try scaleCompositeValue(glyph.x_offset, scale_ratio);
2635 glyph.y_offset = try scaleCompositeValue(glyph.y_offset, scale_ratio);
2636 if (glyph.attachment) |*attachment| {
2637 attachment.target_glyph_index = try addCompositeIndex(
2638 attachment.target_glyph_index,
2639 glyph_base,
2640 );
2641 }
2642 if (glyph.ligature_caret_count > 0) {
2643 glyph.ligature_caret_start = try addCompositeIndex(
2644 glyph.ligature_caret_start,
2645 caret_base,
2646 );
2647 }
2648 workspace.glyphs.appendAssumeCapacity(glyph);
2649 workspace.glyph_sources.appendAssumeCapacity(.{ .segment_index = segment_index });
2650 }
2651 }
2652
2653 fn addCompositeIndex(value: u32, base: anytype) !u32 {
2654 const wide = std.math.add(u64, value, @intCast(base)) catch
2655 return error.SourceTooLong;
2656 if (wide > std.math.maxInt(u32)) return error.SourceTooLong;
2657 return @intCast(wide);
2658 }
2659
2660 fn scaleCompositeValue(value: i32, ratio: f32) !i32 {
2661 const scaled = @as(f64, @floatFromInt(value)) * @as(f64, ratio);
2662 if (!std.math.isFinite(scaled) or
2663 scaled < @as(f64, @floatFromInt(std.math.minInt(i32))) or
2664 scaled > @as(f64, @floatFromInt(std.math.maxInt(i32))))
2665 {
2666 return error.TextGeometryOverflow;
2667 }
2668 return @intFromFloat(@round(scaled));
2669 }
2670
2671 fn measureUncached(atlas: *Atlas, text: UiText) !Size {
2672 const scale = textScale(atlas, text);
2673 const line_height = textLineHeight(atlas, text);
2674 const wrap_width = textWrapWidth(text);
2675 var line_count: usize = 0;
2676 var max_width: f32 = 0;
2677 var lines = std.mem.splitScalar(u8, text.content, '\n');
2678 while (lines.next()) |line| {
2679 const plan = try LinePlan.init(atlas, line, wrap_width / scale);
2680 var iterator = plan.iterator();
2681 while (iterator.next(&plan)) |visual| {
2682 max_width = @max(max_width, visual.advance * scale);
2683 line_count += 1;
2684 }
2685 }
2686 if (line_count == 0) line_count = 1;
2687 return .{
2688 .width = if (wrap_width > 0) @min(max_width, wrap_width) else max_width,
2689 .height = line_height * @as(f32, @floatFromInt(line_count)),
2690 };
2691 }
2692
2693 fn measureCompositeUncached(base_atlas: *Atlas, atlases: *const AtlasSet, text: UiText) !Size {
2694 const base_scale = textScale(base_atlas, text);
2695 const wrap_width = textWrapWidth(text);
2696 const multiplier = textLineHeightMultiplier(text);
2697 var max_width: f32 = 0;
2698 var total_height: f32 = 0;
2699 var line_start: usize = 0;
2700 var lines = std.mem.splitScalar(u8, text.content, '\n');
2701 while (lines.next()) |line| {
2702 defer line_start = nextLineStart(text.content.len, line_start, line.len);
2703 const plan = try LinePlan.initComposite(
2704 base_atlas,
2705 atlases,
2706 text,
2707 line,
2708 line_start,
2709 if (wrap_width > 0) wrap_width / base_scale else 0,
2710 );
2711 var iterator = plan.iterator();
2712 while (iterator.next(&plan)) |visual| {
2713 const box = plan.compositeLineBox(visual, multiplier);
2714 max_width = @max(max_width, visual.advance * base_scale);
2715 total_height += box.height * base_scale;
2716 }
2717 }
2718 if (total_height == 0) {
2719 const base = compositeMetricSpan(
2720 atlases.forText(text) orelse return error.MissingTextAtlas,
2721 gui.model.resolvedTextStyle(text, 0),
2722 base_scale,
2723 0,
2724 0,
2725 );
2726 total_height = @max(
2727 base.point_size * multiplier,
2728 base.ascent + base.descent,
2729 ) * base_scale;
2730 }
2731 return .{
2732 .width = if (wrap_width > 0) @min(max_width, wrap_width) else max_width,
2733 .height = total_height,
2734 };
2735 }
2736
2737 fn shapeBitmap(allocator: Allocator, bitmap: *Atlas.Bitmap, content: []const u8) !filigree.GlyphRun {
2738 bitmap.shaped.clearRetainingCapacity();
2739 bitmap.clusters.clearRetainingCapacity();
2740 var total: i32 = 0;
2741 var index: usize = 0;
2742 while (index < content.len) {
2743 const sequence_len = std.unicode.utf8ByteSequenceLength(content[index]) catch {
2744 index += 1;
2745 continue;
2746 };
2747 if (index + sequence_len > content.len) break;
2748 const cluster: u32 = @intCast(index);
2749 const codepoint = std.unicode.utf8Decode(content[index .. index + sequence_len]) catch {
2750 index += sequence_len;
2751 continue;
2752 };
2753 index += sequence_len;
2754 const glyph_start: u32 = @intCast(bitmap.shaped.items.len);
2755 const cluster_index: u32 = @intCast(bitmap.clusters.items.len);
2756 if (codepoint < 0x20) {
2757 try bitmap.clusters.append(allocator, .{
2758 .source = .{ .start = cluster, .end = @intCast(index) },
2759 .glyphs = .{ .start = glyph_start, .end = glyph_start },
2760 });
2761 continue;
2762 }
2763 const glyph_id = bitmap.map.get(codepoint) orelse
2764 bitmap.map.get('?') orelse
2765 std.math.maxInt(u32);
2766 try bitmap.shaped.append(allocator, .{
2767 .glyph_id = glyph_id,
2768 .cluster = cluster,
2769 .cluster_index = cluster_index,
2770 .source_start = cluster,
2771 .source_end = @intCast(index),
2772 .x_advance = bitmap.advance,
2773 .y_advance = 0,
2774 .x_offset = 0,
2775 .y_offset = 0,
2776 });
2777 try bitmap.clusters.append(allocator, .{
2778 .source = .{ .start = cluster, .end = @intCast(index) },
2779 .glyphs = .{ .start = glyph_start, .end = glyph_start + 1 },
2780 });
2781 total = addClampedI32(total, bitmap.advance);
2782 }
2783 return .{
2784 .glyphs = bitmap.shaped.items,
2785 .clusters = bitmap.clusters.items,
2786 .ligature_carets = &.{},
2787 .total_x_advance = total,
2788 .total_y_advance = 0,
2789 .direction = .ltr,
2790 .writing_mode = .horizontal,
2791 .output_order = .visual,
2792 };
2793 }
2794
2795 pub fn packAtlasImageAlloc(allocator: Allocator, atlas: filigree.GlyphAtlas) !OwnedAtlasImage {
2796 if (atlas.width <= 0 or atlas.height <= 0) return error.InvalidAtlas;
2797 const width = std.math.cast(u32, atlas.width) orelse return error.InvalidAtlas;
2798 const height = std.math.cast(u32, atlas.height) orelse return error.InvalidAtlas;
2799 const pixel_count = try pixelCount(width, height);
2800 const byte_count = std.math.mul(usize, pixel_count, 4) catch return error.InvalidAtlas;
2801 if (atlas.rgba.len < byte_count) return error.BufferTooSmall;
2802
2803 const pixels = try allocator.alloc(u32, pixel_count);
2804 errdefer allocator.free(pixels);
2805 for (pixels, 0..) |*pixel, index| {
2806 const base = index * 4;
2807 pixel.* = packRgba(.{
2808 .r = atlas.rgba[base],
2809 .g = atlas.rgba[base + 1],
2810 .b = atlas.rgba[base + 2],
2811 .a = atlas.rgba[base + 3],
2812 });
2813 }
2814 return .{
2815 .image = .{ .width = width, .height = height, .pixels = pixels },
2816 .pixels = pixels,
2817 };
2818 }
2819
2820 fn glyphIndexAlloc(allocator: Allocator, atlas: filigree.GlyphAtlas) !std.AutoHashMapUnmanaged(u32, usize) {
2821 var index: std.AutoHashMapUnmanaged(u32, usize) = .empty;
2822 errdefer index.deinit(allocator);
2823 try index.ensureTotalCapacity(allocator, @intCast(atlas.glyphs.len));
2824 for (atlas.glyphs, 0..) |glyph, position| {
2825 index.putAssumeCapacity(glyph.glyph_id, position);
2826 }
2827 return index;
2828 }
2829
2830 pub fn drawGlyphRun(
2831 writer: anytype,
2832 atlas_image_index: u32,
2833 atlas: filigree.GlyphAtlas,
2834 run: filigree.GlyphRun,
2835 origin_x: f32,
2836 origin_y: f32,
2837 color: Color,
2838 ) void {
2839 var pen_x: i32 = 0;
2840 var pen_y: i32 = 0;
2841 for (run.glyphs) |glyph| {
2842 const atlas_index = findAtlasGlyph(atlas, glyph.glyph_id) orelse {
2843 pen_x = addClampedI32(pen_x, glyph.x_advance);
2844 pen_y = addClampedI32(pen_y, glyph.y_advance);
2845 continue;
2846 };
2847 const atlas_glyph = atlas.glyphs[atlas_index];
2848 const source = atlas.recs[atlas_index];
2849 if (atlas_glyph.width <= 0 or atlas_glyph.height <= 0) {
2850 pen_x = addClampedI32(pen_x, glyph.x_advance);
2851 pen_y = addClampedI32(pen_y, glyph.y_advance);
2852 continue;
2853 }
2854 const rect = Rect{
2855 .x = origin_x + @as(f32, @floatFromInt(round26Dot6(addClampedI32(pen_x, glyph.x_offset)) + atlas_glyph.offset_x)),
2856 .y = origin_y + @as(f32, @floatFromInt(round26Dot6(addClampedI32(pen_y, glyph.y_offset)) + atlas_glyph.offset_y)),
2857 .width = @floatFromInt(atlas_glyph.width),
2858 .height = @floatFromInt(atlas_glyph.height),
2859 };
2860 writer.drawGlyph(rect, atlas_image_index, .{
2861 .x = source.x,
2862 .y = source.y,
2863 .width = source.width,
2864 .height = source.height,
2865 }, color);
2866 pen_x = addClampedI32(pen_x, glyph.x_advance);
2867 pen_y = addClampedI32(pen_y, glyph.y_advance);
2868 }
2869 }
2870
2871 pub fn appendFrameCommands(commands: *command.CommandBuffer, frame: UiFrame, atlases: *const AtlasSet, device_scale: f32, layer: u8) !void {
2872 const start = commands.items().len;
2873 errdefer commands.rollback(start);
2874 for (frame.widgets) |widget| {
2875 if (widget.layer != layer) continue;
2876 try appendWidgetCommands(commands, widget, atlases, device_scale);
2877 }
2878 }
2879
2880 pub fn appendFrameRegionCommands(commands: *command.CommandBuffer, frame: UiFrame, atlases: *const AtlasSet, region: Region, device_scale: f32, layer: u8) !void {
2881 const start = commands.items().len;
2882 errdefer commands.rollback(start);
2883 const rect = regionRect(region);
2884 for (frame.widgets) |widget| {
2885 if (widget.layer != layer) continue;
2886 if (!intersects(scaleRect(widget.visible_rect, device_scale), rect)) continue;
2887 try appendWidgetCommands(commands, widget, atlases, device_scale);
2888 }
2889 }
2890
2891 fn appendWidgetCommands(commands: *command.CommandBuffer, widget: WidgetFrame, atlases: *const AtlasSet, device_scale: f32) !void {
2892 const text = widget.text orelse return;
2893 try gui.model.validateText(text);
2894 const entry = atlases.forText(text) orelse return;
2895 if (hasMixedTextMetrics(text) or try atlases.requiresFallback(entry, text.content)) {
2896 return appendCompositeWidgetCommands(commands, widget, atlases, device_scale);
2897 }
2898 return appendSingleWidgetCommands(commands, widget, atlases, device_scale);
2899 }
2900
2901 fn appendSingleWidgetCommands(commands: *command.CommandBuffer, widget: WidgetFrame, atlases: *const AtlasSet, device_scale: f32) !void {
2902 const text = widget.text.?;
2903 const has_markers = widget.text_selection.cursor_visible or widget.text_selection.selection_active;
2904 if (text.content.len == 0 and !has_markers) return;
2905 const entry = atlases.forText(text) orelse return;
2906 const atlas = entry.atlas;
2907 const foreground = widget.paint.foreground orelse Color{ .r = 28, .g = 34, .b = 31, .a = 255 };
2908 const rect = scaleRect(widget.rect, device_scale);
2909 const layout = try TextLayout.init(atlases, text, rect, device_scale);
2910 const font_metrics = atlas.metrics();
2911 var writer = GlyphCommandWriter{
2912 .commands = commands,
2913 .clip = scaleRect(widget.visible_rect, device_scale),
2914 };
2915 var pending_caret: ?TextMarker = null;
2916 var glyph_runs = TextRunCursor{ .runs = text.runs };
2917 var background_runs = TextRunRangeCursor{ .runs = text.runs };
2918 var decoration_runs = TextRunRangeCursor{ .runs = text.runs };
2919 const caret_run = findTextRun(text.runs, widget.text_selection.cursor_byte_offset);
2920 const caret_foreground = if (caret_run) |run| run.foreground orelse foreground else foreground;
2921 const caret_background = if (caret_run) |run| run.background else null;
2922 var iterator = layout.iterator();
2923 while (try iterator.next()) |line| {
2924 if (text.runs.len > 0) {
2925 appendRunBackgroundCommands(
2926 &writer,
2927 &background_runs,
2928 line.plan,
2929 line.visual,
2930 line.hard_line_start,
2931 line.origin_x,
2932 line.y,
2933 line.height,
2934 line.scale,
2935 );
2936 }
2937 const cursor = if (has_markers) blk: {
2938 appendSelectionCommand(
2939 &writer,
2940 widget.text_selection,
2941 line.plan,
2942 line.visual,
2943 line.hard_line_start,
2944 line.origin_x,
2945 line.y,
2946 line.height,
2947 line.scale,
2948 foreground,
2949 );
2950 if (caretMarker(
2951 atlas,
2952 widget.text_selection,
2953 line.plan,
2954 line.visual,
2955 line.hard_line_start,
2956 line.origin_x,
2957 line.y,
2958 line.height,
2959 line.scale,
2960 device_scale,
2961 caret_foreground,
2962 )) |marker| {
2963 if (widget.text_selection.cursor_block) {
2964 writer.fillRect(marker.rect, marker.color);
2965 } else {
2966 pending_caret = marker;
2967 }
2968 }
2969 break :blk blockCursorHighlight(
2970 widget,
2971 line.plan,
2972 line.visual,
2973 line.hard_line_start,
2974 caret_foreground,
2975 caret_background,
2976 );
2977 } else null;
2978 var line_run = line.plan.run;
2979 line_run.glyphs = line.visual.glyphs(line.plan.run);
2980 if (text.runs.len == 0) {
2981 drawGlyphRunScaled(
2982 &writer,
2983 entry.image_index,
2984 atlas,
2985 line_run,
2986 line.hard_line,
2987 line.y,
2988 line.height,
2989 line.origin_x,
2990 line.origin_y,
2991 line.scale,
2992 foreground,
2993 cursor,
2994 );
2995 } else {
2996 drawStyledGlyphRunScaled(
2997 &writer,
2998 entry.image_index,
2999 atlas,
3000 line_run,
3001 line.hard_line,
3002 line.hard_line_start,
3003 line.y,
3004 line.height,
3005 line.origin_x,
3006 line.origin_y,
3007 line.scale,
3008 foreground,
3009 cursor,
3010 &glyph_runs,
3011 );
3012 appendRunDecorationCommands(
3013 &writer,
3014 &decoration_runs,
3015 line.plan,
3016 line.visual,
3017 line.hard_line_start,
3018 line.origin_x,
3019 line.y,
3020 line.origin_y,
3021 line.height,
3022 line.scale,
3023 device_scale,
3024 font_metrics,
3025 foreground,
3026 );
3027 }
3028 }
3029 if (pending_caret) |caret| writer.fillRect(caret.rect, caret.color);
3030 if (writer.failure) |failure| return failure;
3031 }
3032
3033 fn appendCompositeWidgetCommands(
3034 commands: *command.CommandBuffer,
3035 widget: WidgetFrame,
3036 atlases: *const AtlasSet,
3037 device_scale: f32,
3038 ) !void {
3039 const text = widget.text.?;
3040 const has_markers = widget.text_selection.cursor_visible or
3041 widget.text_selection.selection_active;
3042 if (text.content.len == 0 and !has_markers) return;
3043 const entry = atlases.forText(text) orelse return;
3044 const base_atlas = entry.atlas;
3045 const foreground = widget.paint.foreground orelse
3046 Color{ .r = 28, .g = 34, .b = 31, .a = 255 };
3047 const rect = scaleRect(widget.rect, device_scale);
3048 const layout = try TextLayout.init(atlases, text, rect, device_scale);
3049 var writer = GlyphCommandWriter{
3050 .commands = commands,
3051 .clip = scaleRect(widget.visible_rect, device_scale),
3052 };
3053 var pending_caret: ?TextMarker = null;
3054 var glyph_runs = TextRunCursor{ .runs = text.runs };
3055 var background_runs = TextRunRangeCursor{ .runs = text.runs };
3056 var decoration_runs = TextRunRangeCursor{ .runs = text.runs };
3057 const caret_run = findTextRun(text.runs, widget.text_selection.cursor_byte_offset);
3058 const caret_foreground = if (caret_run) |run|
3059 run.foreground orelse foreground
3060 else
3061 foreground;
3062 const caret_background = if (caret_run) |run| run.background else null;
3063 var iterator = layout.iterator();
3064 while (try iterator.next()) |line| {
3065 appendRunBackgroundCommands(
3066 &writer,
3067 &background_runs,
3068 line.plan,
3069 line.visual,
3070 line.hard_line_start,
3071 line.origin_x,
3072 line.y,
3073 line.height,
3074 line.scale,
3075 );
3076 const cursor = if (has_markers) blk: {
3077 appendSelectionCommand(
3078 &writer,
3079 widget.text_selection,
3080 line.plan,
3081 line.visual,
3082 line.hard_line_start,
3083 line.origin_x,
3084 line.y,
3085 line.height,
3086 line.scale,
3087 foreground,
3088 );
3089 if (caretMarker(
3090 base_atlas,
3091 widget.text_selection,
3092 line.plan,
3093 line.visual,
3094 line.hard_line_start,
3095 line.origin_x,
3096 line.y,
3097 line.height,
3098 line.scale,
3099 device_scale,
3100 caret_foreground,
3101 )) |marker| {
3102 if (widget.text_selection.cursor_block) {
3103 writer.fillRect(marker.rect, marker.color);
3104 } else {
3105 pending_caret = marker;
3106 }
3107 }
3108 break :blk blockCursorHighlight(
3109 widget,
3110 line.plan,
3111 line.visual,
3112 line.hard_line_start,
3113 caret_foreground,
3114 caret_background,
3115 );
3116 } else null;
3117 drawCompositeStyledGlyphRunScaled(
3118 &writer,
3119 line.plan,
3120 line.visual,
3121 line.hard_line,
3122 line.hard_line_start,
3123 line.y,
3124 line.height,
3125 line.origin_x,
3126 line.origin_y,
3127 line.scale,
3128 line.ascent,
3129 foreground,
3130 cursor,
3131 &glyph_runs,
3132 );
3133 appendRunDecorationCommands(
3134 &writer,
3135 &decoration_runs,
3136 line.plan,
3137 line.visual,
3138 line.hard_line_start,
3139 line.origin_x,
3140 line.y,
3141 line.origin_y,
3142 line.height,
3143 line.scale,
3144 device_scale,
3145 .{ .ascent = line.ascent, .descent = line.descent, .line_gap = 0 },
3146 foreground,
3147 );
3148 }
3149 if (pending_caret) |caret| writer.fillRect(caret.rect, caret.color);
3150 if (writer.failure) |failure| return failure;
3151 }
3152
3153 fn scaleRect(rect: Rect, scale: f32) Rect {
3154 return .{
3155 .x = rect.x * scale,
3156 .y = rect.y * scale,
3157 .width = rect.width * scale,
3158 .height = rect.height * scale,
3159 };
3160 }
3161
3162 fn regionRect(region: Region) Rect {
3163 return .{
3164 .x = @floatFromInt(region.x),
3165 .y = @floatFromInt(region.y),
3166 .width = @floatFromInt(region.width),
3167 .height = @floatFromInt(region.height),
3168 };
3169 }
3170
3171 fn intersects(left: Rect, right: Rect) bool {
3172 return left.x < right.x + right.width and
3173 right.x < left.x + left.width and
3174 left.y < right.y + right.height and
3175 right.y < left.y + left.height;
3176 }
3177
3178 const GlyphCommandWriter = struct {
3179 commands: *command.CommandBuffer,
3180 clip: Rect,
3181 failure: ?Allocator.Error = null,
3182
3183 fn drawGlyph(self: *GlyphCommandWriter, rect: Rect, image_index: u32, source: Rect, color: Color) void {
3184 if (self.failure != null) return;
3185 self.commands.append(.{
3186 .kind = .glyph,
3187 .rect = rect,
3188 .clip = self.clip,
3189 .source = source,
3190 .color = color,
3191 .image_index = image_index,
3192 }) catch |failure| {
3193 self.failure = failure;
3194 };
3195 }
3196
3197 pub fn fillRect(self: *GlyphCommandWriter, rect: Rect, color: Color) void {
3198 if (self.failure != null) return;
3199 self.commands.append(.{
3200 .kind = .fill,
3201 .rect = rect,
3202 .clip = self.clip,
3203 .color = color,
3204 }) catch |failure| {
3205 self.failure = failure;
3206 };
3207 }
3208 };
3209
3210 const TextRunCursor = struct {
3211 runs: []const UiTextRun,
3212 index: usize = 0,
3213 last_offset: usize = 0,
3214 started: bool = false,
3215
3216 fn find(self: *TextRunCursor, byte_offset: usize) ?UiTextRun {
3217 if (!self.started or byte_offset < self.last_offset) {
3218 self.index = firstRunEndingAfter(self.runs, byte_offset);
3219 self.started = true;
3220 } else {
3221 while (self.index < self.runs.len and self.runs[self.index].byte_end <= byte_offset) {
3222 self.index += 1;
3223 }
3224 }
3225 self.last_offset = byte_offset;
3226 if (self.index >= self.runs.len) return null;
3227 const run = self.runs[self.index];
3228 if (byte_offset < run.byte_start) return null;
3229 return run;
3230 }
3231 };
3232
3233 const TextRunRangeCursor = struct {
3234 runs: []const UiTextRun,
3235 index: usize = 0,
3236
3237 fn firstOverlap(self: *TextRunRangeCursor, byte_start: usize) usize {
3238 while (self.index < self.runs.len and self.runs[self.index].byte_end <= byte_start) {
3239 self.index += 1;
3240 }
3241 return self.index;
3242 }
3243 };
3244
3245 fn firstRunEndingAfter(runs: []const UiTextRun, byte_offset: usize) usize {
3246 var low: usize = 0;
3247 var high = runs.len;
3248 while (low < high) {
3249 const middle = low + (high - low) / 2;
3250 if (runs[middle].byte_end <= byte_offset) {
3251 low = middle + 1;
3252 } else {
3253 high = middle;
3254 }
3255 }
3256 return low;
3257 }
3258
3259 fn findTextRun(runs: []const UiTextRun, byte_offset: usize) ?UiTextRun {
3260 const index = firstRunEndingAfter(runs, byte_offset);
3261 if (index >= runs.len or byte_offset < runs[index].byte_start) return null;
3262 return runs[index];
3263 }
3264
3265 fn appendRunBackgroundCommands(
3266 writer: *GlyphCommandWriter,
3267 cursor: *TextRunRangeCursor,
3268 plan: *const LinePlan,
3269 visual: VisualLine,
3270 line_start: usize,
3271 origin_x: f32,
3272 line_y: f32,
3273 line_height: f32,
3274 scale: f32,
3275 ) void {
3276 const visual_start = line_start + visual.byte_start;
3277 const visual_end = line_start + visual.byte_end;
3278 if (visual_end <= visual_start) return;
3279 var index = cursor.firstOverlap(visual_start);
3280 while (index < cursor.runs.len and cursor.runs[index].byte_start < visual_end) : (index += 1) {
3281 const run = cursor.runs[index];
3282 const color = run.background orelse continue;
3283 const rect = textRunRect(
3284 plan,
3285 visual,
3286 line_start,
3287 @max(run.byte_start, visual_start),
3288 @min(run.byte_end, visual_end),
3289 origin_x,
3290 line_y,
3291 line_height,
3292 scale,
3293 ) orelse continue;
3294 writer.fillRect(rect, color);
3295 }
3296 }
3297
3298 fn appendRunDecorationCommands(
3299 writer: *GlyphCommandWriter,
3300 cursor: *TextRunRangeCursor,
3301 plan: *const LinePlan,
3302 visual: VisualLine,
3303 line_start: usize,
3304 origin_x: f32,
3305 line_y: f32,
3306 origin_y: f32,
3307 line_height: f32,
3308 scale: f32,
3309 device_scale: f32,
3310 metrics: Metrics,
3311 foreground: Color,
3312 ) void {
3313 const visual_start = line_start + visual.byte_start;
3314 const visual_end = line_start + visual.byte_end;
3315 if (visual_end <= visual_start) return;
3316 const thickness = @max(device_scale, @min(line_height * 0.08, @max(device_scale, scale)));
3317 const bottom = line_y + line_height - thickness;
3318 const baseline = origin_y + metrics.ascent * scale;
3319 const underline_y = @min(bottom, baseline + @max(device_scale, scale * 0.5));
3320 const strike_y = std.math.clamp(origin_y + metrics.ascent * scale * 0.55, line_y, bottom);
3321 var index = cursor.firstOverlap(visual_start);
3322 while (index < cursor.runs.len and cursor.runs[index].byte_start < visual_end) : (index += 1) {
3323 const run = cursor.runs[index];
3324 if (!run.underline and !run.strikethrough) continue;
3325 const range = textRunRect(
3326 plan,
3327 visual,
3328 line_start,
3329 @max(run.byte_start, visual_start),
3330 @min(run.byte_end, visual_end),
3331 origin_x,
3332 0,
3333 thickness,
3334 scale,
3335 ) orelse continue;
3336 const color = run.foreground orelse foreground;
3337 if (run.underline) {
3338 writer.fillRect(.{
3339 .x = range.x,
3340 .y = underline_y,
3341 .width = range.width,
3342 .height = thickness,
3343 }, color);
3344 }
3345 if (run.strikethrough) {
3346 writer.fillRect(.{
3347 .x = range.x,
3348 .y = strike_y,
3349 .width = range.width,
3350 .height = thickness,
3351 }, color);
3352 }
3353 }
3354 }
3355
3356 fn textRunRect(
3357 plan: *const LinePlan,
3358 visual: VisualLine,
3359 line_start: usize,
3360 byte_start: usize,
3361 byte_end: usize,
3362 origin_x: f32,
3363 y: f32,
3364 height: f32,
3365 scale: f32,
3366 ) ?Rect {
3367 if (byte_end <= byte_start) return null;
3368 const start_x = origin_x + visual.advanceForByteOffset(
3369 plan.run,
3370 byte_start - line_start,
3371 plan.content.len,
3372 ) * scale;
3373 const end_x = origin_x + visual.advanceForByteOffset(
3374 plan.run,
3375 byte_end - line_start,
3376 plan.content.len,
3377 ) * scale;
3378 const left = @min(start_x, end_x);
3379 const right = @max(start_x, end_x);
3380 if (right <= left) return null;
3381 return .{
3382 .x = left,
3383 .y = y,
3384 .width = right - left,
3385 .height = height,
3386 };
3387 }
3388
3389 const TextMarker = struct {
3390 rect: Rect,
3391 color: Color,
3392 };
3393
3394 fn appendSelectionCommand(
3395 writer: *GlyphCommandWriter,
3396 selection: UiTextSelection,
3397 plan: *const LinePlan,
3398 visual: VisualLine,
3399 line_start: usize,
3400 origin_x: f32,
3401 line_y: f32,
3402 line_height: f32,
3403 scale: f32,
3404 foreground: Color,
3405 ) void {
3406 if (!selection.selection_active) return;
3407 const selected = sortedSelection(selection);
3408 const visual_start = line_start + visual.byte_start;
3409 const visual_end = line_start + visual.byte_end;
3410 const start = @max(selected.start, visual_start);
3411 const end = @min(selected.end, visual_end);
3412 if (end <= start) return;
3413 const start_x = origin_x + visual.advanceForByteOffset(plan.run, start - line_start, plan.content.len) * scale;
3414 const end_x = origin_x + visual.advanceForByteOffset(plan.run, end - line_start, plan.content.len) * scale;
3415 writer.fillRect(.{
3416 .x = start_x,
3417 .y = line_y,
3418 .width = end_x - start_x,
3419 .height = line_height,
3420 }, .{ .r = foreground.r, .g = foreground.g, .b = foreground.b, .a = 64 });
3421 }
3422
3423 fn caretMarker(
3424 atlas: *Atlas,
3425 selection: UiTextSelection,
3426 plan: *const LinePlan,
3427 visual: VisualLine,
3428 line_start: usize,
3429 origin_x: f32,
3430 line_y: f32,
3431 line_height: f32,
3432 scale: f32,
3433 device_scale: f32,
3434 foreground: Color,
3435 ) ?TextMarker {
3436 if (!selection.cursor_visible) return null;
3437 if (selection.cursor_byte_offset < line_start) return null;
3438 const cursor_offset = selection.cursor_byte_offset - line_start;
3439 if (!visual.containsCaret(cursor_offset, .downstream)) return null;
3440 const x = origin_x + visual.advanceForByteOffset(plan.run, cursor_offset, plan.content.len) * scale;
3441 if (selection.cursor_block) {
3442 return .{
3443 .rect = .{
3444 .x = x,
3445 .y = line_y,
3446 .width = caretBlockWidth(atlas, plan, visual, cursor_offset, scale, device_scale),
3447 .height = @max(1, line_height),
3448 },
3449 .color = .{ .r = foreground.r, .g = foreground.g, .b = foreground.b, .a = 176 },
3450 };
3451 }
3452 const inset = @min(line_height * 0.12, @max(device_scale, scale));
3453 return .{
3454 .rect = .{
3455 .x = x,
3456 .y = line_y + inset,
3457 .width = @max(device_scale, scale),
3458 .height = @max(1, line_height - inset * 2),
3459 },
3460 .color = foreground,
3461 };
3462 }
3463
3464 fn blockCursorHighlight(
3465 widget: WidgetFrame,
3466 plan: *const LinePlan,
3467 visual: VisualLine,
3468 line_start: usize,
3469 foreground: Color,
3470 run_background: ?Color,
3471 ) ?CursorHighlight {
3472 const selection = widget.text_selection;
3473 if (!selection.cursor_visible or !selection.cursor_block) return null;
3474 if (selection.cursor_byte_offset < line_start) return null;
3475 const start = selection.cursor_byte_offset - line_start;
3476 if (!visual.containsCaret(start, .downstream) or start >= visual.byte_end) return null;
3477 const end = @min(nextCaretByteOffset(plan.run, plan.content, start), visual.byte_end);
3478 if (end <= start) return null;
3479 return .{
3480 .lead_advance = visual.advanceForByteOffset(plan.run, start, plan.content.len),
3481 .trail_advance = visual.advanceForByteOffset(plan.run, end, plan.content.len),
3482 .color = run_background orelse widget.paint.background orelse contrastColor(foreground),
3483 };
3484 }
3485
3486 fn contrastColor(foreground: Color) Color {
3487 const luminance = (@as(u32, foreground.r) * 299 + @as(u32, foreground.g) * 587 + @as(u32, foreground.b) * 114) / 1000;
3488 if (luminance > 128) return .{ .r = 24, .g = 27, .b = 26, .a = 255 };
3489 return .{ .r = 245, .g = 246, .b = 244, .a = 255 };
3490 }
3491
3492 fn caretBlockWidth(atlas: *Atlas, plan: *const LinePlan, visual: VisualLine, cursor_offset: usize, scale: f32, device_scale: f32) f32 {
3493 if (cursor_offset < visual.byte_end) {
3494 const end = @min(nextCaretByteOffset(plan.run, plan.content, cursor_offset), visual.byte_end);
3495 const lead = visual.advanceForByteOffset(plan.run, cursor_offset, plan.content.len) * scale;
3496 const trail = visual.advanceForByteOffset(plan.run, end, plan.content.len) * scale;
3497 if (trail > lead) return trail - lead;
3498 }
3499 const run = atlas.shape("M") catch return @max(device_scale, scale);
3500 return @max(1, runAdvance(run) * scale);
3501 }
3502
3503 const TextRange = struct {
3504 start: usize,
3505 end: usize,
3506 };
3507
3508 fn sortedSelection(selection: UiTextSelection) TextRange {
3509 const anchor = selection.selection_anchor_byte_offset;
3510 const focus = selection.selection_focus_byte_offset;
3511 return if (anchor <= focus)
3512 .{ .start = anchor, .end = focus }
3513 else
3514 .{ .start = focus, .end = anchor };
3515 }
3516
3517 fn findAtlasGlyph(atlas: filigree.GlyphAtlas, glyph_id: u32) ?usize {
3518 for (atlas.glyphs, 0..) |glyph, index| {
3519 if (glyph.glyph_id == glyph_id) return index;
3520 }
3521 return null;
3522 }
3523
3524 fn synthCodepoint(line: []const u8, glyph: filigree.ShapedGlyph) ?u21 {
3525 if (glyph.source_codepoint_count != 1) return null;
3526 const start: usize = glyph.source_start;
3527 const end: usize = glyph.source_end;
3528 if (start >= end or end > line.len) return null;
3529 const sequence = line[start..end];
3530 const length = std.unicode.utf8ByteSequenceLength(sequence[0]) catch return null;
3531 if (length > sequence.len) return null;
3532 const codepoint = std.unicode.utf8Decode(sequence[0..length]) catch return null;
3533 return if (synth.covered(codepoint)) codepoint else null;
3534 }
3535
3536 fn drawSyntheticOrNotdef(
3537 writer: anytype,
3538 line: []const u8,
3539 glyph: filigree.ShapedGlyph,
3540 pen_x: i32,
3541 line_y: f32,
3542 line_height: f32,
3543 origin_x: f32,
3544 scale: f32,
3545 color: Color,
3546 ) void {
3547 std.debug.assert(glyph.glyph_id == 0);
3548 const cell = Rect{
3549 .x = origin_x +
3550 @as(f32, @floatFromInt(round26Dot6(addClampedI32(pen_x, glyph.x_offset)))) * scale,
3551 .y = line_y,
3552 .width = @as(f32, @floatFromInt(glyph.x_advance)) / 64 * scale,
3553 .height = line_height,
3554 };
3555 if (synthCodepoint(line, glyph)) |codepoint| {
3556 const drawn = synth.draw(writer, codepoint, cell, color);
3557 std.debug.assert(drawn);
3558 return;
3559 }
3560 synth.drawNotdef(writer, cell, color);
3561 }
3562
3563 const CursorHighlight = struct {
3564 lead_advance: f32,
3565 trail_advance: f32,
3566 color: Color,
3567 };
3568
3569 fn glyphRunColor(pen_x: i32, cursor: ?CursorHighlight, color: Color) Color {
3570 const highlight = cursor orelse return color;
3571 const pen = @as(f32, @floatFromInt(pen_x)) / 64;
3572 const epsilon = 0.01;
3573 if (pen >= highlight.lead_advance - epsilon and pen < highlight.trail_advance - epsilon) return highlight.color;
3574 return color;
3575 }
3576
3577 fn drawCompositeStyledGlyphRunScaled(
3578 writer: anytype,
3579 plan: *const LinePlan,
3580 visual: VisualLine,
3581 line: []const u8,
3582 line_start: usize,
3583 line_y: f32,
3584 line_height: f32,
3585 origin_x: f32,
3586 origin_y: f32,
3587 scale: f32,
3588 line_ascent: f32,
3589 color: Color,
3590 cursor: ?CursorHighlight,
3591 run_cursor: *TextRunCursor,
3592 ) void {
3593 var pen_x: i32 = 0;
3594 var pen_y: i32 = 0;
3595 for (plan.run.glyphs[visual.glyph_start..visual.glyph_end], visual.glyph_start..) |glyph, glyph_index| {
3596 const byte_offset = line_start + sourceOffset(glyph.source_start, line.len);
3597 const text_run = run_cursor.find(byte_offset);
3598 const styled_color = if (text_run) |value|
3599 value.foreground orelse color
3600 else
3601 color;
3602 const glyph_color = glyphRunColor(pen_x, cursor, styled_color);
3603 if (glyph.glyph_id == 0) {
3604 drawSyntheticOrNotdef(
3605 writer,
3606 line,
3607 glyph,
3608 pen_x,
3609 line_y,
3610 line_height,
3611 origin_x,
3612 scale,
3613 glyph_color,
3614 );
3615 pen_x = addClampedI32(pen_x, glyph.x_advance);
3616 pen_y = addClampedI32(pen_y, glyph.y_advance);
3617 continue;
3618 }
3619 const source_index = plan.glyph_sources[glyph_index].segment_index;
3620 const span = plan.metric_spans[source_index];
3621 const atlas = span.atlas;
3622 const atlas_index = atlas.glyph_index.get(glyph.glyph_id) orelse {
3623 pen_x = addClampedI32(pen_x, glyph.x_advance);
3624 pen_y = addClampedI32(pen_y, glyph.y_advance);
3625 continue;
3626 };
3627 const atlas_glyph = atlas.atlas.glyphs[atlas_index];
3628 const source = atlas.atlas.recs[atlas_index];
3629 if (atlas_glyph.width <= 0 or atlas_glyph.height <= 0) {
3630 pen_x = addClampedI32(pen_x, glyph.x_advance);
3631 pen_y = addClampedI32(pen_y, glyph.y_advance);
3632 continue;
3633 }
3634 const style_origin_y = origin_y + (line_ascent - span.ascent) * scale;
3635 const rect = Rect{
3636 .x = origin_x +
3637 @as(f32, @floatFromInt(round26Dot6(addClampedI32(pen_x, glyph.x_offset)))) * scale +
3638 @as(f32, @floatFromInt(atlas_glyph.offset_x)) * span.scale_ratio * scale,
3639 .y = style_origin_y +
3640 @as(f32, @floatFromInt(round26Dot6(addClampedI32(pen_y, glyph.y_offset)))) * scale +
3641 @as(f32, @floatFromInt(atlas_glyph.offset_y)) * span.scale_ratio * scale,
3642 .width = @as(f32, @floatFromInt(atlas_glyph.width)) * span.scale_ratio * scale,
3643 .height = @as(f32, @floatFromInt(atlas_glyph.height)) * span.scale_ratio * scale,
3644 };
3645 writer.drawGlyph(rect, span.image_index, .{
3646 .x = source.x,
3647 .y = source.y,
3648 .width = source.width,
3649 .height = source.height,
3650 }, glyph_color);
3651 pen_x = addClampedI32(pen_x, glyph.x_advance);
3652 pen_y = addClampedI32(pen_y, glyph.y_advance);
3653 }
3654 }
3655
3656 fn drawGlyphRunScaled(
3657 writer: anytype,
3658 atlas_image_index: u32,
3659 atlas: *const Atlas,
3660 run: filigree.GlyphRun,
3661 line: []const u8,
3662 line_y: f32,
3663 line_height: f32,
3664 origin_x: f32,
3665 origin_y: f32,
3666 scale: f32,
3667 color: Color,
3668 cursor: ?CursorHighlight,
3669 ) void {
3670 drawGlyphRunScaledMode(
3671 false,
3672 writer,
3673 atlas_image_index,
3674 atlas,
3675 run,
3676 line,
3677 0,
3678 line_y,
3679 line_height,
3680 origin_x,
3681 origin_y,
3682 scale,
3683 color,
3684 cursor,
3685 null,
3686 );
3687 }
3688
3689 fn drawStyledGlyphRunScaled(
3690 writer: anytype,
3691 atlas_image_index: u32,
3692 atlas: *const Atlas,
3693 run: filigree.GlyphRun,
3694 line: []const u8,
3695 line_start: usize,
3696 line_y: f32,
3697 line_height: f32,
3698 origin_x: f32,
3699 origin_y: f32,
3700 scale: f32,
3701 color: Color,
3702 cursor: ?CursorHighlight,
3703 run_cursor: *TextRunCursor,
3704 ) void {
3705 drawGlyphRunScaledMode(
3706 true,
3707 writer,
3708 atlas_image_index,
3709 atlas,
3710 run,
3711 line,
3712 line_start,
3713 line_y,
3714 line_height,
3715 origin_x,
3716 origin_y,
3717 scale,
3718 color,
3719 cursor,
3720 run_cursor,
3721 );
3722 }
3723
3724 fn drawGlyphRunScaledMode(
3725 comptime styled: bool,
3726 writer: anytype,
3727 atlas_image_index: u32,
3728 atlas: *const Atlas,
3729 run: filigree.GlyphRun,
3730 line: []const u8,
3731 line_start: usize,
3732 line_y: f32,
3733 line_height: f32,
3734 origin_x: f32,
3735 origin_y: f32,
3736 scale: f32,
3737 color: Color,
3738 cursor: ?CursorHighlight,
3739 run_cursor: ?*TextRunCursor,
3740 ) void {
3741 var pen_x: i32 = 0;
3742 var pen_y: i32 = 0;
3743 for (run.glyphs) |glyph| {
3744 const styled_color = if (styled) blk: {
3745 const byte_offset = line_start + sourceOffset(glyph.source_start, line.len);
3746 const text_run = run_cursor.?.find(byte_offset);
3747 break :blk if (text_run) |value| value.foreground orelse color else color;
3748 } else color;
3749 const glyph_color = glyphRunColor(pen_x, cursor, styled_color);
3750 if (glyph.glyph_id == 0) {
3751 drawSyntheticOrNotdef(
3752 writer,
3753 line,
3754 glyph,
3755 pen_x,
3756 line_y,
3757 line_height,
3758 origin_x,
3759 scale,
3760 glyph_color,
3761 );
3762 pen_x = addClampedI32(pen_x, glyph.x_advance);
3763 pen_y = addClampedI32(pen_y, glyph.y_advance);
3764 continue;
3765 }
3766 const atlas_index = atlas.glyph_index.get(glyph.glyph_id) orelse {
3767 pen_x = addClampedI32(pen_x, glyph.x_advance);
3768 pen_y = addClampedI32(pen_y, glyph.y_advance);
3769 continue;
3770 };
3771 const atlas_glyph = atlas.atlas.glyphs[atlas_index];
3772 const source = atlas.atlas.recs[atlas_index];
3773 if (atlas_glyph.width <= 0 or atlas_glyph.height <= 0) {
3774 pen_x = addClampedI32(pen_x, glyph.x_advance);
3775 pen_y = addClampedI32(pen_y, glyph.y_advance);
3776 continue;
3777 }
3778 const rect = Rect{
3779 .x = origin_x + @as(f32, @floatFromInt(round26Dot6(addClampedI32(pen_x, glyph.x_offset)) + atlas_glyph.offset_x)) * scale,
3780 .y = origin_y + @as(f32, @floatFromInt(round26Dot6(addClampedI32(pen_y, glyph.y_offset)) + atlas_glyph.offset_y)) * scale,
3781 .width = @as(f32, @floatFromInt(atlas_glyph.width)) * scale,
3782 .height = @as(f32, @floatFromInt(atlas_glyph.height)) * scale,
3783 };
3784 writer.drawGlyph(rect, atlas_image_index, .{
3785 .x = source.x,
3786 .y = source.y,
3787 .width = source.width,
3788 .height = source.height,
3789 }, glyph_color);
3790 pen_x = addClampedI32(pen_x, glyph.x_advance);
3791 pen_y = addClampedI32(pen_y, glyph.y_advance);
3792 }
3793 }
3794
3795 fn runAdvance(run: filigree.GlyphRun) f32 {
3796 return @as(f32, @floatFromInt(run.total_x_advance)) / 64;
3797 }
3798
3799 fn glyphSliceAdvance(glyphs: []const filigree.ShapedGlyph) f32 {
3800 var advance: f32 = 0;
3801 for (glyphs) |glyph| advance += @as(f32, @floatFromInt(glyph.x_advance)) / 64;
3802 return advance;
3803 }
3804
3805 fn nextCaretByteOffset(run: filigree.GlyphRun, text: []const u8, byte_offset: usize) usize {
3806 const target = @min(byte_offset, text.len);
3807 if (target >= text.len) return text.len;
3808 const map = run.clusterMap();
3809 for (run.clusters, 0..) |cluster, cluster_index| {
3810 const start = sourceOffset(cluster.source.start, text.len);
3811 const end = sourceOffset(cluster.source.end, text.len);
3812 if (end <= target) continue;
3813 if (target < start) return start;
3814 const stop_count = map.clusterCaretStopCount(cluster_index) orelse 0;
3815 if (stop_count > 2) {
3816 var stop_index: usize = 1;
3817 while (stop_index < stop_count) : (stop_index += 1) {
3818 const stop = map.clusterCaretStop(cluster_index, stop_index) orelse continue;
3819 const offset = sourceOffset(stop, text.len);
3820 if (offset > target) return offset;
3821 }
3822 return end;
3823 }
3824 return nextGraphemeBoundary(text, start, end, target);
3825 }
3826 return text.len;
3827 }
3828
3829 fn nextGraphemeBoundary(text: []const u8, start: usize, end: usize, target: usize) usize {
3830 if (end <= start) return end;
3831 const source_start = std.math.cast(u32, start) orelse return end;
3832 var iterator = filigree.unicode.SourceIterator.init(.{ .utf8 = text[start..end] }, source_start) catch return end;
3833 var state: filigree.unicode.GraphemeState = .{};
3834 while (iterator.next() catch return end) |scalar| {
3835 const boundary = !state.consume(scalar.codepoint);
3836 const offset = sourceOffset(scalar.source.start, text.len);
3837 if (boundary and offset > target) return offset;
3838 }
3839 return end;
3840 }
3841
3842 fn sourceOffset(offset: u32, text_len: usize) usize {
3843 return @min(@as(usize, @intCast(offset)), text_len);
3844 }
3845
3846 fn nextLineStart(content_len: usize, line_start: usize, line_len: usize) usize {
3847 const line_end = @min(content_len, line_start + line_len);
3848 return if (line_end < content_len) line_end + 1 else line_end;
3849 }
3850
3851 fn textPointSize(atlas: *const Atlas, text: UiText) f32 {
3852 if (std.math.isFinite(text.point_size) and text.point_size > 0) return @floatCast(text.point_size);
3853 if (atlas.pixel_size > 0) return @floatFromInt(atlas.pixel_size);
3854 return 16;
3855 }
3856
3857 fn textScale(atlas: *const Atlas, text: UiText) f32 {
3858 if (atlas.pixel_size <= 0) return 1;
3859 return textPointSize(atlas, text) / @as(f32, @floatFromInt(atlas.pixel_size));
3860 }
3861
3862 fn textWrapWidth(text: UiText) f32 {
3863 if (std.math.isFinite(text.wrap_width) and text.wrap_width > 0) return @floatCast(text.wrap_width);
3864 return 0;
3865 }
3866
3867 fn textLineHeight(atlas: *const Atlas, text: UiText) f32 {
3868 const multiplier = textLineHeightMultiplier(text);
3869 const requested = textPointSize(atlas, text) * multiplier;
3870 return @max(requested, atlas.metrics().height() * textScale(atlas, text));
3871 }
3872
3873 fn textLineHeightMultiplier(text: UiText) f32 {
3874 return if (std.math.isFinite(text.line_height) and text.line_height > 0)
3875 @floatCast(text.line_height)
3876 else
3877 1.2;
3878 }
3879
3880 fn alignedStart(start: f32, container: f32, content: f32, alignment: gui.model.UiTextAlign) f32 {
3881 return switch (alignment) {
3882 .start => start,
3883 .center => start + @max(container - content, 0) / 2,
3884 .end => start + @max(container - content, 0),
3885 };
3886 }
3887
3888 fn pixelCount(width: u32, height: u32) !usize {
3889 return std.math.mul(usize, width, height) catch error.InvalidAtlas;
3890 }
3891
3892 fn packRgba(color: Color) u32 {
3893 return @as(u32, color.r) |
3894 (@as(u32, color.g) << 8) |
3895 (@as(u32, color.b) << 16) |
3896 (@as(u32, color.a) << 24);
3897 }
3898
3899 fn round26Dot6(value: i32) i32 {
3900 if (value >= 0) return @divTrunc(value + 32, 64);
3901 return @divTrunc(value - 32, 64);
3902 }
3903
3904 fn addClampedI32(left: i32, right: i32) i32 {
3905 const value = @as(i64, left) + @as(i64, right);
3906 if (value > std.math.maxInt(i32)) return std.math.maxInt(i32);
3907 if (value < std.math.minInt(i32)) return std.math.minInt(i32);
3908 return @intCast(value);
3909 }
3910
3911 const TestRecorder = struct {
3912 glyph_count: usize = 0,
3913 last_rect: Rect = .{},
3914 last_source: Rect = .{},
3915 last_color: Color = .{},
3916 last_image_index: u32 = 0,
3917
3918 fn drawGlyph(self: *TestRecorder, rect: Rect, image_index: u32, source: Rect, color: Color) void {
3919 self.glyph_count += 1;
3920 self.last_rect = rect;
3921 self.last_source = source;
3922 self.last_color = color;
3923 self.last_image_index = image_index;
3924 }
3925 };
3926
3927 const test_output_limits: filigree.Output.Limits = .{
3928 .max_glyphs = 4096,
3929 .max_ligature_carets = 4096,
3930 };
3931
3932 const test_atlas_cache_limits: AtlasCacheStorage.Limits = .{
3933 .measure_entries = 512,
3934 .measure_payload_bytes = 64 * 1024,
3935 .shape_entries = 1024,
3936 .shape_payload_bytes = 512 * 1024,
3937 };
3938
3939 fn initShapeOnlyAtlas(allocator: Allocator, bytes: []u8, pixel_size: i32) !Atlas {
3940 errdefer allocator.free(bytes);
3941 var font = filigree.Font.initFromBytes(bytes.ptr, bytes.len) orelse return error.InvalidFont;
3942 errdefer font.deinit();
3943 font.setScale(@floatFromInt(pixel_size), 72);
3944 var cache = try AtlasCacheStorage.init(allocator, test_atlas_cache_limits);
3945 errdefer cache.deinit(allocator);
3946 cache.activate();
3947 return .{
3948 .allocator = allocator,
3949 .pixel_size = pixel_size,
3950 .atlas = .{},
3951 .image = .{
3952 .image = .{ .width = 0, .height = 0, .pixels = &.{} },
3953 .pixels = &.{},
3954 },
3955 .cache = cache,
3956 .backend = .{ .outline = .{
3957 .font_bytes = bytes,
3958 .font = font,
3959 .context = filigree.Context.init(allocator, .{}),
3960 .output = try filigree.Output.init(allocator, test_output_limits),
3961 } },
3962 };
3963 }
3964
3965 test "packAtlasImageAlloc converts filigree atlas rgba to packed image" {
3966 const allocator = std.testing.allocator;
3967 var rgba = [_]u8{
3968 255, 255, 255, 128,
3969 10, 20, 30, 40,
3970 };
3971 const atlas = filigree.GlyphAtlas{
3972 .rgba = rgba[0..],
3973 .width = 2,
3974 .height = 1,
3975 };
3976
3977 var owned = try packAtlasImageAlloc(allocator, atlas);
3978 defer owned.deinit(allocator);
3979
3980 try std.testing.expectEqual(@as(u32, 2), owned.image.width);
3981 try std.testing.expectEqual(@as(u32, 1), owned.image.height);
3982 try std.testing.expectEqual(@as(u32, 0x80ff_ffff), owned.pixels[0]);
3983 try std.testing.expectEqual(@as(u32, 0x281e_140a), owned.pixels[1]);
3984 }
3985
3986 test "drawGlyphRun emits atlas-backed glyph commands" {
3987 var atlas_glyphs = [_]filigree.GlyphAtlasGlyph{.{
3988 .glyph_id = 7,
3989 .width = 3,
3990 .height = 4,
3991 .offset_x = 1,
3992 .offset_y = 2,
3993 }};
3994 var atlas_recs = [_]filigree.GlyphAtlasRectangle{.{
3995 .x = 5,
3996 .y = 6,
3997 .width = 3,
3998 .height = 4,
3999 }};
4000 const atlas = filigree.GlyphAtlas{
4001 .width = 8,
4002 .height = 8,
4003 .glyphs = atlas_glyphs[0..],
4004 .recs = atlas_recs[0..],
4005 };
4006 const shaped = [_]filigree.ShapedGlyph{.{
4007 .glyph_id = 7,
4008 .cluster = 0,
4009 .x_advance = 8 * 64,
4010 .y_advance = 0,
4011 .x_offset = 64,
4012 .y_offset = 0,
4013 }};
4014 const run = filigree.GlyphRun{
4015 .glyphs = shaped[0..],
4016 .clusters = &.{},
4017 .ligature_carets = &.{},
4018 .total_x_advance = 8 * 64,
4019 .total_y_advance = 0,
4020 .direction = .ltr,
4021 .writing_mode = .horizontal,
4022 .output_order = .visual,
4023 };
4024 var recorder = TestRecorder{};
4025
4026 drawGlyphRun(&recorder, 4, atlas, run, 10, 20, .{ .r = 1, .g = 2, .b = 3, .a = 200 });
4027
4028 try std.testing.expectEqual(@as(usize, 1), recorder.glyph_count);
4029 try std.testing.expectEqual(@as(u32, 4), recorder.last_image_index);
4030 try std.testing.expectEqual(@as(f32, 12), recorder.last_rect.x);
4031 try std.testing.expectEqual(@as(f32, 22), recorder.last_rect.y);
4032 try std.testing.expectEqual(@as(f32, 3), recorder.last_rect.width);
4033 try std.testing.expectEqual(@as(f32, 5), recorder.last_source.x);
4034 try std.testing.expectEqual(@as(u8, 200), recorder.last_color.a);
4035 }
4036
4037 test "drawGlyphRun consumes filigree shaped atlas output" {
4038 const allocator = std.testing.allocator;
4039 const bytes = try filigree.fixtures.createWithOutlines(allocator);
4040 defer allocator.free(bytes);
4041 var font = filigree.Font.initFromBytes(bytes.ptr, bytes.len) orelse return error.InvalidFont;
4042 defer font.deinit();
4043 font.setPixelHeightScale(20);
4044
4045 var context = filigree.Context.init(allocator, .{});
4046 defer context.deinit();
4047 var output = try filigree.Output.init(allocator, test_output_limits);
4048 defer output.deinit(allocator);
4049 try context.shapeRun(.{
4050 .font = &font,
4051 .text = .{ .utf8 = "A" },
4052 }, &output);
4053 const run = output.run();
4054 try std.testing.expect(run.glyphs.len > 0);
4055
4056 var glyph_ids = try allocator.alloc(i32, run.glyphs.len);
4057 defer allocator.free(glyph_ids);
4058 var codepoints = try allocator.alloc(i32, run.glyphs.len);
4059 defer allocator.free(codepoints);
4060 for (run.glyphs, 0..) |glyph, index| {
4061 glyph_ids[index] = std.math.cast(i32, glyph.glyph_id) orelse return error.GlyphIdTooLarge;
4062 codepoints[index] = 'A';
4063 }
4064
4065 const atlas = try filigree.glyphAtlasAlloc(allocator, allocator, bytes, 20, glyph_ids, codepoints, 2);
4066 defer atlas.deinit(allocator);
4067 var owned = try packAtlasImageAlloc(allocator, atlas);
4068 defer owned.deinit(allocator);
4069 var recorder = TestRecorder{};
4070
4071 drawGlyphRun(&recorder, 0, atlas, run, 0, 0, .{ .r = 20, .g = 30, .b = 40, .a = 255 });
4072
4073 try std.testing.expectEqual(run.glyphs.len, recorder.glyph_count);
4074 try std.testing.expect(owned.image.width > 0);
4075 try std.testing.expect(owned.image.height > 0);
4076 try std.testing.expect(owned.pixels.len > 0);
4077 }
4078
4079 test "bitmap atlas rasterizes cells and shapes ascii with fallback" {
4080 const allocator = std.testing.allocator;
4081 const glyphs = [_]BitmapGlyph{
4082 .{ .codepoint = 'A', .rows = &.{ 0x81, 0xFF } },
4083 .{ .codepoint = '?', .rows = &.{ 0xFF, 0x00 } },
4084 };
4085 var atlas = try Atlas.initFromBitmapGlyphs(
4086 allocator,
4087 glyphs[0..],
4088 .{ .width = 8, .height = 2, .stride = 1 },
4089 16,
4090 test_atlas_cache_limits,
4091 );
4092 defer atlas.deinit();
4093
4094 try std.testing.expectEqual(@as(i32, 18), atlas.atlas.width);
4095 try std.testing.expectEqual(@as(i32, 2), atlas.atlas.height);
4096 try std.testing.expectEqual(@as(u8, 255), atlas.atlas.rgba[3]);
4097 try std.testing.expectEqual(@as(u8, 0), atlas.atlas.rgba[7]);
4098 try std.testing.expect(atlas.image.image.width == 18);
4099
4100 const run = try atlas.shape("AZ\n");
4101 try std.testing.expectEqual(@as(usize, 2), run.glyphs.len);
4102 try std.testing.expectEqual(@as(u32, 'A'), run.glyphs[0].glyph_id);
4103 try std.testing.expectEqual(@as(u32, '?'), run.glyphs[1].glyph_id);
4104 try std.testing.expectEqual(@as(i32, 2 * 8 * 64), run.total_x_advance);
4105
4106 var recorder = TestRecorder{};
4107 drawGlyphRun(&recorder, 0, atlas.atlas, run, 100, 200, .{ .r = 9, .g = 8, .b = 7, .a = 255 });
4108 try std.testing.expectEqual(@as(usize, 2), recorder.glyph_count);
4109 try std.testing.expectEqual(@as(f32, 108), recorder.last_rect.x);
4110 try std.testing.expectEqual(@as(f32, 200), recorder.last_rect.y);
4111 try std.testing.expectEqual(@as(f32, 9), recorder.last_source.x);
4112 }
4113
4114 test "bitmap atlas measures scaled multiline text" {
4115 const allocator = std.testing.allocator;
4116 const glyphs = [_]BitmapGlyph{
4117 .{ .codepoint = 'A', .rows = &.{ 0x81, 0xFF } },
4118 .{ .codepoint = '?', .rows = &.{ 0xFF, 0x00 } },
4119 };
4120 var atlas = try Atlas.initFromBitmapGlyphs(
4121 allocator,
4122 glyphs[0..],
4123 .{ .width = 8, .height = 2, .stride = 1 },
4124 16,
4125 test_atlas_cache_limits,
4126 );
4127 defer atlas.deinit();
4128
4129 const measured = try measure(&atlas, .{
4130 .content = "AZ\nA",
4131 .point_size = 20,
4132 .line_height = 1.5,
4133 });
4134
4135 try std.testing.expectApproxEqAbs(@as(f32, 20), measured.width, 0.001);
4136 try std.testing.expectApproxEqAbs(@as(f32, 60), measured.height, 0.001);
4137 }
4138
4139 test "bitmap atlas measures and paints Unicode wrapped text with one line plan" {
4140 const allocator = std.testing.allocator;
4141 const glyphs = [_]BitmapGlyph{
4142 .{ .codepoint = 'A', .rows = &.{ 0xFF, 0xFF } },
4143 .{ .codepoint = 'B', .rows = &.{ 0xFF, 0xFF } },
4144 .{ .codepoint = ' ', .rows = &.{ 0x00, 0x00 } },
4145 .{ .codepoint = 'C', .rows = &.{ 0xFF, 0xFF } },
4146 .{ .codepoint = 'D', .rows = &.{ 0xFF, 0xFF } },
4147 };
4148 var atlas = try Atlas.initFromBitmapGlyphs(
4149 allocator,
4150 glyphs[0..],
4151 .{ .width = 8, .height = 2, .stride = 1 },
4152 16,
4153 test_atlas_cache_limits,
4154 );
4155 defer atlas.deinit();
4156 const wrapped = UiText{
4157 .content = "AB CD",
4158 .point_size = 16,
4159 .line_height = 1,
4160 .wrap_width = 24,
4161 };
4162
4163 const measured = try measure(&atlas, wrapped);
4164 try std.testing.expectApproxEqAbs(@as(f32, 24), measured.width, 0.001);
4165 try std.testing.expectApproxEqAbs(@as(f32, 32), measured.height, 0.001);
4166
4167 const child = [_]UiNode{.{
4168 .widget_id = 2,
4169 .kind = .label,
4170 .text = wrapped,
4171 .paint = .{ .foreground = .{ .r = 255, .g = 255, .b = 255, .a = 255 } },
4172 .size = .{ .width = 24, .height = 32 },
4173 }};
4174 const surface = gui.model.UiSurfaceTree{
4175 .available_size = .{ .width = 24, .height = 32 },
4176 .root = .{
4177 .widget_id = 1,
4178 .children = child[0..],
4179 },
4180 };
4181 var frame_workspace = gui.frame.Workspace.init(allocator);
4182 defer frame_workspace.deinit();
4183 const frame = try frame_workspace.buildSurface(&surface, .{});
4184 var commands = command.CommandBuffer.init(allocator);
4185 defer commands.deinit();
4186 const entries = [_]AtlasSet.Entry{.{ .face = 0, .image_index = 0, .atlas = &atlas }};
4187 const atlases = AtlasSet{ .entries = entries[0..] };
4188
4189 try appendFrameCommands(&commands, frame, &atlases, 1, 0);
4190
4191 try std.testing.expectEqual(@as(usize, 5), commands.items().len);
4192 try std.testing.expectApproxEqAbs(@as(f32, 16), commands.items()[3].rect.y - commands.items()[0].rect.y, 0.001);
4193 }
4194
4195 test "multiline text geometry distinguishes soft affinity and hard empty lines" {
4196 const allocator = std.testing.allocator;
4197 const glyphs = [_]BitmapGlyph{
4198 .{ .codepoint = 'A', .rows = &.{ 0xFF, 0xFF } },
4199 .{ .codepoint = 'B', .rows = &.{ 0xFF, 0xFF } },
4200 .{ .codepoint = 'C', .rows = &.{ 0xFF, 0xFF } },
4201 .{ .codepoint = 'D', .rows = &.{ 0xFF, 0xFF } },
4202 .{ .codepoint = 'E', .rows = &.{ 0xFF, 0xFF } },
4203 .{ .codepoint = 'F', .rows = &.{ 0xFF, 0xFF } },
4204 .{ .codepoint = ' ', .rows = &.{ 0x00, 0x00 } },
4205 };
4206 var atlas = try Atlas.initFromBitmapGlyphs(
4207 allocator,
4208 &glyphs,
4209 .{ .width = 8, .height = 2, .stride = 1 },
4210 16,
4211 test_atlas_cache_limits,
4212 );
4213 defer atlas.deinit();
4214 const entries = [_]AtlasSet.Entry{.{
4215 .face = 0,
4216 .image_index = 0,
4217 .atlas = &atlas,
4218 }};
4219 const atlases = AtlasSet{ .entries = &entries };
4220 const text = UiText{
4221 .content = "AB CD\n\nEF",
4222 .point_size = 16,
4223 .line_height = 1,
4224 .wrap_width = 24,
4225 .horizontal_align = .center,
4226 .vertical_align = .center,
4227 };
4228 const box = Size{ .width = 64, .height = 80 };
4229
4230 const upstream = try textCaretGeometry(
4231 &atlases,
4232 text,
4233 box,
4234 3,
4235 .upstream,
4236 );
4237 try std.testing.expectEqual(@as(usize, 0), upstream.line_index);
4238 try std.testing.expectApproxEqAbs(@as(f32, 44), upstream.x, 0.001);
4239 try std.testing.expectApproxEqAbs(@as(f32, 8), upstream.y, 0.001);
4240
4241 const downstream = try textCaretGeometry(
4242 &atlases,
4243 text,
4244 box,
4245 3,
4246 .downstream,
4247 );
4248 try std.testing.expectEqual(@as(usize, 1), downstream.line_index);
4249 try std.testing.expectApproxEqAbs(@as(f32, 24), downstream.x, 0.001);
4250 try std.testing.expectApproxEqAbs(@as(f32, 24), downstream.y, 0.001);
4251
4252 const hard_end = try textCaretGeometry(
4253 &atlases,
4254 text,
4255 box,
4256 5,
4257 .downstream,
4258 );
4259 try std.testing.expectEqual(@as(usize, 1), hard_end.line_index);
4260 try std.testing.expectApproxEqAbs(@as(f32, 40), hard_end.x, 0.001);
4261
4262 const empty = try textCaretGeometry(
4263 &atlases,
4264 text,
4265 box,
4266 6,
4267 .downstream,
4268 );
4269 try std.testing.expectEqual(@as(usize, 2), empty.line_index);
4270 try std.testing.expectApproxEqAbs(@as(f32, 32), empty.x, 0.001);
4271 try std.testing.expectApproxEqAbs(@as(f32, 40), empty.y, 0.001);
4272
4273 const upstream_hit = try textHitTestPoint(
4274 &atlases,
4275 text,
4276 box,
4277 .{ .x = 60, .y = 12 },
4278 );
4279 try std.testing.expectEqual(@as(usize, 3), upstream_hit.byte_offset);
4280 try std.testing.expectEqual(TextCaretAffinity.upstream, upstream_hit.affinity);
4281
4282 const downstream_hit = try textHitTestPoint(
4283 &atlases,
4284 text,
4285 box,
4286 .{ .x = 0, .y = 24 },
4287 );
4288 try std.testing.expectEqual(@as(usize, 3), downstream_hit.byte_offset);
4289 try std.testing.expectEqual(TextCaretAffinity.downstream, downstream_hit.affinity);
4290
4291 const bottom = try textHitTestPoint(
4292 &atlases,
4293 text,
4294 box,
4295 .{ .x = 100, .y = 200 },
4296 );
4297 try std.testing.expectEqual(text.content.len, bottom.byte_offset);
4298 try std.testing.expectEqual(@as(usize, 3), bottom.line_index);
4299 }
4300
4301 test "widget text hit query honors translated clip and clamps to cluster boundaries" {
4302 const allocator = std.testing.allocator;
4303 const glyphs = [_]BitmapGlyph{
4304 .{ .codepoint = 'A', .rows = &.{ 0xFF, 0xFF } },
4305 .{ .codepoint = 'B', .rows = &.{ 0xFF, 0xFF } },
4306 .{ .codepoint = 0x00E9, .rows = &.{ 0xFF, 0xFF } },
4307 };
4308 var atlas = try Atlas.initFromBitmapGlyphs(
4309 allocator,
4310 &glyphs,
4311 .{ .width = 8, .height = 2, .stride = 1 },
4312 16,
4313 test_atlas_cache_limits,
4314 );
4315 defer atlas.deinit();
4316 const entries = [_]AtlasSet.Entry{.{
4317 .face = 0,
4318 .image_index = 0,
4319 .atlas = &atlas,
4320 }};
4321 const atlases = AtlasSet{ .entries = &entries };
4322 const widget = WidgetFrame{
4323 .root_id = 1,
4324 .widget_id = 2,
4325 .kind = .label,
4326 .rect = .{ .x = 30, .y = -8, .width = 24, .height = 32 },
4327 .visible_rect = .{ .x = 34, .y = 0, .width = 16, .height = 16 },
4328 .paint = .{},
4329 .scroll = .{},
4330 .constraints = .{},
4331 .content_size = .{ .width = 24, .height = 32 },
4332 .focusable = false,
4333 .has_text = true,
4334 .text = .{
4335 .content = "AéB\nAB",
4336 .point_size = 16,
4337 .line_height = 1,
4338 .wrap_width = 24,
4339 },
4340 };
4341
4342 try std.testing.expect((try textHitTestWidgetPoint(
4343 &atlases,
4344 widget,
4345 .{ .x = 33, .y = 4 },
4346 )) == null);
4347 const visible = (try textHitTestWidgetPoint(
4348 &atlases,
4349 widget,
4350 .{ .x = 42, .y = 4 },
4351 )).?;
4352 const direct = try textHitTestPoint(
4353 &atlases,
4354 widget.text.?,
4355 .{ .width = widget.rect.width, .height = widget.rect.height },
4356 .{ .x = 12, .y = 12 },
4357 );
4358 try std.testing.expectEqual(direct.byte_offset, visible.byte_offset);
4359 try std.testing.expectEqual(direct.affinity, visible.affinity);
4360
4361 const leading = (try textHitTestWidgetPointClamped(
4362 &atlases,
4363 widget,
4364 .{ .x = -100, .y = -100 },
4365 )).?;
4366 const trailing = (try textHitTestWidgetPointClamped(
4367 &atlases,
4368 widget,
4369 .{ .x = 100, .y = 100 },
4370 )).?;
4371 try std.testing.expect(leading.byte_offset <= trailing.byte_offset);
4372 try std.testing.expect(
4373 leading.byte_offset == 0 or
4374 leading.byte_offset == widget.text.?.content.len or
4375 widget.text.?.content[leading.byte_offset] & 0b1100_0000 != 0b1000_0000,
4376 );
4377 try std.testing.expect(
4378 trailing.byte_offset == 0 or
4379 trailing.byte_offset == widget.text.?.content.len or
4380 widget.text.?.content[trailing.byte_offset] & 0b1100_0000 != 0b1000_0000,
4381 );
4382 try std.testing.expect((try textHitTestWidgetPointClamped(
4383 &atlases,
4384 .{
4385 .root_id = widget.root_id,
4386 .widget_id = widget.widget_id,
4387 .kind = widget.kind,
4388 .rect = widget.rect,
4389 .visible_rect = .{},
4390 .paint = widget.paint,
4391 .scroll = widget.scroll,
4392 .constraints = widget.constraints,
4393 .content_size = widget.content_size,
4394 .focusable = false,
4395 .text = widget.text,
4396 },
4397 .{},
4398 )) == null);
4399 }
4400
4401 test "multiline text geometry follows mixed metric line boxes and end alignment" {
4402 const allocator = std.testing.allocator;
4403 const base_glyphs = [_]BitmapGlyph{
4404 .{ .codepoint = 'A', .rows = &.{ 0xFF, 0xFF } },
4405 .{ .codepoint = 'B', .rows = &.{ 0xFF, 0xFF } },
4406 .{ .codepoint = 'C', .rows = &.{ 0xFF, 0xFF } },
4407 };
4408 const large_glyphs = [_]BitmapGlyph{
4409 .{ .codepoint = 'A', .rows = &.{ 0xFF, 0x0F, 0xFF, 0x0F, 0xFF, 0x0F, 0xFF, 0x0F } },
4410 .{ .codepoint = 'B', .rows = &.{ 0xFF, 0x0F, 0xFF, 0x0F, 0xFF, 0x0F, 0xFF, 0x0F } },
4411 .{ .codepoint = 'C', .rows = &.{ 0xFF, 0x0F, 0xFF, 0x0F, 0xFF, 0x0F, 0xFF, 0x0F } },
4412 };
4413 var base_atlas = try Atlas.initFromBitmapGlyphs(
4414 allocator,
4415 &base_glyphs,
4416 .{ .width = 8, .height = 2, .stride = 1 },
4417 16,
4418 test_atlas_cache_limits,
4419 );
4420 defer base_atlas.deinit();
4421 var large_atlas = try Atlas.initFromBitmapGlyphs(
4422 allocator,
4423 &large_glyphs,
4424 .{ .width = 12, .height = 4, .stride = 2 },
4425 24,
4426 test_atlas_cache_limits,
4427 );
4428 defer large_atlas.deinit();
4429 const styles = [_]UiTextStyle{.{
4430 .font_asset_id = 2,
4431 .point_size = 24,
4432 }};
4433 const runs = [_]UiTextRun{.{
4434 .byte_start = 2,
4435 .byte_end = 3,
4436 .style_slot = 1,
4437 }};
4438 const text = UiText{
4439 .content = "A\nBC",
4440 .runs = &runs,
4441 .styles = &styles,
4442 .font_asset_id = 1,
4443 .point_size = 16,
4444 .line_height = 1,
4445 .horizontal_align = .end,
4446 .vertical_align = .end,
4447 };
4448 const entries = [_]AtlasSet.Entry{
4449 .{ .face = 1, .image_index = 0, .atlas = &base_atlas },
4450 .{ .face = 2, .image_index = 1, .atlas = &large_atlas },
4451 };
4452 const atlases = AtlasSet{ .entries = &entries };
4453 const box = Size{ .width = 48, .height = 60 };
4454
4455 const first = try textCaretGeometry(
4456 &atlases,
4457 text,
4458 box,
4459 1,
4460 .downstream,
4461 );
4462 try std.testing.expectEqual(@as(usize, 0), first.line_index);
4463 try std.testing.expectApproxEqAbs(@as(f32, 48), first.x, 0.001);
4464 try std.testing.expectApproxEqAbs(@as(f32, 20), first.y, 0.001);
4465 try std.testing.expectApproxEqAbs(@as(f32, 16), first.height, 0.001);
4466
4467 const mixed = try textCaretGeometry(
4468 &atlases,
4469 text,
4470 box,
4471 3,
4472 .downstream,
4473 );
4474 try std.testing.expectEqual(@as(usize, 1), mixed.line_index);
4475 try std.testing.expectApproxEqAbs(@as(f32, 40), mixed.x, 0.001);
4476 try std.testing.expectApproxEqAbs(@as(f32, 36), mixed.y, 0.001);
4477 try std.testing.expectApproxEqAbs(@as(f32, 24), mixed.height, 0.001);
4478 }
4479
4480 test "warmed multiline text geometry needs no backing allocation" {
4481 var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{});
4482 const allocator = failing.allocator();
4483 const glyphs = [_]BitmapGlyph{
4484 .{ .codepoint = 'A', .rows = &.{ 0xFF, 0xFF } },
4485 .{ .codepoint = 'B', .rows = &.{ 0xFF, 0xFF } },
4486 .{ .codepoint = ' ', .rows = &.{ 0x00, 0x00 } },
4487 };
4488 var atlas = try Atlas.initFromBitmapGlyphs(
4489 allocator,
4490 &glyphs,
4491 .{ .width = 8, .height = 2, .stride = 1 },
4492 16,
4493 test_atlas_cache_limits,
4494 );
4495 defer atlas.deinit();
4496 const entries = [_]AtlasSet.Entry{.{
4497 .face = 0,
4498 .image_index = 0,
4499 .atlas = &atlas,
4500 }};
4501 const atlases = AtlasSet{ .entries = &entries };
4502 const text = UiText{
4503 .content = "AB AB\n\nAB AB",
4504 .point_size = 16,
4505 .line_height = 1,
4506 .wrap_width = 24,
4507 .vertical_align = .center,
4508 };
4509 const box = Size{ .width = 48, .height = 96 };
4510 for (0..text.content.len + 1) |byte_offset| {
4511 _ = try textCaretGeometry(
4512 &atlases,
4513 text,
4514 box,
4515 byte_offset,
4516 .downstream,
4517 );
4518 }
4519 _ = try textHitTestPoint(&atlases, text, box, .{ .x = 12, .y = 32 });
4520
4521 failing.fail_index = failing.alloc_index;
4522 failing.resize_fail_index = failing.resize_index;
4523 for (0..8) |_| {
4524 for (0..text.content.len + 1) |byte_offset| {
4525 _ = try textCaretGeometry(
4526 &atlases,
4527 text,
4528 box,
4529 byte_offset,
4530 .downstream,
4531 );
4532 }
4533 _ = try textHitTestPoint(&atlases, text, box, .{ .x = 12, .y = 32 });
4534 }
4535 try std.testing.expect(!failing.has_induced_failure);
4536 }
4537
4538 test "multiline query geometry matches painted caret at device scales" {
4539 const allocator = std.testing.allocator;
4540 const glyphs = [_]BitmapGlyph{
4541 .{ .codepoint = 'A', .rows = &.{ 0xFF, 0xFF } },
4542 .{ .codepoint = 'B', .rows = &.{ 0xFF, 0xFF } },
4543 .{ .codepoint = 'C', .rows = &.{ 0xFF, 0xFF } },
4544 .{ .codepoint = 'D', .rows = &.{ 0xFF, 0xFF } },
4545 .{ .codepoint = 'E', .rows = &.{ 0xFF, 0xFF } },
4546 .{ .codepoint = 'F', .rows = &.{ 0xFF, 0xFF } },
4547 .{ .codepoint = ' ', .rows = &.{ 0x00, 0x00 } },
4548 };
4549 var atlas = try Atlas.initFromBitmapGlyphs(
4550 allocator,
4551 &glyphs,
4552 .{ .width = 8, .height = 2, .stride = 1 },
4553 16,
4554 test_atlas_cache_limits,
4555 );
4556 defer atlas.deinit();
4557 const text = UiText{
4558 .content = "AB CD\nEF",
4559 .point_size = 16,
4560 .line_height = 1,
4561 .wrap_width = 24,
4562 .horizontal_align = .center,
4563 .vertical_align = .center,
4564 };
4565 const child = [_]UiNode{.{
4566 .widget_id = 2,
4567 .kind = .text_input,
4568 .text = text,
4569 .text_selection = .{
4570 .cursor_visible = true,
4571 .cursor_byte_offset = 3,
4572 },
4573 .paint = .{
4574 .foreground = .{ .r = 240, .g = 240, .b = 240, .a = 255 },
4575 },
4576 .size = .{ .width = 64, .height = 80 },
4577 }};
4578 const surface = gui.model.UiSurfaceTree{
4579 .available_size = .{ .width = 80, .height = 96 },
4580 .root = .{ .widget_id = 1, .children = &child },
4581 };
4582 var frame_workspace = gui.frame.Workspace.init(allocator);
4583 defer frame_workspace.deinit();
4584 const frame = try frame_workspace.buildSurface(&surface, .{});
4585 const widget = frame.widgets[1];
4586 const entries = [_]AtlasSet.Entry{.{
4587 .face = 0,
4588 .image_index = 0,
4589 .atlas = &atlas,
4590 }};
4591 const atlases = AtlasSet{ .entries = &entries };
4592 const geometry = try textCaretGeometry(
4593 &atlases,
4594 text,
4595 .{ .width = widget.rect.width, .height = widget.rect.height },
4596 3,
4597 .downstream,
4598 );
4599 var commands = command.CommandBuffer.init(allocator);
4600 defer commands.deinit();
4601 for ([_]f32{ 1, 2 }) |device_scale| {
4602 commands.reset();
4603 try appendFrameCommands(
4604 &commands,
4605 frame,
4606 &atlases,
4607 device_scale,
4608 0,
4609 );
4610 const caret = commands.items()[commands.items().len - 1];
4611 try std.testing.expectEqual(command.Kind.fill, caret.kind);
4612 try std.testing.expectApproxEqAbs(
4613 (widget.rect.x + geometry.x) * device_scale,
4614 caret.rect.x,
4615 0.001,
4616 );
4617 try std.testing.expect(
4618 caret.rect.y >= (widget.rect.y + geometry.y) * device_scale,
4619 );
4620 try std.testing.expect(
4621 caret.rect.y + caret.rect.height <=
4622 (widget.rect.y + geometry.y + geometry.height) * device_scale,
4623 );
4624 }
4625 }
4626
4627 test "shape cache preserves cluster and ligature caret geometry" {
4628 const allocator = std.testing.allocator;
4629 const bytes = try filigree.fixtures.createWithGsubLigatureAndGdefCarets(allocator);
4630 var atlas = try initShapeOnlyAtlas(allocator, bytes, 20);
4631 defer atlas.deinit();
4632 const text = "fi";
4633
4634 const fresh = try atlas.shapeUncached(text);
4635 const expected_glyphs = try allocator.dupe(filigree.ShapedGlyph, fresh.glyphs);
4636 defer allocator.free(expected_glyphs);
4637 const expected_clusters = try allocator.dupe(filigree.Cluster, fresh.clusters);
4638 defer allocator.free(expected_clusters);
4639 const expected_carets = try allocator.dupe(filigree.LigatureCaret, fresh.ligature_carets);
4640 defer allocator.free(expected_carets);
4641 const expected = filigree.GlyphRun{
4642 .glyphs = expected_glyphs,
4643 .clusters = expected_clusters,
4644 .ligature_carets = expected_carets,
4645 .total_x_advance = fresh.total_x_advance,
4646 .total_y_advance = fresh.total_y_advance,
4647 .direction = fresh.direction,
4648 .writing_mode = fresh.writing_mode,
4649 .output_order = fresh.output_order,
4650 };
4651
4652 const cached = try atlas.shape(text);
4653 try expectGlyphRunEqual(expected, cached);
4654 try std.testing.expect(cached.clusters.len > 0);
4655 try std.testing.expect(cached.ligature_carets.len > 0);
4656 try std.testing.expectEqual(
4657 shapePayloadBytes(text, cached).?,
4658 atlas.cacheStatus().shape.payload_bytes,
4659 );
4660 try std.testing.expectEqual(
4661 filigree.caret.advanceForByteOffset(expected, 1, text.len),
4662 try atlas.advanceForByteOffset(text, 1),
4663 );
4664 try std.testing.expectEqual(
4665 filigree.caret.hitTestAdvance(expected, 4.6, text),
4666 try atlas.hitTestAdvance(text, 4.6),
4667 );
4668
4669 _ = try atlas.shape("A");
4670 const retained = try atlas.shape(text);
4671 try expectGlyphRunEqual(expected, retained);
4672 try std.testing.expectEqual(cached.glyphs.ptr, retained.glyphs.ptr);
4673 try std.testing.expectEqual(cached.clusters.ptr, retained.clusters.ptr);
4674 try std.testing.expectEqual(cached.ligature_carets.ptr, retained.ligature_carets.ptr);
4675 }
4676
4677 test "style boundaries inside ligatures keep one glyph and use its cluster start" {
4678 const allocator = std.testing.allocator;
4679 const bytes = try filigree.fixtures.createWithGsubLigatureAndGdefCarets(allocator);
4680 var atlas = try initShapeOnlyAtlas(allocator, bytes, 20);
4681 defer atlas.deinit();
4682 const content = "fi";
4683 const run = try atlas.shape(content);
4684 try std.testing.expectEqual(@as(usize, 1), run.glyphs.len);
4685 try std.testing.expectEqual(@as(u32, 0), run.glyphs[0].source_start);
4686 const glyph_start = sourceOffset(run.glyphs[0].source_start, content.len);
4687 const trailing_style = [_]UiTextRun{.{
4688 .byte_start = 1,
4689 .byte_end = 2,
4690 .foreground = .{ .r = 220, .g = 30, .b = 20 },
4691 }};
4692 var trailing_cursor = TextRunCursor{ .runs = &trailing_style };
4693 try std.testing.expect(trailing_cursor.find(glyph_start) == null);
4694 const leading_style = [_]UiTextRun{.{
4695 .byte_start = 0,
4696 .byte_end = 1,
4697 .foreground = .{ .r = 20, .g = 80, .b = 220 },
4698 }};
4699 var leading_cursor = TextRunCursor{ .runs = &leading_style };
4700 try std.testing.expectEqual(
4701 leading_style[0],
4702 leading_cursor.find(glyph_start).?,
4703 );
4704 try std.testing.expectEqual(
4705 try measure(&atlas, .{ .content = content }),
4706 try measure(&atlas, .{ .content = content, .runs = &trailing_style }),
4707 );
4708 }
4709
4710 test "cached combining and ZWJ caret queries match fresh shaping" {
4711 const allocator = std.testing.allocator;
4712 const bytes = try filigree.fixtures.createWithOutlines(allocator);
4713 var atlas = try initShapeOnlyAtlas(allocator, bytes, 20);
4714 defer atlas.deinit();
4715 const cases = [_][]const u8{
4716 "e\u{0301}",
4717 "\u{1f469}\u{200d}\u{1f4bb}",
4718 };
4719
4720 for (cases) |text| {
4721 const fresh = try atlas.shapeUncached(text);
4722 const expected_glyphs = try allocator.dupe(filigree.ShapedGlyph, fresh.glyphs);
4723 defer allocator.free(expected_glyphs);
4724 const expected_clusters = try allocator.dupe(filigree.Cluster, fresh.clusters);
4725 defer allocator.free(expected_clusters);
4726 const expected_carets = try allocator.dupe(filigree.LigatureCaret, fresh.ligature_carets);
4727 defer allocator.free(expected_carets);
4728 const expected = filigree.GlyphRun{
4729 .glyphs = expected_glyphs,
4730 .clusters = expected_clusters,
4731 .ligature_carets = expected_carets,
4732 .total_x_advance = fresh.total_x_advance,
4733 .total_y_advance = fresh.total_y_advance,
4734 .direction = fresh.direction,
4735 .writing_mode = fresh.writing_mode,
4736 .output_order = fresh.output_order,
4737 };
4738 try std.testing.expect(expected.clusters.len > 0);
4739
4740 for (0..text.len + 1) |byte_offset| {
4741 try std.testing.expectEqual(
4742 filigree.caret.advanceForByteOffset(expected, byte_offset, text.len),
4743 try atlas.advanceForByteOffset(text, byte_offset),
4744 );
4745 }
4746 for (0..33) |raw_advance| {
4747 const advance: f32 = @floatFromInt(raw_advance);
4748 try std.testing.expectEqual(
4749 filigree.caret.hitTestAdvance(expected, advance, text),
4750 try atlas.hitTestAdvance(text, advance),
4751 );
4752 }
4753 }
4754 }
4755
4756 test "wrapped markers preserve glyph geometry and partition selection by visual line" {
4757 const allocator = std.testing.allocator;
4758 const glyphs = [_]BitmapGlyph{
4759 .{ .codepoint = 'A', .rows = &.{ 0xFF, 0xFF } },
4760 .{ .codepoint = 'B', .rows = &.{ 0xFF, 0xFF } },
4761 .{ .codepoint = 'C', .rows = &.{ 0xFF, 0xFF } },
4762 .{ .codepoint = 'D', .rows = &.{ 0xFF, 0xFF } },
4763 .{ .codepoint = 'E', .rows = &.{ 0xFF, 0xFF } },
4764 .{ .codepoint = 'F', .rows = &.{ 0xFF, 0xFF } },
4765 .{ .codepoint = ' ', .rows = &.{ 0x00, 0x00 } },
4766 };
4767 var atlas = try Atlas.initFromBitmapGlyphs(
4768 allocator,
4769 &glyphs,
4770 .{ .width = 8, .height = 2, .stride = 1 },
4771 16,
4772 test_atlas_cache_limits,
4773 );
4774 defer atlas.deinit();
4775 const text = UiText{
4776 .content = "AB CD EF",
4777 .point_size = 16,
4778 .line_height = 1,
4779 .wrap_width = 24,
4780 };
4781 const nodes = [_]UiNode{
4782 .{
4783 .widget_id = 2,
4784 .kind = .text_input,
4785 .text = text,
4786 .paint = .{ .foreground = .{ .r = 12, .g = 24, .b = 36, .a = 255 } },
4787 .size = .{ .width = 24, .height = 48 },
4788 },
4789 .{
4790 .widget_id = 2,
4791 .kind = .text_input,
4792 .text = text,
4793 .text_selection = .{
4794 .cursor_visible = true,
4795 .cursor_byte_offset = 7,
4796 .selection_active = true,
4797 .selection_anchor_byte_offset = 1,
4798 .selection_focus_byte_offset = 7,
4799 },
4800 .paint = .{ .foreground = .{ .r = 12, .g = 24, .b = 36, .a = 255 } },
4801 .size = .{ .width = 24, .height = 48 },
4802 },
4803 .{
4804 .widget_id = 2,
4805 .kind = .text_input,
4806 .text = text,
4807 .text_selection = .{
4808 .cursor_visible = true,
4809 .cursor_byte_offset = 7,
4810 },
4811 .paint = .{ .foreground = .{ .r = 12, .g = 24, .b = 36, .a = 255 } },
4812 .size = .{ .width = 24, .height = 48 },
4813 },
4814 };
4815 const entries = [_]AtlasSet.Entry{.{ .face = 0, .image_index = 0, .atlas = &atlas }};
4816 const atlases = AtlasSet{ .entries = &entries };
4817 var frames: [3]gui.frame.Workspace = .{
4818 gui.frame.Workspace.init(allocator),
4819 gui.frame.Workspace.init(allocator),
4820 gui.frame.Workspace.init(allocator),
4821 };
4822 defer for (&frames) |*frame| frame.deinit();
4823 var buffers: [3]command.CommandBuffer = .{
4824 command.CommandBuffer.init(allocator),
4825 command.CommandBuffer.init(allocator),
4826 command.CommandBuffer.init(allocator),
4827 };
4828 defer for (&buffers) |*buffer| buffer.deinit();
4829
4830 for (0..nodes.len) |index| {
4831 const surface = gui.model.UiSurfaceTree{
4832 .available_size = .{ .width = 24, .height = 48 },
4833 .root = .{ .widget_id = 1, .children = nodes[index .. index + 1] },
4834 };
4835 const frame = try frames[index].buildSurface(&surface, .{});
4836 try appendFrameCommands(&buffers[index], frame, &atlases, 1, 0);
4837 }
4838
4839 var unfocused_glyphs: [16]command.Command = undefined;
4840 var unfocused_count: usize = 0;
4841 for (buffers[0].items()) |item| {
4842 if (item.kind != .glyph) continue;
4843 unfocused_glyphs[unfocused_count] = item;
4844 unfocused_count += 1;
4845 }
4846 var focused_count: usize = 0;
4847 var selection_y: [3]f32 = undefined;
4848 var selection_count: usize = 0;
4849 for (buffers[1].items()) |item| {
4850 if (item.kind == .glyph) {
4851 const expected = unfocused_glyphs[focused_count];
4852 try std.testing.expectEqual(expected.kind, item.kind);
4853 try std.testing.expectEqual(expected.rect, item.rect);
4854 try std.testing.expectEqual(expected.clip, item.clip);
4855 try std.testing.expectEqual(expected.source, item.source);
4856 try std.testing.expectEqual(expected.color, item.color);
4857 try std.testing.expectEqual(expected.image_index, item.image_index);
4858 focused_count += 1;
4859 }
4860 if (item.kind == .fill and item.color.a == 64) {
4861 selection_y[selection_count] = item.rect.y;
4862 selection_count += 1;
4863 }
4864 }
4865 try std.testing.expectEqual(@as(usize, 8), unfocused_count);
4866 try std.testing.expectEqual(unfocused_count, focused_count);
4867 try std.testing.expectEqual(@as(usize, 3), selection_count);
4868 try std.testing.expectApproxEqAbs(@as(f32, 16), selection_y[1] - selection_y[0], 0.001);
4869 try std.testing.expectApproxEqAbs(@as(f32, 16), selection_y[2] - selection_y[1], 0.001);
4870
4871 const fragment_id = command.FragmentId{
4872 .root_id = 1,
4873 .element_id = 2,
4874 .namespace = command.fragment_namespace_widget,
4875 .part = command.fragment_part_text,
4876 };
4877 try buffers[0].commitFragment(fragment_id, 0);
4878 try buffers[2].commitFragment(fragment_id, 0);
4879 var retained = gui.paint.RetainedCommands.init(allocator);
4880 defer retained.deinit();
4881 try retained.retain(&buffers[0]);
4882 const damage = try retained.diff(
4883 &buffers[2],
4884 &.{Region.full(320, 200)},
4885 320,
4886 200,
4887 );
4888 const narrowed = switch (damage) {
4889 .semantic => return error.ExpectedNarrowedDamage,
4890 .narrowed => |value| value,
4891 };
4892 try std.testing.expectEqual(@as(usize, 1), narrowed.slice().len);
4893 const bounds = narrowed.bounding().?;
4894 try std.testing.expect(bounds.width <= 3);
4895 try std.testing.expect(bounds.height <= 20);
4896 }
4897
4898 test "bitmap wrapped measurement reuses warmed line workspace" {
4899 var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{});
4900 const allocator = failing.allocator();
4901 const glyphs = [_]BitmapGlyph{
4902 .{ .codepoint = 'A', .rows = &.{ 0xFF, 0xFF } },
4903 .{ .codepoint = 'B', .rows = &.{ 0xFF, 0xFF } },
4904 .{ .codepoint = ' ', .rows = &.{ 0x00, 0x00 } },
4905 .{ .codepoint = 'C', .rows = &.{ 0xFF, 0xFF } },
4906 .{ .codepoint = 'D', .rows = &.{ 0xFF, 0xFF } },
4907 };
4908 var atlas = try Atlas.initFromBitmapGlyphs(
4909 allocator,
4910 glyphs[0..],
4911 .{ .width = 8, .height = 2, .stride = 1 },
4912 16,
4913 test_atlas_cache_limits,
4914 );
4915 defer atlas.deinit();
4916 const wrapped = UiText{
4917 .content = "AB CD",
4918 .point_size = 16,
4919 .line_height = 1,
4920 .wrap_width = 24,
4921 };
4922 _ = try measureUncached(&atlas, wrapped);
4923
4924 failing.fail_index = failing.alloc_index;
4925 failing.resize_fail_index = failing.resize_index;
4926 const repeated = try measureUncached(&atlas, wrapped);
4927
4928 try std.testing.expectApproxEqAbs(@as(f32, 32), repeated.height, 0.001);
4929 try std.testing.expect(!failing.has_induced_failure);
4930 }
4931
4932 test "warmed visual line and caret queries need no backing allocation" {
4933 var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{});
4934 const allocator = failing.allocator();
4935 const glyphs = [_]BitmapGlyph{
4936 .{ .codepoint = 'A', .rows = &.{ 0xFF, 0xFF } },
4937 .{ .codepoint = 'B', .rows = &.{ 0xFF, 0xFF } },
4938 .{ .codepoint = ' ', .rows = &.{ 0x00, 0x00 } },
4939 };
4940 var atlas = try Atlas.initFromBitmapGlyphs(
4941 allocator,
4942 &glyphs,
4943 .{ .width = 8, .height = 2, .stride = 1 },
4944 16,
4945 test_atlas_cache_limits,
4946 );
4947 defer atlas.deinit();
4948 const text = "AB AB AB AB AB AB";
4949 _ = try LinePlan.init(&atlas, text, 24);
4950 for (0..text.len + 1) |byte_offset| {
4951 _ = try atlas.advanceForByteOffset(text, byte_offset);
4952 }
4953
4954 failing.fail_index = failing.alloc_index;
4955 failing.resize_fail_index = failing.resize_index;
4956 for (0..8) |_| {
4957 const plan = try LinePlan.init(&atlas, text, 24);
4958 var iterator = plan.iterator();
4959 while (iterator.next(&plan)) |visual| {
4960 _ = visual.advanceForByteOffset(plan.run, visual.byte_start, text.len);
4961 _ = visual.advanceForByteOffset(plan.run, visual.byte_end, text.len);
4962 }
4963 for (0..text.len + 1) |byte_offset| {
4964 const advance = try atlas.advanceForByteOffset(text, byte_offset);
4965 _ = try atlas.hitTestAdvance(text, advance);
4966 }
4967 }
4968 try std.testing.expect(!failing.has_induced_failure);
4969 }
4970
4971 test "atlas metrics derive from face and bitmap cell geometry" {
4972 const allocator = std.testing.allocator;
4973 var scratch = try AtlasScratch.init(allocator, .{ .bytes = 1024 * 1024 });
4974 defer scratch.deinit(allocator);
4975 const bytes = try filigree.fixtures.createWithOutlines(allocator);
4976 var outline_atlas = try Atlas.initFromOwnedBytes(
4977 allocator,
4978 &scratch,
4979 bytes,
4980 20,
4981 test_atlas_cache_limits,
4982 test_output_limits,
4983 );
4984 defer outline_atlas.deinit();
4985
4986 const outline_metrics = outline_atlas.metrics();
4987 const face = outline_atlas.backend.outline.font.face;
4988 const upem: f32 = @floatFromInt(face.units_per_em);
4989 try std.testing.expectApproxEqAbs(
4990 @as(f32, @floatFromInt(face.ascender)) * 20.0 / upem,
4991 outline_metrics.ascent,
4992 0.001,
4993 );
4994 try std.testing.expectApproxEqAbs(
4995 @as(f32, @floatFromInt(-@as(i32, face.descender))) * 20.0 / upem,
4996 outline_metrics.descent,
4997 0.001,
4998 );
4999 try std.testing.expect(outline_metrics.height() > 0);
5000
5001 const glyphs = [_]BitmapGlyph{.{ .codepoint = 'A', .rows = &.{ 0xFF, 0xFF } }};
5002 var bitmap_atlas = try Atlas.initFromBitmapGlyphs(
5003 allocator,
5004 glyphs[0..],
5005 .{ .width = 8, .height = 2, .stride = 1 },
5006 16,
5007 test_atlas_cache_limits,
5008 );
5009 defer bitmap_atlas.deinit();
5010
5011 const bitmap_metrics = bitmap_atlas.metrics();
5012 try std.testing.expectApproxEqAbs(@as(f32, 2), bitmap_metrics.ascent, 0.001);
5013 try std.testing.expectApproxEqAbs(@as(f32, 0), bitmap_metrics.descent, 0.001);
5014 try std.testing.expectApproxEqAbs(@as(f32, 2), bitmap_metrics.height(), 0.001);
5015 }
5016
5017 test "fallback face segments preserve primary runs and paint final notdef" {
5018 var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{});
5019 const allocator = failing.allocator();
5020 var scratch = try AtlasScratch.init(allocator, .{ .bytes = 1024 * 1024 });
5021 defer scratch.deinit(allocator);
5022 const primary_bytes = try filigree.fixtures.createWithOutlines(allocator);
5023 var primary = try Atlas.initFromOwnedBytes(
5024 allocator,
5025 &scratch,
5026 primary_bytes,
5027 18,
5028 test_atlas_cache_limits,
5029 test_output_limits,
5030 );
5031 defer primary.deinit();
5032 const fallback_bytes = try filigree.fixtures.createFallbackWithOutlines(allocator);
5033 var fallback_atlas = try Atlas.initFromOwnedBytes(
5034 allocator,
5035 &scratch,
5036 fallback_bytes,
5037 18,
5038 test_atlas_cache_limits,
5039 test_output_limits,
5040 );
5041 defer fallback_atlas.deinit();
5042 var fallback = try fallback_mod.Engine.init(allocator, .{
5043 .max_source_units = 32,
5044 .cache_entries = 8,
5045 .cache_payload_bytes = 1024,
5046 }, test_output_limits);
5047 defer fallback.deinit();
5048
5049 const entries = [_]AtlasSet.Entry{.{
5050 .face = 1,
5051 .image_index = 3,
5052 .atlas = &primary,
5053 }};
5054 const fallback_entries = [_]AtlasSet.Entry{.{
5055 .face = 0,
5056 .image_index = 9,
5057 .atlas = &fallback_atlas,
5058 }};
5059 const atlases = AtlasSet{
5060 .entries = &entries,
5061 .fallback_entries = &fallback_entries,
5062 .fallback = &fallback,
5063 };
5064 const content = "A\u{3b2}B\u{3bb}";
5065 const text = UiText{
5066 .content = content,
5067 .font_asset_id = 1,
5068 .point_size = 18,
5069 .line_height = 1,
5070 };
5071 const plan = try LinePlan.initComposite(&primary, &atlases, text, content, 0, 0);
5072 try std.testing.expectEqual(@as(usize, 4), plan.run.glyphs.len);
5073 try std.testing.expectEqual(@as(usize, 4), plan.metric_spans.len);
5074 try std.testing.expectEqual(@as(u32, 3), plan.metric_spans[plan.glyph_sources[0].segment_index].image_index);
5075 try std.testing.expectEqual(@as(u32, 9), plan.metric_spans[plan.glyph_sources[1].segment_index].image_index);
5076 try std.testing.expectEqual(@as(u32, 3), plan.metric_spans[plan.glyph_sources[2].segment_index].image_index);
5077 try std.testing.expectEqual(@as(u32, 9), plan.metric_spans[plan.glyph_sources[3].segment_index].image_index);
5078 try std.testing.expectEqual(@as(u32, 0), plan.run.glyphs[3].glyph_id);
5079 try std.testing.expect(!fallback_atlas.glyph_index.contains(0));
5080
5081 const surface = gui.model.UiSurfaceTree{
5082 .available_size = .{ .width = 96, .height = 24 },
5083 .root = .{
5084 .widget_id = 1,
5085 .children = &.{.{
5086 .widget_id = 2,
5087 .kind = .label,
5088 .text = text,
5089 .size = .{ .width = 96, .height = 24 },
5090 }},
5091 },
5092 };
5093 var frame_workspace = gui.frame.Workspace.init(allocator);
5094 defer frame_workspace.deinit();
5095 const frame = try frame_workspace.buildSurface(&surface, .{
5096 .resolvers = frameResolvers(&atlases),
5097 });
5098 var commands = command.CommandBuffer.init(allocator);
5099 defer commands.deinit();
5100 try appendFrameCommands(&commands, frame, &atlases, 1, 0);
5101 var glyph_images: [3]u32 = undefined;
5102 var glyph_count: usize = 0;
5103 var fill_count: usize = 0;
5104 for (commands.items()) |item| {
5105 switch (item.kind) {
5106 .glyph => {
5107 glyph_images[glyph_count] = item.image_index;
5108 glyph_count += 1;
5109 },
5110 .fill => fill_count += 1,
5111 else => {},
5112 }
5113 }
5114 try std.testing.expectEqual(@as(usize, 3), glyph_count);
5115 try std.testing.expectEqualSlices(u32, &.{ 3, 9, 3 }, &glyph_images);
5116 try std.testing.expectEqual(@as(usize, 4), fill_count);
5117
5118 const primary_plan = try textCaretPlan(&atlases, .{
5119 .content = "AB",
5120 .font_asset_id = 1,
5121 .point_size = 18,
5122 .line_height = 1,
5123 });
5124 try std.testing.expectEqual(@as(usize, 0), primary_plan.plan.glyph_sources.len);
5125 try expectGlyphRunEqual(try primary.shape("AB"), primary_plan.plan.run);
5126
5127 const primary_font = primary.outlineFont().?;
5128 const fallback_font = fallback_atlas.outlineFont().?;
5129 _ = try fallback.segments(primary_font, fallback_font, content);
5130 failing.fail_index = failing.alloc_index;
5131 failing.resize_fail_index = failing.resize_index;
5132 for (0..1024) |_| {
5133 _ = try fallback.segments(primary_font, fallback_font, content);
5134 }
5135 try std.testing.expect(!failing.has_induced_failure);
5136 }
5137
5138 fn appendComposerPromptCommands(
5139 atlases: *const AtlasSet,
5140 workspace: *gui.frame.Workspace,
5141 commands: *command.CommandBuffer,
5142 ) !void {
5143 const surface = gui.model.UiSurfaceTree{
5144 .available_size = .{ .width = 256, .height = 24 },
5145 .root = .{
5146 .widget_id = 1,
5147 .children = &.{.{
5148 .widget_id = 2,
5149 .kind = .label,
5150 .text = .{
5151 .content = "\u{276f} review styled text",
5152 .font_asset_id = 1,
5153 .point_size = 18,
5154 .line_height = 1,
5155 },
5156 .size = .{ .width = 256, .height = 24 },
5157 }},
5158 },
5159 };
5160 const frame = try workspace.buildSurface(&surface, .{
5161 .resolvers = frameResolvers(atlases),
5162 });
5163 try appendFrameCommands(commands, frame, atlases, 1, 0);
5164 }
5165
5166 test "composer prompt glyph renders exactly with and without fallback face" {
5167 const allocator = std.testing.allocator;
5168 var scratch = try AtlasScratch.init(allocator, .{ .bytes = 1024 * 1024 });
5169 defer scratch.deinit(allocator);
5170 const primary_bytes = try filigree.fixtures.createWithOutlines(allocator);
5171 var primary = try Atlas.initFromOwnedBytes(
5172 allocator,
5173 &scratch,
5174 primary_bytes,
5175 18,
5176 test_atlas_cache_limits,
5177 test_output_limits,
5178 );
5179 defer primary.deinit();
5180 const fallback_bytes = try filigree.fixtures.createFallbackWithOutlines(allocator);
5181 var fallback_atlas = try Atlas.initFromOwnedBytes(
5182 allocator,
5183 &scratch,
5184 fallback_bytes,
5185 18,
5186 test_atlas_cache_limits,
5187 test_output_limits,
5188 );
5189 defer fallback_atlas.deinit();
5190 var fallback = try fallback_mod.Engine.init(allocator, .{
5191 .max_source_units = 64,
5192 .cache_entries = 4,
5193 .cache_payload_bytes = 1024,
5194 }, test_output_limits);
5195 defer fallback.deinit();
5196 const entries = [_]AtlasSet.Entry{.{ .face = 1, .image_index = 3, .atlas = &primary }};
5197 const fallback_entries = [_]AtlasSet.Entry{.{ .face = 0, .image_index = 9, .atlas = &fallback_atlas }};
5198 const atlas_sets = [_]AtlasSet{
5199 .{ .entries = &entries },
5200 .{ .entries = &entries, .fallback_entries = &fallback_entries, .fallback = &fallback },
5201 };
5202 var workspaces = [_]gui.frame.Workspace{ gui.frame.Workspace.init(allocator), gui.frame.Workspace.init(allocator) };
5203 defer for (&workspaces) |*workspace| workspace.deinit();
5204 var buffers = [_]command.CommandBuffer{ command.CommandBuffer.init(allocator), command.CommandBuffer.init(allocator) };
5205 defer for (&buffers) |*buffer| buffer.deinit();
5206 for (&atlas_sets, 0..) |*atlases, index| {
5207 try appendComposerPromptCommands(atlases, &workspaces[index], &buffers[index]);
5208 }
5209 try std.testing.expectEqual(buffers[0].items().len, buffers[1].items().len);
5210 for (buffers[0].items(), buffers[1].items()) |without_fallback, with_fallback| {
5211 try std.testing.expect(std.meta.eql(without_fallback, with_fallback));
5212 }
5213 }
5214
5215 test "outline atlas scratch admits its measured maximum and rejects one byte less" {
5216 const allocator = std.testing.allocator;
5217 var survey = try AtlasScratch.init(allocator, .{ .bytes = 1024 * 1024 });
5218 defer survey.deinit(allocator);
5219 const survey_bytes = try filigree.fixtures.createWithOutlines(allocator);
5220 var surveyed = try Atlas.initFromOwnedBytes(
5221 allocator,
5222 &survey,
5223 survey_bytes,
5224 20,
5225 test_atlas_cache_limits,
5226 test_output_limits,
5227 );
5228 surveyed.deinit();
5229 const measured = survey.status().high_water_bytes;
5230 try std.testing.expect(measured > 0);
5231
5232 var exact = try AtlasScratch.init(allocator, .{ .bytes = measured });
5233 defer exact.deinit(allocator);
5234 const exact_bytes = try filigree.fixtures.createWithOutlines(allocator);
5235 var admitted = try Atlas.initFromOwnedBytes(
5236 allocator,
5237 &exact,
5238 exact_bytes,
5239 20,
5240 test_atlas_cache_limits,
5241 test_output_limits,
5242 );
5243 admitted.deinit();
5244 try std.testing.expectEqual(measured, exact.status().high_water_bytes);
5245 try std.testing.expectEqual(@as(usize, 0), exact.status().exhaustions);
5246
5247 var short = try AtlasScratch.init(allocator, .{ .bytes = measured - 1 });
5248 defer short.deinit(allocator);
5249 const short_bytes = try filigree.fixtures.createWithOutlines(allocator);
5250 try std.testing.expectError(
5251 error.OutOfMemory,
5252 Atlas.initFromOwnedBytes(
5253 allocator,
5254 &short,
5255 short_bytes,
5256 20,
5257 test_atlas_cache_limits,
5258 test_output_limits,
5259 ),
5260 );
5261 try std.testing.expectEqual(@as(usize, 1), short.status().epochs);
5262 try std.testing.expect(short.status().exhaustions > 0);
5263 }
5264
5265 test "appendFrameCommands keeps glyph runs inside their line box" {
5266 const allocator = std.testing.allocator;
5267 const glyphs = [_]BitmapGlyph{
5268 .{ .codepoint = 'A', .rows = &.{ 0xFF, 0xFF } },
5269 .{ .codepoint = 'g', .rows = &.{ 0xFF, 0xFF } },
5270 };
5271 var atlas = try Atlas.initFromBitmapGlyphs(
5272 allocator,
5273 glyphs[0..],
5274 .{ .width = 8, .height = 2, .stride = 1 },
5275 16,
5276 test_atlas_cache_limits,
5277 );
5278 defer atlas.deinit();
5279 const child = [_]gui.model.UiNode{.{
5280 .widget_id = 2,
5281 .kind = .label,
5282 .text = .{ .content = "Ag\ngA", .point_size = 16, .line_height = 1.5 },
5283 .paint = .{ .foreground = .{ .r = 255, .g = 255, .b = 255, .a = 255 } },
5284 .size = .{ .width = 64, .height = 48 },
5285 }};
5286 const surface = gui.model.UiSurfaceTree{
5287 .available_size = .{ .width = 80, .height = 64 },
5288 .root = .{
5289 .widget_id = 1,
5290 .children = child[0..],
5291 },
5292 };
5293 var frame_workspace = gui.frame.Workspace.init(allocator);
5294 defer frame_workspace.deinit();
5295 const frame = try frame_workspace.buildSurface(&surface, .{});
5296 var commands = command.CommandBuffer.init(allocator);
5297 defer commands.deinit();
5298
5299 const entries = [_]AtlasSet.Entry{.{ .face = 0, .image_index = 0, .atlas = &atlas }};
5300 const atlases = AtlasSet{ .entries = entries[0..] };
5301 try appendFrameCommands(&commands, frame, &atlases, 1, 0);
5302
5303 var block_y: f32 = 0;
5304 for (frame.widgets) |widget| {
5305 if (widget.text != null) block_y = widget.rect.y;
5306 }
5307 const line_height: f32 = 24;
5308 const half_leading: f32 = (line_height - 2) / 2;
5309 var glyph_index: usize = 0;
5310 for (commands.items()) |item| {
5311 if (item.kind != .glyph) continue;
5312 const line_index: f32 = if (glyph_index < 2) 0 else 1;
5313 const line_top = block_y + line_index * line_height;
5314 try std.testing.expectApproxEqAbs(line_top + half_leading, item.rect.y, 0.001);
5315 try std.testing.expect(item.rect.y >= line_top);
5316 try std.testing.expect(item.rect.y + item.rect.height <= line_top + line_height);
5317 glyph_index += 1;
5318 }
5319 try std.testing.expectEqual(@as(usize, 4), glyph_index);
5320 }
5321
5322 test "appendFrameCommands paints the glyph under a block cursor in the background color" {
5323 const allocator = std.testing.allocator;
5324 const glyphs = [_]BitmapGlyph{
5325 .{ .codepoint = 'm', .rows = &.{ 0xFF, 0xFF } },
5326 .{ .codepoint = 'e', .rows = &.{ 0xFF, 0xFF } },
5327 };
5328 var atlas = try Atlas.initFromBitmapGlyphs(
5329 allocator,
5330 glyphs[0..],
5331 .{ .width = 8, .height = 2, .stride = 1 },
5332 16,
5333 test_atlas_cache_limits,
5334 );
5335 defer atlas.deinit();
5336 const background = gui.model.UiColor{ .r = 30, .g = 33, .b = 39, .a = 255 };
5337 const child = [_]gui.model.UiNode{.{
5338 .widget_id = 2,
5339 .kind = .label,
5340 .text = .{ .content = "me", .point_size = 16, .line_height = 1.5 },
5341 .text_selection = .{ .cursor_visible = true, .cursor_block = true, .cursor_byte_offset = 0 },
5342 .paint = .{
5343 .foreground = .{ .r = 220, .g = 223, .b = 228, .a = 255 },
5344 .background = background,
5345 },
5346 .size = .{ .width = 64, .height = 24 },
5347 }};
5348 const surface = gui.model.UiSurfaceTree{
5349 .available_size = .{ .width = 80, .height = 32 },
5350 .root = .{
5351 .widget_id = 1,
5352 .children = child[0..],
5353 },
5354 };
5355 var frame_workspace = gui.frame.Workspace.init(allocator);
5356 defer frame_workspace.deinit();
5357 const frame = try frame_workspace.buildSurface(&surface, .{});
5358 var commands = command.CommandBuffer.init(allocator);
5359 defer commands.deinit();
5360
5361 const entries = [_]AtlasSet.Entry{.{ .face = 0, .image_index = 0, .atlas = &atlas }};
5362 const atlases = AtlasSet{ .entries = entries[0..] };
5363 try appendFrameCommands(&commands, frame, &atlases, 1, 0);
5364
5365 var saw_fill = false;
5366 var glyph_colors: [2]Color = undefined;
5367 var glyph_count: usize = 0;
5368 for (commands.items()) |item| {
5369 switch (item.kind) {
5370 .fill => saw_fill = true,
5371 .glyph => {
5372 glyph_colors[glyph_count] = item.color;
5373 glyph_count += 1;
5374 },
5375 else => {},
5376 }
5377 }
5378 try std.testing.expect(saw_fill);
5379 try std.testing.expectEqual(@as(usize, 2), glyph_count);
5380 try std.testing.expectEqual(background.r, glyph_colors[0].r);
5381 try std.testing.expectEqual(background.g, glyph_colors[0].g);
5382 try std.testing.expectEqual(background.b, glyph_colors[0].b);
5383 try std.testing.expectEqual(@as(u8, 220), glyph_colors[1].r);
5384 }
5385
5386 test "appendFrameCommands scales glyph commands to text point size" {
5387 const allocator = std.testing.allocator;
5388 const glyphs = [_]BitmapGlyph{
5389 .{ .codepoint = 'A', .rows = &.{ 0x81, 0xFF } },
5390 };
5391 var atlas = try Atlas.initFromBitmapGlyphs(
5392 allocator,
5393 glyphs[0..],
5394 .{ .width = 8, .height = 2, .stride = 1 },
5395 16,
5396 test_atlas_cache_limits,
5397 );
5398 defer atlas.deinit();
5399 const child = [_]gui.model.UiNode{.{
5400 .widget_id = 2,
5401 .kind = .label,
5402 .text = .{ .content = "A", .point_size = 32, .line_height = 1.0 },
5403 .paint = .{ .foreground = .{ .r = 12, .g = 24, .b = 36, .a = 255 } },
5404 .size = .{ .width = 64, .height = 40 },
5405 }};
5406 const surface = gui.model.UiSurfaceTree{
5407 .available_size = .{ .width = 80, .height = 48 },
5408 .root = .{
5409 .widget_id = 1,
5410 .children = child[0..],
5411 },
5412 };
5413 var frame_workspace = gui.frame.Workspace.init(allocator);
5414 defer frame_workspace.deinit();
5415 const frame = try frame_workspace.buildSurface(&surface, .{});
5416 var commands = command.CommandBuffer.init(allocator);
5417 defer commands.deinit();
5418
5419 const entries = [_]AtlasSet.Entry{.{ .face = 0, .image_index = 0, .atlas = &atlas }};
5420 const atlases = AtlasSet{ .entries = entries[0..] };
5421 try appendFrameCommands(&commands, frame, &atlases, 1, 0);
5422
5423 try std.testing.expect(commands.items().len > 0);
5424 try std.testing.expectApproxEqAbs(@as(f32, 16), commands.items()[0].rect.width, 0.001);
5425 try std.testing.expectApproxEqAbs(@as(f32, 4), commands.items()[0].rect.height, 0.001);
5426 }
5427
5428 test "appendFrameCommands scales glyph geometry by device scale and keeps atlas sources" {
5429 const allocator = std.testing.allocator;
5430 const glyphs = [_]BitmapGlyph{
5431 .{ .codepoint = 'A', .rows = &.{ 0x81, 0xFF } },
5432 };
5433 var atlas = try Atlas.initFromBitmapGlyphs(
5434 allocator,
5435 glyphs[0..],
5436 .{ .width = 8, .height = 2, .stride = 1 },
5437 16,
5438 test_atlas_cache_limits,
5439 );
5440 defer atlas.deinit();
5441 const child = [_]gui.model.UiNode{.{
5442 .widget_id = 2,
5443 .kind = .label,
5444 .text = .{ .content = "A", .point_size = 16, .line_height = 1.0 },
5445 .paint = .{ .foreground = .{ .r = 12, .g = 24, .b = 36, .a = 255 } },
5446 .size = .{ .width = 64, .height = 40 },
5447 }};
5448 const surface = gui.model.UiSurfaceTree{
5449 .available_size = .{ .width = 80, .height = 48 },
5450 .root = .{
5451 .widget_id = 1,
5452 .children = child[0..],
5453 },
5454 };
5455 var frame_workspace = gui.frame.Workspace.init(allocator);
5456 defer frame_workspace.deinit();
5457 const frame = try frame_workspace.buildSurface(&surface, .{});
5458
5459 const entries = [_]AtlasSet.Entry{.{ .face = 0, .image_index = 0, .atlas = &atlas }};
5460 const atlases = AtlasSet{ .entries = entries[0..] };
5461
5462 var logical = command.CommandBuffer.init(allocator);
5463 defer logical.deinit();
5464 try appendFrameCommands(&logical, frame, &atlases, 1, 0);
5465 var device = command.CommandBuffer.init(allocator);
5466 defer device.deinit();
5467 try appendFrameCommands(&device, frame, &atlases, 2, 0);
5468
5469 try std.testing.expectEqual(logical.items().len, device.items().len);
5470 for (logical.items(), device.items()) |one, two| {
5471 try std.testing.expectEqual(one.kind, two.kind);
5472 try std.testing.expectApproxEqAbs(one.rect.x * 2, two.rect.x, 0.001);
5473 try std.testing.expectApproxEqAbs(one.rect.y * 2, two.rect.y, 0.001);
5474 try std.testing.expectApproxEqAbs(one.rect.width * 2, two.rect.width, 0.001);
5475 try std.testing.expectApproxEqAbs(one.rect.height * 2, two.rect.height, 0.001);
5476 try std.testing.expectApproxEqAbs(one.clip.x * 2, two.clip.x, 0.001);
5477 try std.testing.expectEqual(one.source.x, two.source.x);
5478 try std.testing.expectEqual(one.source.width, two.source.width);
5479 }
5480 }
5481
5482 test "appendFrameCommands draws text selection fills before glyph commands" {
5483 const allocator = std.testing.allocator;
5484 const glyphs = [_]BitmapGlyph{
5485 .{ .codepoint = 'A', .rows = &.{ 0xFF, 0xFF } },
5486 .{ .codepoint = 'B', .rows = &.{ 0xFF, 0xFF } },
5487 .{ .codepoint = 'C', .rows = &.{ 0xFF, 0xFF } },
5488 .{ .codepoint = 'D', .rows = &.{ 0xFF, 0xFF } },
5489 };
5490 var atlas = try Atlas.initFromBitmapGlyphs(
5491 allocator,
5492 glyphs[0..],
5493 .{ .width = 8, .height = 2, .stride = 1 },
5494 16,
5495 test_atlas_cache_limits,
5496 );
5497 defer atlas.deinit();
5498 const child = [_]gui.model.UiNode{.{
5499 .widget_id = 2,
5500 .kind = .text_input,
5501 .text = .{ .content = "ABCD", .point_size = 16, .line_height = 1.0 },
5502 .text_selection = .{
5503 .cursor_visible = true,
5504 .cursor_byte_offset = 2,
5505 .selection_active = true,
5506 .selection_anchor_byte_offset = 1,
5507 .selection_focus_byte_offset = 3,
5508 },
5509 .paint = .{ .foreground = .{ .r = 12, .g = 24, .b = 36, .a = 255 } },
5510 .size = .{ .width = 64, .height = 20 },
5511 }};
5512 const surface = gui.model.UiSurfaceTree{
5513 .available_size = .{ .width = 80, .height = 32 },
5514 .root = .{
5515 .widget_id = 1,
5516 .children = child[0..],
5517 },
5518 };
5519 var frame_workspace = gui.frame.Workspace.init(allocator);
5520 defer frame_workspace.deinit();
5521 const frame = try frame_workspace.buildSurface(&surface, .{});
5522 var commands = command.CommandBuffer.init(allocator);
5523 defer commands.deinit();
5524
5525 const entries = [_]AtlasSet.Entry{.{ .face = 0, .image_index = 0, .atlas = &atlas }};
5526 const atlases = AtlasSet{ .entries = entries[0..] };
5527 try appendFrameCommands(&commands, frame, &atlases, 1, 0);
5528
5529 try std.testing.expectEqual(@as(usize, 6), commands.items().len);
5530 try std.testing.expectEqual(command.Kind.fill, commands.items()[0].kind);
5531 try std.testing.expectEqual(command.Kind.glyph, commands.items()[1].kind);
5532 try std.testing.expectEqual(command.Kind.fill, commands.items()[5].kind);
5533 try std.testing.expectApproxEqAbs(@as(f32, 8), commands.items()[0].rect.x, 0.001);
5534 try std.testing.expectApproxEqAbs(@as(f32, 16), commands.items()[0].rect.width, 0.001);
5535 try std.testing.expectApproxEqAbs(@as(f32, 16), commands.items()[5].rect.x, 0.001);
5536 try std.testing.expect(commands.items()[0].rect.width > commands.items()[5].rect.width);
5537 }
5538
5539 test "styled text runs preserve layout caret and selection geometry" {
5540 const allocator = std.testing.allocator;
5541 const glyphs = [_]BitmapGlyph{
5542 .{ .codepoint = 'A', .rows = &.{ 0xFF, 0xFF } },
5543 .{ .codepoint = 'B', .rows = &.{ 0xFF, 0xFF } },
5544 .{ .codepoint = 'C', .rows = &.{ 0xFF, 0xFF } },
5545 .{ .codepoint = 'D', .rows = &.{ 0xFF, 0xFF } },
5546 };
5547 var atlas = try Atlas.initFromBitmapGlyphs(
5548 allocator,
5549 &glyphs,
5550 .{ .width = 8, .height = 2, .stride = 1 },
5551 16,
5552 test_atlas_cache_limits,
5553 );
5554 defer atlas.deinit();
5555 const runs = [_]UiTextRun{
5556 .{
5557 .byte_start = 1,
5558 .byte_end = 2,
5559 .foreground = .{ .r = 220, .g = 30, .b = 20, .a = 255 },
5560 .background = .{ .r = 15, .g = 35, .b = 90, .a = 180 },
5561 .underline = true,
5562 },
5563 .{
5564 .byte_start = 2,
5565 .byte_end = 4,
5566 .foreground = .{ .r = 20, .g = 190, .b = 70, .a = 255 },
5567 .strikethrough = true,
5568 },
5569 };
5570 const plain = UiText{ .content = "ABCD", .point_size = 16, .line_height = 1 };
5571 const styled = UiText{
5572 .content = plain.content,
5573 .runs = &runs,
5574 .point_size = plain.point_size,
5575 .line_height = plain.line_height,
5576 .wrap_width = plain.wrap_width,
5577 };
5578 const selection = UiTextSelection{
5579 .cursor_visible = true,
5580 .cursor_byte_offset = 3,
5581 .selection_active = true,
5582 .selection_anchor_byte_offset = 1,
5583 .selection_focus_byte_offset = 4,
5584 };
5585 try std.testing.expectEqual(try measure(&atlas, plain), try measure(&atlas, styled));
5586 try std.testing.expectEqual(@as(usize, 1), atlas.cacheStatus().measure.entries);
5587
5588 const nodes = [_]UiNode{
5589 .{
5590 .widget_id = 2,
5591 .kind = .label,
5592 .text = plain,
5593 .text_selection = selection,
5594 .paint = .{ .foreground = .{ .r = 12, .g = 24, .b = 36, .a = 255 } },
5595 .size = .{ .width = 64, .height = 16 },
5596 },
5597 .{
5598 .widget_id = 2,
5599 .kind = .label,
5600 .text = styled,
5601 .text_selection = selection,
5602 .paint = .{ .foreground = .{ .r = 12, .g = 24, .b = 36, .a = 255 } },
5603 .size = .{ .width = 64, .height = 16 },
5604 },
5605 };
5606 const entries = [_]AtlasSet.Entry{.{ .face = 0, .image_index = 0, .atlas = &atlas }};
5607 const atlases = AtlasSet{ .entries = &entries };
5608 var frames = [_]gui.frame.Workspace{
5609 gui.frame.Workspace.init(allocator),
5610 gui.frame.Workspace.init(allocator),
5611 };
5612 defer for (&frames) |*frame| frame.deinit();
5613 var buffers = [_]command.CommandBuffer{
5614 command.CommandBuffer.init(allocator),
5615 command.CommandBuffer.init(allocator),
5616 };
5617 defer for (&buffers) |*buffer| buffer.deinit();
5618 var widget_geometry: [2]WidgetFrame = undefined;
5619 for (0..nodes.len) |index| {
5620 const surface = gui.model.UiSurfaceTree{
5621 .available_size = .{ .width = 64, .height = 16 },
5622 .root = .{ .widget_id = 1, .children = nodes[index .. index + 1] },
5623 };
5624 const frame = try frames[index].buildSurface(&surface, .{});
5625 widget_geometry[index] = frame.widgets[1];
5626 try appendFrameCommands(&buffers[index], frame, &atlases, 1, 0);
5627 }
5628 try std.testing.expectEqual(widget_geometry[0].rect, widget_geometry[1].rect);
5629 try std.testing.expectEqual(widget_geometry[0].visible_rect, widget_geometry[1].visible_rect);
5630 try std.testing.expectEqual(widget_geometry[0].constraints, widget_geometry[1].constraints);
5631 try std.testing.expectEqual(widget_geometry[0].content_size, widget_geometry[1].content_size);
5632
5633 var plain_glyphs: [4]command.Command = undefined;
5634 var styled_glyphs: [4]command.Command = undefined;
5635 var plain_count: usize = 0;
5636 var styled_count: usize = 0;
5637 for (buffers[0].items()) |item| {
5638 if (item.kind != .glyph) continue;
5639 plain_glyphs[plain_count] = item;
5640 plain_count += 1;
5641 }
5642 for (buffers[1].items()) |item| {
5643 if (item.kind != .glyph) continue;
5644 styled_glyphs[styled_count] = item;
5645 styled_count += 1;
5646 }
5647 try std.testing.expectEqual(@as(usize, 4), plain_count);
5648 try std.testing.expectEqual(plain_count, styled_count);
5649 for (plain_glyphs, styled_glyphs) |plain_glyph, styled_glyph| {
5650 try std.testing.expectEqual(plain_glyph.rect, styled_glyph.rect);
5651 try std.testing.expectEqual(plain_glyph.source, styled_glyph.source);
5652 try std.testing.expectEqual(plain_glyph.clip, styled_glyph.clip);
5653 }
5654 try std.testing.expectEqual(@as(u8, 12), styled_glyphs[0].color.r);
5655 try std.testing.expectEqual(@as(u8, 220), styled_glyphs[1].color.r);
5656 try std.testing.expectEqual(@as(u8, 20), styled_glyphs[2].color.r);
5657 try std.testing.expectEqual(@as(u8, 20), styled_glyphs[3].color.r);
5658
5659 const plain_commands = buffers[0].items();
5660 const styled_commands = buffers[1].items();
5661 try std.testing.expectEqual(@as(usize, 6), plain_commands.len);
5662 try std.testing.expectEqual(@as(usize, 9), styled_commands.len);
5663 try std.testing.expectEqual(command.Kind.fill, styled_commands[0].kind);
5664 try std.testing.expectEqual(@as(u8, 15), styled_commands[0].color.r);
5665 try std.testing.expectApproxEqAbs(@as(f32, 8), styled_commands[0].rect.x, 0.001);
5666 try std.testing.expectApproxEqAbs(@as(f32, 8), styled_commands[0].rect.width, 0.001);
5667 try std.testing.expectEqual(command.Kind.fill, styled_commands[6].kind);
5668 try std.testing.expectEqual(@as(u8, 220), styled_commands[6].color.r);
5669 try std.testing.expectEqual(command.Kind.fill, styled_commands[7].kind);
5670 try std.testing.expectEqual(@as(u8, 20), styled_commands[7].color.r);
5671 try std.testing.expectEqual(plain_commands[0].rect, styled_commands[1].rect);
5672 try std.testing.expectEqual(plain_commands[plain_commands.len - 1].rect, styled_commands[styled_commands.len - 1].rect);
5673 }
5674
5675 test "resolved equal metric slots preserve measure and command bytes" {
5676 const allocator = std.testing.allocator;
5677 const glyphs = [_]BitmapGlyph{
5678 .{ .codepoint = 'A', .rows = &.{ 0xFF, 0xFF } },
5679 .{ .codepoint = 'B', .rows = &.{ 0xFF, 0xFF } },
5680 .{ .codepoint = 'C', .rows = &.{ 0xFF, 0xFF } },
5681 };
5682 var atlas = try Atlas.initFromBitmapGlyphs(
5683 allocator,
5684 &glyphs,
5685 .{ .width = 8, .height = 2, .stride = 1 },
5686 16,
5687 test_atlas_cache_limits,
5688 );
5689 defer atlas.deinit();
5690 const plain = UiText{
5691 .content = "ABC",
5692 .point_size = 16,
5693 .line_height = 1,
5694 .wrap_width = 16,
5695 };
5696 const styles = [_]UiTextStyle{.{
5697 .font_asset_id = plain.font_asset_id,
5698 .point_size = plain.point_size,
5699 }};
5700 const runs = [_]UiTextRun{.{
5701 .byte_start = 1,
5702 .byte_end = 2,
5703 .style_slot = 1,
5704 }};
5705 const slotted = UiText{
5706 .content = plain.content,
5707 .styles = &styles,
5708 .runs = &runs,
5709 .point_size = plain.point_size,
5710 .line_height = plain.line_height,
5711 .wrap_width = plain.wrap_width,
5712 };
5713 try std.testing.expectEqual(try measure(&atlas, plain), try measure(&atlas, slotted));
5714 try std.testing.expectEqual(@as(usize, 1), atlas.cacheStatus().measure.entries);
5715 const entries = [_]AtlasSet.Entry{.{
5716 .face = 0,
5717 .image_index = 4,
5718 .atlas = &atlas,
5719 }};
5720 const atlases = AtlasSet{ .entries = &entries };
5721 var workspaces = [_]gui.frame.Workspace{
5722 gui.frame.Workspace.init(allocator),
5723 gui.frame.Workspace.init(allocator),
5724 };
5725 defer for (&workspaces) |*workspace| workspace.deinit();
5726 var buffers = [_]command.CommandBuffer{
5727 command.CommandBuffer.init(allocator),
5728 command.CommandBuffer.init(allocator),
5729 };
5730 defer for (&buffers) |*buffer| buffer.deinit();
5731 const texts = [_]UiText{ plain, slotted };
5732 for (texts, 0..) |text, index| {
5733 const tree = gui.model.UiSurfaceTree{
5734 .available_size = .{ .width = 64, .height = 24 },
5735 .root = .{
5736 .widget_id = 1,
5737 .children = &.{.{
5738 .widget_id = 2,
5739 .kind = .text_input,
5740 .text = text,
5741 .text_selection = .{
5742 .cursor_visible = true,
5743 .cursor_byte_offset = 2,
5744 .selection_active = true,
5745 .selection_anchor_byte_offset = 0,
5746 .selection_focus_byte_offset = 3,
5747 },
5748 .size = .{ .width = 64, .height = 48 },
5749 }},
5750 },
5751 };
5752 const frame = try workspaces[index].buildSurface(&tree, .{
5753 .resolvers = frameResolvers(&atlases),
5754 });
5755 try appendFrameCommands(&buffers[index], frame, &atlases, 1, 0);
5756 }
5757 try std.testing.expectEqual(buffers[0].items().len, buffers[1].items().len);
5758 for (buffers[0].items(), buffers[1].items()) |plain_command, slotted_command| {
5759 try std.testing.expect(std.meta.eql(plain_command, slotted_command));
5760 }
5761 }
5762
5763 test "mixed metric measurement keys distinguish alternate atlas sets" {
5764 const allocator = std.testing.allocator;
5765 const base_glyphs = [_]BitmapGlyph{.{
5766 .codepoint = 'A',
5767 .rows = &.{ 0xFF, 0xFF },
5768 }};
5769 const narrow_glyphs = [_]BitmapGlyph{.{
5770 .codepoint = 'B',
5771 .rows = &.{ 0xFF, 0xF0, 0xFF, 0xF0 },
5772 }};
5773 const wide_glyphs = [_]BitmapGlyph{.{
5774 .codepoint = 'B',
5775 .rows = &.{ 0xFF, 0xFF, 0xF0, 0xFF, 0xFF, 0xF0 },
5776 }};
5777 var base_atlas = try Atlas.initFromBitmapGlyphs(
5778 allocator,
5779 &base_glyphs,
5780 .{ .width = 8, .height = 2, .stride = 1 },
5781 16,
5782 test_atlas_cache_limits,
5783 );
5784 defer base_atlas.deinit();
5785 var narrow_atlas = try Atlas.initFromBitmapGlyphs(
5786 allocator,
5787 &narrow_glyphs,
5788 .{ .width = 12, .height = 2, .stride = 2 },
5789 16,
5790 test_atlas_cache_limits,
5791 );
5792 defer narrow_atlas.deinit();
5793 var wide_atlas = try Atlas.initFromBitmapGlyphs(
5794 allocator,
5795 &wide_glyphs,
5796 .{ .width = 20, .height = 2, .stride = 3 },
5797 16,
5798 test_atlas_cache_limits,
5799 );
5800 defer wide_atlas.deinit();
5801 const styles = [_]UiTextStyle{.{
5802 .font_asset_id = 2,
5803 .point_size = 16,
5804 }};
5805 const runs = [_]UiTextRun{.{
5806 .byte_start = 1,
5807 .byte_end = 2,
5808 .style_slot = 1,
5809 }};
5810 const text = UiText{
5811 .content = "AB",
5812 .runs = &runs,
5813 .styles = &styles,
5814 .font_asset_id = 1,
5815 .point_size = 16,
5816 .line_height = 1,
5817 };
5818 const narrow_entries = [_]AtlasSet.Entry{
5819 .{ .face = 1, .image_index = 0, .atlas = &base_atlas },
5820 .{ .face = 2, .image_index = 1, .atlas = &narrow_atlas },
5821 };
5822 const wide_entries = [_]AtlasSet.Entry{
5823 .{ .face = 1, .image_index = 0, .atlas = &base_atlas },
5824 .{ .face = 2, .image_index = 2, .atlas = &wide_atlas },
5825 };
5826 const narrow = AtlasSet{ .entries = &narrow_entries };
5827 const wide = AtlasSet{ .entries = &wide_entries };
5828 try std.testing.expectEqual(@as(f32, 20), (try measureSet(&narrow, text)).width);
5829 try std.testing.expectEqual(@as(f32, 28), (try measureSet(&wide, text)).width);
5830 try std.testing.expectEqual(@as(usize, 2), base_atlas.cacheStatus().measure.entries);
5831 }
5832
5833 test "mixed metric runs share a baseline and retain per glyph atlas identity" {
5834 const allocator = std.testing.allocator;
5835 const base_glyphs = [_]BitmapGlyph{
5836 .{ .codepoint = 'A', .rows = &.{ 0xFF, 0xFF } },
5837 .{ .codepoint = 'C', .rows = &.{ 0xFF, 0xFF } },
5838 };
5839 const large_glyphs = [_]BitmapGlyph{
5840 .{ .codepoint = 'B', .rows = &.{
5841 0xFF, 0xF0,
5842 0xFF, 0xF0,
5843 0xFF, 0xF0,
5844 0xFF, 0xF0,
5845 } },
5846 };
5847 var base_atlas = try Atlas.initFromBitmapGlyphs(
5848 allocator,
5849 &base_glyphs,
5850 .{ .width = 8, .height = 2, .stride = 1 },
5851 16,
5852 test_atlas_cache_limits,
5853 );
5854 defer base_atlas.deinit();
5855 var large_atlas = try Atlas.initFromBitmapGlyphs(
5856 allocator,
5857 &large_glyphs,
5858 .{ .width = 12, .height = 4, .stride = 2 },
5859 24,
5860 test_atlas_cache_limits,
5861 );
5862 defer large_atlas.deinit();
5863 const styles = [_]UiTextStyle{.{
5864 .font_asset_id = 2,
5865 .point_size = 24,
5866 }};
5867 const runs = [_]UiTextRun{.{
5868 .byte_start = 1,
5869 .byte_end = 2,
5870 .style_slot = 1,
5871 .background = .{ .r = 12, .g = 24, .b = 48, .a = 180 },
5872 .underline = true,
5873 }};
5874 const text = UiText{
5875 .content = "ABC",
5876 .runs = &runs,
5877 .styles = &styles,
5878 .font_asset_id = 1,
5879 .point_size = 16,
5880 .line_height = 1,
5881 };
5882 const entries = [_]AtlasSet.Entry{
5883 .{ .face = 1, .image_index = 3, .atlas = &base_atlas },
5884 .{ .face = 2, .image_index = 9, .atlas = &large_atlas },
5885 };
5886 const atlases = AtlasSet{ .entries = &entries };
5887 try std.testing.expectEqual(Size{ .width = 28, .height = 24 }, try measureSet(&atlases, text));
5888 const plan = try LinePlan.initComposite(
5889 &base_atlas,
5890 &atlases,
5891 text,
5892 text.content,
5893 0,
5894 0,
5895 );
5896 try std.testing.expectEqual(@as(usize, 3), plan.run.glyphs.len);
5897 try std.testing.expectEqual(@as(i32, 8 * 64), plan.run.glyphs[0].x_advance);
5898 try std.testing.expectEqual(@as(i32, 12 * 64), plan.run.glyphs[1].x_advance);
5899 try std.testing.expectEqual(@as(i32, 8 * 64), plan.run.glyphs[2].x_advance);
5900 for (0..text.content.len + 1) |byte_offset| {
5901 const advance = try textAdvanceForByteOffset(&atlases, text, byte_offset);
5902 const hit = try textHitTestAdvance(&atlases, text, advance);
5903 try std.testing.expectEqual(byte_offset, hit.byte_offset);
5904 try std.testing.expectApproxEqAbs(advance, hit.advance, 0.001);
5905 }
5906 _ = try base_atlas.shape("CC");
5907 try std.testing.expectEqual(@as(u32, 'A'), plan.run.glyphs[0].glyph_id);
5908 try std.testing.expectEqual(@as(u32, 'B'), plan.run.glyphs[1].glyph_id);
5909 const tree = gui.model.UiSurfaceTree{
5910 .available_size = .{ .width = 64, .height = 24 },
5911 .root = .{
5912 .widget_id = 1,
5913 .children = &.{.{
5914 .widget_id = 2,
5915 .kind = .label,
5916 .text = text,
5917 .text_selection = .{
5918 .selection_active = true,
5919 .selection_anchor_byte_offset = 0,
5920 .selection_focus_byte_offset = 3,
5921 },
5922 .size = .{ .width = 64, .height = 24 },
5923 }},
5924 },
5925 };
5926 var workspace = gui.frame.Workspace.init(allocator);
5927 defer workspace.deinit();
5928 const frame = try workspace.buildSurface(&tree, .{
5929 .resolvers = frameResolvers(&atlases),
5930 });
5931 var one = command.CommandBuffer.init(allocator);
5932 defer one.deinit();
5933 var two = command.CommandBuffer.init(allocator);
5934 defer two.deinit();
5935 try appendFrameCommands(&one, frame, &atlases, 1, 0);
5936 try appendFrameCommands(&two, frame, &atlases, 2, 0);
5937 var one_glyphs: [3]command.Command = undefined;
5938 var two_glyphs: [3]command.Command = undefined;
5939 var one_count: usize = 0;
5940 var two_count: usize = 0;
5941 for (one.items()) |item| {
5942 if (item.kind != .glyph) continue;
5943 one_glyphs[one_count] = item;
5944 one_count += 1;
5945 }
5946 for (two.items()) |item| {
5947 if (item.kind != .glyph) continue;
5948 two_glyphs[two_count] = item;
5949 two_count += 1;
5950 }
5951 try std.testing.expectEqual(@as(usize, 3), one_count);
5952 try std.testing.expectEqual(one_count, two_count);
5953 try std.testing.expectEqual(@as(u32, 3), one_glyphs[0].image_index);
5954 try std.testing.expectEqual(@as(u32, 9), one_glyphs[1].image_index);
5955 try std.testing.expectEqual(@as(u32, 3), one_glyphs[2].image_index);
5956 try std.testing.expectApproxEqAbs(
5957 one_glyphs[0].rect.y + one_glyphs[0].rect.height,
5958 one_glyphs[1].rect.y + one_glyphs[1].rect.height,
5959 0.001,
5960 );
5961 try std.testing.expectApproxEqAbs(
5962 one_glyphs[1].rect.y + one_glyphs[1].rect.height,
5963 one_glyphs[2].rect.y + one_glyphs[2].rect.height,
5964 0.001,
5965 );
5966 for (one_glyphs, two_glyphs) |logical, scaled| {
5967 try std.testing.expectEqual(logical.image_index, scaled.image_index);
5968 try std.testing.expectApproxEqAbs(logical.rect.x * 2, scaled.rect.x, 0.001);
5969 try std.testing.expectApproxEqAbs(logical.rect.y * 2, scaled.rect.y, 0.001);
5970 try std.testing.expectApproxEqAbs(logical.rect.width * 2, scaled.rect.width, 0.001);
5971 try std.testing.expectApproxEqAbs(logical.rect.height * 2, scaled.rect.height, 0.001);
5972 }
5973 }
5974
5975 test "styled text runs rasterize fixed colors gaps decorations and wrapped partitions" {
5976 const allocator = std.testing.allocator;
5977 const ink = @as([8]u8, @splat(0x80));
5978 const blank = @as([8]u8, @splat(0));
5979 const glyphs = [_]BitmapGlyph{
5980 .{ .codepoint = 'A', .rows = &ink },
5981 .{ .codepoint = 'B', .rows = &ink },
5982 .{ .codepoint = ' ', .rows = &blank },
5983 .{ .codepoint = 'C', .rows = &ink },
5984 .{ .codepoint = 'D', .rows = &ink },
5985 .{ .codepoint = 'E', .rows = &ink },
5986 };
5987 var atlas = try Atlas.initFromBitmapGlyphs(
5988 allocator,
5989 &glyphs,
5990 .{ .width = 4, .height = 8, .stride = 1 },
5991 8,
5992 test_atlas_cache_limits,
5993 );
5994 defer atlas.deinit();
5995 const base = Color{ .r = 20, .g = 30, .b = 40, .a = 255 };
5996 const clear = Color{ .r = 2, .g = 3, .b = 4, .a = 255 };
5997 const red = Color{ .r = 220, .g = 30, .b = 20, .a = 255 };
5998 const blue = Color{ .r = 10, .g = 40, .b = 100, .a = 255 };
5999 const green = Color{ .r = 20, .g = 200, .b = 60, .a = 255 };
6000 const runs = [_]UiTextRun{
6001 .{
6002 .byte_start = 1,
6003 .byte_end = 4,
6004 .foreground = red,
6005 .background = blue,
6006 .underline = true,
6007 },
6008 .{
6009 .byte_start = 4,
6010 .byte_end = 5,
6011 .foreground = green,
6012 .strikethrough = true,
6013 },
6014 .{
6015 .byte_start = 5,
6016 .byte_end = 6,
6017 .foreground = .{ .r = 255, .b = 255, .a = 0 },
6018 .background = .{ .a = 0 },
6019 },
6020 };
6021 const children = [_]UiNode{.{
6022 .widget_id = 2,
6023 .kind = .label,
6024 .text = .{
6025 .content = "AB CDE",
6026 .runs = &runs,
6027 .point_size = 8,
6028 .line_height = 1,
6029 .wrap_width = 12,
6030 },
6031 .paint = .{ .foreground = base },
6032 .size = .{ .width = 12, .height = 16 },
6033 }};
6034 const surface = gui.model.UiSurfaceTree{
6035 .available_size = .{ .width = 12, .height = 16 },
6036 .root = .{ .widget_id = 1, .children = &children },
6037 };
6038 var frame_workspace = gui.frame.Workspace.init(allocator);
6039 defer frame_workspace.deinit();
6040 const frame = try frame_workspace.buildSurface(&surface, .{});
6041 var commands = command.CommandBuffer.init(allocator);
6042 defer commands.deinit();
6043 const entries = [_]AtlasSet.Entry{.{
6044 .face = 0,
6045 .image_index = 0,
6046 .atlas = &atlas,
6047 }};
6048 const atlases = AtlasSet{ .entries = &entries };
6049 try appendFrameCommands(&commands, frame, &atlases, 1, 0);
6050
6051 var pixels = @as([(12 * 16)]u32, @splat(0));
6052 const images = [_]command.Image{atlas.image.image};
6053 try cpu.renderCommandsPackedWithImages(
6054 commands.items(),
6055 .{ .width = 12, .height = 16, .pixels = &pixels },
6056 clear,
6057 .{ .images = &images },
6058 );
6059
6060 try std.testing.expectEqual(cpu.packRgba(base), pixels[0 + 2 * 12]);
6061 try std.testing.expectEqual(cpu.packRgba(clear), pixels[2 + 2 * 12]);
6062 try std.testing.expectEqual(cpu.packRgba(red), pixels[4 + 2 * 12]);
6063 try std.testing.expectEqual(cpu.packRgba(blue), pixels[6 + 2 * 12]);
6064 try std.testing.expectEqual(cpu.packRgba(blue), pixels[10 + 2 * 12]);
6065 try std.testing.expectEqual(cpu.packRgba(red), pixels[6 + 7 * 12]);
6066 try std.testing.expectEqual(cpu.packRgba(red), pixels[0 + 10 * 12]);
6067 try std.testing.expectEqual(cpu.packRgba(blue), pixels[2 + 10 * 12]);
6068 try std.testing.expectEqual(cpu.packRgba(red), pixels[2 + 15 * 12]);
6069 try std.testing.expectEqual(cpu.packRgba(green), pixels[4 + 10 * 12]);
6070 try std.testing.expectEqual(
6071 cpu.packRgba(.{ .r = 11, .g = 102, .b = 32, .a = 255 }),
6072 pixels[6 + 12 * 12],
6073 );
6074 try std.testing.expectEqual(cpu.packRgba(clear), pixels[8 + 10 * 12]);
6075 try std.testing.expectEqual(cpu.packRgba(clear), pixels[10 + 10 * 12]);
6076 }
6077
6078 test "retained text run color changes damage only the styled glyph" {
6079 const allocator = std.testing.allocator;
6080 const glyphs = [_]BitmapGlyph{
6081 .{ .codepoint = 'A', .rows = &.{ 0xFF, 0xFF } },
6082 .{ .codepoint = 'B', .rows = &.{ 0xFF, 0xFF } },
6083 .{ .codepoint = 'C', .rows = &.{ 0xFF, 0xFF } },
6084 .{ .codepoint = 'D', .rows = &.{ 0xFF, 0xFF } },
6085 };
6086 var atlas = try Atlas.initFromBitmapGlyphs(
6087 allocator,
6088 &glyphs,
6089 .{ .width = 8, .height = 2, .stride = 1 },
6090 16,
6091 test_atlas_cache_limits,
6092 );
6093 defer atlas.deinit();
6094 const red = [_]UiTextRun{.{
6095 .byte_start = 1,
6096 .byte_end = 2,
6097 .foreground = .{ .r = 220, .g = 30, .b = 20 },
6098 }};
6099 const blue = [_]UiTextRun{.{
6100 .byte_start = 1,
6101 .byte_end = 2,
6102 .foreground = .{ .r = 20, .g = 80, .b = 220 },
6103 }};
6104 const nodes = [_]UiNode{
6105 .{
6106 .widget_id = 2,
6107 .kind = .label,
6108 .text = .{ .content = "ABCD", .runs = &red, .point_size = 16, .line_height = 1 },
6109 .size = .{ .width = 32, .height = 16 },
6110 },
6111 .{
6112 .widget_id = 2,
6113 .kind = .label,
6114 .text = .{ .content = "ABCD", .runs = &blue, .point_size = 16, .line_height = 1 },
6115 .size = .{ .width = 32, .height = 16 },
6116 },
6117 };
6118 const entries = [_]AtlasSet.Entry{.{ .face = 0, .image_index = 0, .atlas = &atlas }};
6119 const atlases = AtlasSet{ .entries = &entries };
6120 var frames = [_]gui.frame.Workspace{
6121 gui.frame.Workspace.init(allocator),
6122 gui.frame.Workspace.init(allocator),
6123 };
6124 defer for (&frames) |*frame| frame.deinit();
6125 var buffers = [_]command.CommandBuffer{
6126 command.CommandBuffer.init(allocator),
6127 command.CommandBuffer.init(allocator),
6128 };
6129 defer for (&buffers) |*buffer| buffer.deinit();
6130 const fragment_id = command.FragmentId{
6131 .root_id = 1,
6132 .element_id = 2,
6133 .namespace = command.fragment_namespace_widget,
6134 .part = command.fragment_part_text,
6135 };
6136 for (0..nodes.len) |index| {
6137 const surface = gui.model.UiSurfaceTree{
6138 .available_size = .{ .width = 32, .height = 16 },
6139 .root = .{ .widget_id = 1, .children = nodes[index .. index + 1] },
6140 };
6141 const frame = try frames[index].buildSurface(&surface, .{});
6142 try appendFrameCommands(&buffers[index], frame, &atlases, 1, 0);
6143 try buffers[index].commitFragment(fragment_id, 0);
6144 }
6145 var retained = gui.paint.RetainedCommands.init(allocator);
6146 defer retained.deinit();
6147 try retained.retain(&buffers[0]);
6148 const damage = try retained.diff(
6149 &buffers[1],
6150 &.{Region.full(32, 16)},
6151 32,
6152 16,
6153 );
6154 const narrowed = switch (damage) {
6155 .semantic => return error.ExpectedNarrowedDamage,
6156 .narrowed => |value| value,
6157 };
6158 try std.testing.expect(narrowed.slice().len > 0);
6159 try std.testing.expect(narrowed.slice().len <= 2);
6160 const bounds = narrowed.bounding().?;
6161 try std.testing.expect(bounds.x >= 7);
6162 try std.testing.expect(bounds.x <= 8);
6163 try std.testing.expect(bounds.width <= 10);
6164 try std.testing.expect(bounds.height <= 4);
6165 }
6166
6167 test "warmed identical styled text measure emission and retained diff need no allocation or damage" {
6168 var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{});
6169 const allocator = failing.allocator();
6170 const glyphs = [_]BitmapGlyph{
6171 .{ .codepoint = 'A', .rows = &.{ 0xFF, 0xFF } },
6172 .{ .codepoint = 'B', .rows = &.{ 0xFF, 0xFF } },
6173 };
6174 var atlas = try Atlas.initFromBitmapGlyphs(
6175 allocator,
6176 &glyphs,
6177 .{ .width = 8, .height = 2, .stride = 1 },
6178 16,
6179 test_atlas_cache_limits,
6180 );
6181 defer atlas.deinit();
6182 const runs = [_]UiTextRun{.{
6183 .byte_start = 0,
6184 .byte_end = 1,
6185 .foreground = .{ .r = 220, .g = 30, .b = 20 },
6186 .background = .{ .r = 10, .g = 20, .b = 40, .a = 160 },
6187 .underline = true,
6188 }};
6189 const child = [_]UiNode{.{
6190 .widget_id = 2,
6191 .kind = .label,
6192 .text = .{ .content = "AB", .runs = &runs, .point_size = 16, .line_height = 1 },
6193 .size = .{ .width = 16, .height = 16 },
6194 }};
6195 const surface = gui.model.UiSurfaceTree{
6196 .available_size = .{ .width = 16, .height = 16 },
6197 .root = .{ .widget_id = 1, .children = &child },
6198 };
6199 var frame_workspace = gui.frame.Workspace.init(allocator);
6200 defer frame_workspace.deinit();
6201 const frame = try frame_workspace.buildSurface(&surface, .{});
6202 var commands = command.CommandBuffer.init(allocator);
6203 defer commands.deinit();
6204 const entries = [_]AtlasSet.Entry{.{ .face = 0, .image_index = 0, .atlas = &atlas }};
6205 const atlases = AtlasSet{ .entries = &entries };
6206 const measured = try measure(&atlas, child[0].text.?);
6207 const fragment_id = command.FragmentId{
6208 .root_id = 1,
6209 .element_id = 2,
6210 .namespace = command.fragment_namespace_widget,
6211 .part = command.fragment_part_text,
6212 };
6213 try appendFrameCommands(&commands, frame, &atlases, 1, 0);
6214 try commands.commitFragment(fragment_id, 0);
6215 var retained = gui.paint.RetainedCommands.init(allocator);
6216 defer retained.deinit();
6217 try retained.retain(&commands);
6218 try appendFrameCommands(&commands, frame, &atlases, 1, 0);
6219 try commands.commitFragment(fragment_id, 0);
6220 try std.testing.expect((try retained.diff(&commands, &.{}, 16, 16)) == .semantic);
6221 try retained.retain(&commands);
6222
6223 failing.fail_index = failing.alloc_index;
6224 failing.resize_fail_index = failing.resize_index;
6225 for (0..8) |_| {
6226 try std.testing.expectEqual(measured, try measure(&atlas, child[0].text.?));
6227 try appendFrameCommands(&commands, frame, &atlases, 1, 0);
6228 try std.testing.expectEqual(@as(usize, 4), commands.items().len);
6229 try commands.commitFragment(fragment_id, 0);
6230 try std.testing.expect((try retained.diff(&commands, &.{}, 16, 16)) == .semantic);
6231 try retained.retain(&commands);
6232 }
6233 try std.testing.expect(!failing.has_induced_failure);
6234 }
6235
6236 test "warmed mixed metric measure planning emission and retained diff need no allocation" {
6237 var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{});
6238 const allocator = failing.allocator();
6239 const base_glyphs = [_]BitmapGlyph{
6240 .{ .codepoint = 'A', .rows = &.{ 0xFF, 0xFF } },
6241 .{ .codepoint = 'B', .rows = &.{ 0xFF, 0xFF } },
6242 };
6243 const large_glyphs = [_]BitmapGlyph{
6244 .{ .codepoint = 'A', .rows = &.{ 0xFF, 0xF0, 0xFF, 0xF0, 0xFF, 0xF0 } },
6245 .{ .codepoint = 'B', .rows = &.{ 0xFF, 0xF0, 0xFF, 0xF0, 0xFF, 0xF0 } },
6246 };
6247 var base_atlas = try Atlas.initFromBitmapGlyphs(
6248 allocator,
6249 &base_glyphs,
6250 .{ .width = 8, .height = 2, .stride = 1 },
6251 16,
6252 test_atlas_cache_limits,
6253 );
6254 defer base_atlas.deinit();
6255 var large_atlas = try Atlas.initFromBitmapGlyphs(
6256 allocator,
6257 &large_glyphs,
6258 .{ .width = 12, .height = 3, .stride = 2 },
6259 24,
6260 test_atlas_cache_limits,
6261 );
6262 defer large_atlas.deinit();
6263 const styles = [_]UiTextStyle{.{
6264 .font_asset_id = 2,
6265 .point_size = 24,
6266 }};
6267 const runs = [_]UiTextRun{.{
6268 .byte_start = 1,
6269 .byte_end = 2,
6270 .style_slot = 1,
6271 .foreground = .{ .r = 220, .g = 30, .b = 20 },
6272 .background = .{ .r = 10, .g = 20, .b = 40, .a = 160 },
6273 .underline = true,
6274 }};
6275 const text = UiText{
6276 .content = "AB",
6277 .runs = &runs,
6278 .styles = &styles,
6279 .font_asset_id = 1,
6280 .point_size = 16,
6281 .line_height = 1,
6282 };
6283 const child = [_]UiNode{.{
6284 .widget_id = 2,
6285 .kind = .text_input,
6286 .text = text,
6287 .size = .{ .width = 24, .height = 24 },
6288 }};
6289 const surface = gui.model.UiSurfaceTree{
6290 .available_size = .{ .width = 24, .height = 24 },
6291 .root = .{ .widget_id = 1, .children = &child },
6292 };
6293 const entries = [_]AtlasSet.Entry{
6294 .{ .face = 1, .image_index = 1, .atlas = &base_atlas },
6295 .{ .face = 2, .image_index = 2, .atlas = &large_atlas },
6296 };
6297 const atlases = AtlasSet{ .entries = &entries };
6298 var frame_workspace = gui.frame.Workspace.init(allocator);
6299 defer frame_workspace.deinit();
6300 const frame = try frame_workspace.buildSurface(&surface, .{
6301 .resolvers = frameResolvers(&atlases),
6302 });
6303 var commands = command.CommandBuffer.init(allocator);
6304 defer commands.deinit();
6305 const measured = try measureSet(&atlases, text);
6306 _ = try LinePlan.initComposite(&base_atlas, &atlases, text, text.content, 0, 0);
6307 const fragment_id = command.FragmentId{
6308 .root_id = 1,
6309 .element_id = 2,
6310 .namespace = command.fragment_namespace_widget,
6311 .part = command.fragment_part_text,
6312 };
6313 try appendFrameCommands(&commands, frame, &atlases, 1, 0);
6314 try commands.commitFragment(fragment_id, 0);
6315 var retained = gui.paint.RetainedCommands.init(allocator);
6316 defer retained.deinit();
6317 try retained.retain(&commands);
6318 try appendFrameCommands(&commands, frame, &atlases, 1, 0);
6319 try commands.commitFragment(fragment_id, 0);
6320 try std.testing.expect((try retained.diff(&commands, &.{}, 24, 24)) == .semantic);
6321 try retained.retain(&commands);
6322
6323 failing.fail_index = failing.alloc_index;
6324 failing.resize_fail_index = failing.resize_index;
6325 for (0..8) |_| {
6326 try std.testing.expectEqual(measured, try measureSet(&atlases, text));
6327 _ = try LinePlan.initComposite(&base_atlas, &atlases, text, text.content, 0, 0);
6328 try appendFrameCommands(&commands, frame, &atlases, 1, 0);
6329 try commands.commitFragment(fragment_id, 0);
6330 try std.testing.expect((try retained.diff(&commands, &.{}, 24, 24)) == .semantic);
6331 try retained.retain(&commands);
6332 }
6333 try std.testing.expect(!failing.has_induced_failure);
6334 }
6335
6336 test "measure and paint reject malformed text runs" {
6337 const allocator = std.testing.allocator;
6338 const glyphs = [_]BitmapGlyph{.{ .codepoint = 'A', .rows = &.{0xFF} }};
6339 var atlas = try Atlas.initFromBitmapGlyphs(
6340 allocator,
6341 &glyphs,
6342 .{ .width = 8, .height = 1, .stride = 1 },
6343 16,
6344 test_atlas_cache_limits,
6345 );
6346 defer atlas.deinit();
6347 const runs = [_]UiTextRun{.{ .byte_start = 0, .byte_end = 2 }};
6348 const text = UiText{ .content = "A", .runs = &runs };
6349 try std.testing.expectError(error.InvalidTextRun, measure(&atlas, text));
6350 const child = [_]UiNode{.{
6351 .widget_id = 2,
6352 .kind = .label,
6353 .text = text,
6354 .size = .{ .width = 16, .height = 16 },
6355 }};
6356 const surface = gui.model.UiSurfaceTree{
6357 .available_size = .{ .width = 16, .height = 16 },
6358 .root = .{ .widget_id = 1, .children = &child },
6359 };
6360 var frame_workspace = gui.frame.Workspace.init(allocator);
6361 defer frame_workspace.deinit();
6362 const frame = try frame_workspace.buildSurface(&surface, .{});
6363 var commands = command.CommandBuffer.init(allocator);
6364 defer commands.deinit();
6365 const entries = [_]AtlasSet.Entry{.{ .face = 0, .image_index = 0, .atlas = &atlas }};
6366 const atlases = AtlasSet{ .entries = &entries };
6367 try std.testing.expectError(error.InvalidTextRun, appendFrameCommands(&commands, frame, &atlases, 1, 0));
6368 try std.testing.expectEqual(@as(usize, 0), commands.items().len);
6369 }
6370
6371 test "appendFrameCommands draws cursor for empty text input" {
6372 const allocator = std.testing.allocator;
6373 const glyphs = [_]BitmapGlyph{
6374 .{ .codepoint = 'A', .rows = &.{ 0xFF, 0xFF } },
6375 };
6376 var atlas = try Atlas.initFromBitmapGlyphs(
6377 allocator,
6378 glyphs[0..],
6379 .{ .width = 8, .height = 2, .stride = 1 },
6380 16,
6381 test_atlas_cache_limits,
6382 );
6383 defer atlas.deinit();
6384 const child = [_]gui.model.UiNode{.{
6385 .widget_id = 2,
6386 .kind = .text_input,
6387 .text = .{ .content = "", .point_size = 16, .line_height = 1.0 },
6388 .text_selection = .{
6389 .cursor_visible = true,
6390 .cursor_byte_offset = 0,
6391 },
6392 .paint = .{ .foreground = .{ .r = 12, .g = 24, .b = 36, .a = 255 } },
6393 .size = .{ .width = 64, .height = 20 },
6394 }};
6395 const surface = gui.model.UiSurfaceTree{
6396 .available_size = .{ .width = 80, .height = 32 },
6397 .root = .{
6398 .widget_id = 1,
6399 .children = child[0..],
6400 },
6401 };
6402 var frame_workspace = gui.frame.Workspace.init(allocator);
6403 defer frame_workspace.deinit();
6404 const frame = try frame_workspace.buildSurface(&surface, .{});
6405 var commands = command.CommandBuffer.init(allocator);
6406 defer commands.deinit();
6407
6408 const entries = [_]AtlasSet.Entry{.{ .face = 0, .image_index = 0, .atlas = &atlas }};
6409 const atlases = AtlasSet{ .entries = entries[0..] };
6410 try appendFrameCommands(&commands, frame, &atlases, 1, 0);
6411
6412 try std.testing.expectEqual(@as(usize, 1), commands.items().len);
6413 try std.testing.expectEqual(command.Kind.fill, commands.items()[0].kind);
6414 try std.testing.expectApproxEqAbs(@as(f32, 0), commands.items()[0].rect.x, 0.001);
6415 try std.testing.expectApproxEqAbs(@as(f32, 1), commands.items()[0].rect.width, 0.001);
6416 try std.testing.expect(commands.items()[0].rect.height > 0);
6417 }
6418
6419 test "shape cache reuses stored runs and survives eviction" {
6420 const allocator = std.testing.allocator;
6421 const limits = AtlasCacheStorage.Limits{
6422 .measure_entries = 0,
6423 .measure_payload_bytes = 0,
6424 .shape_entries = 4,
6425 .shape_payload_bytes = 64 * 1024,
6426 };
6427 const glyphs = [_]BitmapGlyph{
6428 .{ .codepoint = 'a', .rows = &.{ 0xFF, 0xFF } },
6429 .{ .codepoint = 'b', .rows = &.{ 0x0F, 0xF0 } },
6430 };
6431 var atlas = try Atlas.initFromBitmapGlyphs(
6432 allocator,
6433 glyphs[0..],
6434 .{ .width = 8, .height = 2, .stride = 1 },
6435 16,
6436 limits,
6437 );
6438 defer atlas.deinit();
6439
6440 const first = try atlas.shape("ab");
6441 try std.testing.expectEqual(@as(usize, 2), first.glyphs.len);
6442 const second = try atlas.shape("ab");
6443 try std.testing.expectEqual(first.glyphs.ptr, second.glyphs.ptr);
6444 try std.testing.expectEqual(first.total_x_advance, second.total_x_advance);
6445
6446 var name_buffer: [32]u8 = undefined;
6447 for (0..limits.shape_entries) |index| {
6448 const name = try std.fmt.bufPrint(name_buffer[0..], "evict {d}", .{index});
6449 _ = try atlas.shape(name);
6450 }
6451 const evicted = try atlas.shape("ab");
6452 try std.testing.expectEqual(@as(usize, 2), evicted.glyphs.len);
6453 try std.testing.expectEqual(first.total_x_advance, evicted.total_x_advance);
6454 try std.testing.expectEqual(@as(u32, 'a'), evicted.glyphs[0].glyph_id);
6455 try std.testing.expectEqual(@as(u32, 'b'), evicted.glyphs[1].glyph_id);
6456 try std.testing.expectEqual(@as(u64, 1), atlas.cacheStatus().shape.rollovers);
6457 }
6458
6459 test "Atlas cache capacity matches an independent aligned byte model" {
6460 comptime {
6461 @stardustClaim(
6462 @import("alloc_phase").capacity.witness(AtlasCacheStorage, "gui_text_atlas_cache_capacity"),
6463 null,
6464 null,
6465 null,
6466 null,
6467 null,
6468 null,
6469 );
6470 }
6471
6472 const cases = [_]AtlasCacheStorage.Limits{
6473 .{
6474 .measure_entries = 0,
6475 .measure_payload_bytes = 0,
6476 .shape_entries = 0,
6477 .shape_payload_bytes = 0,
6478 },
6479 .{
6480 .measure_entries = 1,
6481 .measure_payload_bytes = 1,
6482 .shape_entries = 1,
6483 .shape_payload_bytes = 1,
6484 },
6485 test_atlas_cache_limits,
6486 .{
6487 .measure_entries = std.math.maxInt(u32),
6488 .measure_payload_bytes = 1,
6489 .shape_entries = 1,
6490 .shape_payload_bytes = 1,
6491 },
6492 };
6493 for (cases) |limits| {
6494 try std.testing.expectEqual(
6495 try modelAtlasCacheCapacity(limits),
6496 try AtlasCacheStorage.Capacity.derive(limits),
6497 );
6498 }
6499 try expectAtlasCacheCapacityErrors();
6500 }
6501
6502 test "Atlas cache storage acquires one exact aligned region" {
6503 comptime {
6504 @stardustClaim(
6505 @import("alloc_phase").capacity.witness(AtlasCacheStorage, "gui_text_atlas_cache_acquisition"),
6506 null,
6507 null,
6508 null,
6509 null,
6510 null,
6511 null,
6512 );
6513 }
6514
6515 var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{});
6516 const capacity = try AtlasCacheStorage.Capacity.derive(test_atlas_cache_limits);
6517 var storage = try AtlasCacheStorage.init(failing.allocator(), test_atlas_cache_limits);
6518 defer storage.deinit(failing.allocator());
6519
6520 try std.testing.expectEqual(@as(usize, 1), failing.alloc_index);
6521 try std.testing.expectEqual(capacity.storage_bytes, failing.allocated_bytes);
6522 try std.testing.expectEqual(alloc_phase.capacity.Phase.initialization, storage.status().phase);
6523 try expectAtlasCacheRegionAddresses(&storage);
6524 storage.activate();
6525 try std.testing.expectEqual(alloc_phase.capacity.Phase.steady, storage.status().phase);
6526 }
6527
6528 test "Atlas cache storage retries after every allocation failure" {
6529 comptime {
6530 @stardustClaim(
6531 @import("alloc_phase").capacity.witness(AtlasCacheStorage, "gui_text_atlas_cache_oom"),
6532 null,
6533 null,
6534 null,
6535 null,
6536 null,
6537 null,
6538 );
6539 }
6540
6541 try std.testing.checkAllAllocationFailures(
6542 std.testing.allocator,
6543 checkAtlasCacheInitFailures,
6544 .{},
6545 );
6546 }
6547
6548 test "Atlas cache storage preserves exact max and max plus one overload behavior" {
6549 comptime {
6550 @stardustClaim(
6551 @import("alloc_phase").capacity.witness(AtlasCacheStorage, "gui_text_atlas_cache_boundaries"),
6552 null,
6553 null,
6554 null,
6555 null,
6556 null,
6557 null,
6558 );
6559 }
6560
6561 try expectShapeCacheBoundaries();
6562 try expectMeasureCacheBoundaries();
6563 try expectDisabledAtlasCaches();
6564 }
6565
6566 test "Activated Atlas cache operations make no backing allocation from cold" {
6567 comptime {
6568 @stardustClaim(
6569 @import("alloc_phase").capacity.witness(AtlasCacheStorage, "gui_text_atlas_cache_sealed_transitive_risk"),
6570 null,
6571 null,
6572 null,
6573 null,
6574 null,
6575 null,
6576 );
6577 }
6578 comptime {
6579 @stardustClaim(
6580 @import("alloc_phase").capacity.witness(AtlasCacheStorage, "gui_text_atlas_cache_sealed_foreign_risk"),
6581 null,
6582 null,
6583 null,
6584 null,
6585 null,
6586 null,
6587 );
6588 }
6589
6590 var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{});
6591 const limits = AtlasCacheStorage.Limits{
6592 .measure_entries = 2,
6593 .measure_payload_bytes = 16,
6594 .shape_entries = 2,
6595 .shape_payload_bytes = 16,
6596 };
6597 var storage = try AtlasCacheStorage.init(failing.allocator(), limits);
6598 defer storage.deinit(failing.allocator());
6599 storage.activate();
6600 const allocations = failing.allocations;
6601 const deallocations = failing.deallocations;
6602 const allocated_bytes = failing.allocated_bytes;
6603 const freed_bytes = failing.freed_bytes;
6604 const resize_index = failing.resize_index;
6605 failing.fail_index = failing.alloc_index;
6606 failing.resize_fail_index = failing.resize_index;
6607
6608 try exerciseColdAtlasCache(&storage);
6609 try std.testing.expectEqual(allocations, failing.allocations);
6610 try std.testing.expectEqual(deallocations, failing.deallocations);
6611 try std.testing.expectEqual(allocated_bytes, failing.allocated_bytes);
6612 try std.testing.expectEqual(freed_bytes, failing.freed_bytes);
6613 try std.testing.expectEqual(resize_index, failing.resize_index);
6614 }
6615
6616 test "Atlas cache bounded probes preserve colliding shape keys" {
6617 comptime {
6618 @stardustClaim(
6619 @import("alloc_phase").capacity.witness(AtlasCacheStorage, "gui_text_atlas_cache_collisions"),
6620 null,
6621 null,
6622 null,
6623 null,
6624 null,
6625 null,
6626 );
6627 }
6628
6629 const limits = AtlasCacheStorage.Limits{
6630 .measure_entries = 0,
6631 .measure_payload_bytes = 0,
6632 .shape_entries = 2,
6633 .shape_payload_bytes = 32,
6634 };
6635 var storage = try AtlasCacheStorage.init(std.testing.allocator, limits);
6636 defer storage.deinit(std.testing.allocator);
6637 storage.activate();
6638 const keys = try collidingShapeKeys(storage.capacity.shape_index_slots);
6639 _ = try storage.storeShape(keys.first, testShapedRun(11));
6640 _ = try storage.storeShape(keys.second, testShapedRun(22));
6641 try std.testing.expectEqual(@as(i32, 11), storage.lookupShape(keys.first).?.total_x_advance);
6642 try std.testing.expectEqual(@as(i32, 22), storage.lookupShape(keys.second).?.total_x_advance);
6643 }
6644
6645 test "Atlas cache storage keeps region pointers and capacity stable" {
6646 comptime {
6647 @stardustClaim(
6648 @import("alloc_phase").capacity.witness(AtlasCacheStorage, "gui_text_atlas_cache_stability"),
6649 null,
6650 null,
6651 null,
6652 null,
6653 null,
6654 null,
6655 );
6656 }
6657
6658 var storage = try AtlasCacheStorage.init(std.testing.allocator, .{
6659 .measure_entries = 2,
6660 .measure_payload_bytes = 16,
6661 .shape_entries = 2,
6662 .shape_payload_bytes = 16,
6663 });
6664 defer storage.deinit(std.testing.allocator);
6665 const capacity = storage.capacity;
6666 const pointers = atlasCachePointers(storage);
6667 storage.activate();
6668 var iteration: usize = 0;
6669 while (iteration < 32) : (iteration += 1) {
6670 const key = if (iteration % 2 == 0) "aa" else "bb";
6671 _ = storage.storeShape(key, testShapedRun(@intCast(iteration))) catch {};
6672 storage.replaceShape();
6673 }
6674 try std.testing.expectEqual(capacity, storage.capacity);
6675 try std.testing.expectEqual(pointers, atlasCachePointers(storage));
6676 }
6677
6678 test "Atlas cache shape operations match a whole epoch reference model" {
6679 comptime {
6680 @stardustClaim(
6681 @import("alloc_phase").capacity.witness(AtlasCacheStorage, "gui_text_atlas_cache_differential"),
6682 null,
6683 null,
6684 null,
6685 null,
6686 null,
6687 null,
6688 );
6689 }
6690
6691 try expectAtlasCacheDifferential();
6692 }
6693
6694 test "Atlas cache bypasses oversized entries without changing either epoch" {
6695 const glyphs = [_]BitmapGlyph{
6696 .{ .codepoint = 'a', .rows = &.{ 0xFF, 0xFF } },
6697 };
6698 const limits = AtlasCacheStorage.Limits{
6699 .measure_entries = 2,
6700 .measure_payload_bytes = 128,
6701 .shape_entries = 2,
6702 .shape_payload_bytes = 128,
6703 };
6704 var atlas = try Atlas.initFromBitmapGlyphs(
6705 std.testing.allocator,
6706 glyphs[0..],
6707 .{ .width = 8, .height = 2, .stride = 1 },
6708 16,
6709 limits,
6710 );
6711 defer atlas.deinit();
6712 const retained_run = try atlas.shape("a");
6713 const retained_measure = try measure(&atlas, cacheEpochText("a"));
6714 const before = atlas.cacheStatus();
6715 const content: [129]u8 = @splat('x');
6716
6717 const first_run = try atlas.shape(&content);
6718 const second_run = try atlas.shape(&content);
6719 try expectGlyphRunEqual(first_run, second_run);
6720 const first_measure = try measure(&atlas, cacheEpochText(&content));
6721 const second_measure = try measure(&atlas, cacheEpochText(&content));
6722 try std.testing.expectEqual(first_measure, second_measure);
6723 const after = atlas.cacheStatus();
6724 try std.testing.expectEqual(before.shape.entries, after.shape.entries);
6725 try std.testing.expectEqual(before.shape.payload_bytes, after.shape.payload_bytes);
6726 try std.testing.expectEqual(before.shape.rollovers, after.shape.rollovers);
6727 try std.testing.expectEqual(before.shape.oversize_bypasses + 4, after.shape.oversize_bypasses);
6728 try std.testing.expectEqual(before.measure.entries, after.measure.entries);
6729 try std.testing.expectEqual(before.measure.payload_bytes, after.measure.payload_bytes);
6730 try std.testing.expectEqual(before.measure.rollovers, after.measure.rollovers);
6731 try std.testing.expectEqual(before.measure.oversize_bypasses + 2, after.measure.oversize_bypasses);
6732 const retained_again = try atlas.shape("a");
6733 try std.testing.expectEqual(retained_run.glyphs.ptr, retained_again.glyphs.ptr);
6734 try std.testing.expectEqual(retained_measure, try measure(&atlas, cacheEpochText("a")));
6735 }
6736
6737 fn expectAtlasCacheCapacityErrors() !void {
6738 try std.testing.expectError(error.InvalidMeasureLimits, AtlasCacheStorage.Capacity.derive(.{
6739 .measure_entries = 1,
6740 .measure_payload_bytes = 0,
6741 .shape_entries = 0,
6742 .shape_payload_bytes = 0,
6743 }));
6744 try std.testing.expectError(error.InvalidShapeLimits, AtlasCacheStorage.Capacity.derive(.{
6745 .measure_entries = 0,
6746 .measure_payload_bytes = 0,
6747 .shape_entries = 0,
6748 .shape_payload_bytes = 1,
6749 }));
6750 try std.testing.expectError(error.CapacityOverflow, AtlasCacheStorage.Capacity.derive(.{
6751 .measure_entries = 1,
6752 .measure_payload_bytes = std.math.maxInt(usize),
6753 .shape_entries = 0,
6754 .shape_payload_bytes = 0,
6755 }));
6756 if (@bitSizeOf(usize) > @bitSizeOf(u32)) {
6757 try std.testing.expectError(error.EntryLimitTooLarge, AtlasCacheStorage.Capacity.derive(.{
6758 .measure_entries = @as(usize, std.math.maxInt(u32)) + 1,
6759 .measure_payload_bytes = 1,
6760 .shape_entries = 0,
6761 .shape_payload_bytes = 0,
6762 }));
6763 }
6764 }
6765
6766 fn expectAtlasCacheRegionAddresses(storage: *const AtlasCacheStorage) !void {
6767 const base = @intFromPtr(storage.bytes.ptr);
6768 const capacity = storage.capacity;
6769 try std.testing.expectEqual(
6770 base + capacity.measure_index_offset,
6771 @intFromPtr(storage.measure_slots.ptr),
6772 );
6773 try std.testing.expectEqual(
6774 base + capacity.measure_entries_offset,
6775 @intFromPtr(storage.measure_entries.ptr),
6776 );
6777 try std.testing.expectEqual(
6778 base + capacity.measure_payload_offset,
6779 @intFromPtr(storage.measure_payload.bytes.ptr),
6780 );
6781 try std.testing.expectEqual(
6782 base + capacity.shape_index_offset,
6783 @intFromPtr(storage.shape_slots.ptr),
6784 );
6785 try std.testing.expectEqual(
6786 base + capacity.shape_entries_offset,
6787 @intFromPtr(storage.shape_entries.ptr),
6788 );
6789 try std.testing.expectEqual(
6790 base + capacity.shape_payload_offset,
6791 @intFromPtr(storage.shape_payload.bytes.ptr),
6792 );
6793 }
6794
6795 fn checkAtlasCacheInitFailures(allocator: Allocator) !void {
6796 var storage = try AtlasCacheStorage.init(allocator, .{
6797 .measure_entries = 3,
6798 .measure_payload_bytes = 31,
6799 .shape_entries = 5,
6800 .shape_payload_bytes = 47,
6801 });
6802 storage.deinit(allocator);
6803 }
6804
6805 fn expectShapeCacheBoundaries() !void {
6806 var storage = try AtlasCacheStorage.init(std.testing.allocator, .{
6807 .measure_entries = 0,
6808 .measure_payload_bytes = 0,
6809 .shape_entries = 2,
6810 .shape_payload_bytes = 64,
6811 });
6812 defer storage.deinit(std.testing.allocator);
6813 storage.activate();
6814 _ = try storage.storeShape("a", testShapedRun(1));
6815 _ = try storage.storeShape("b", testShapedRun(2));
6816 try std.testing.expectEqual(@as(usize, 2), storage.status().shape.entries);
6817 _ = try storage.storeShape("c", testShapedRun(3));
6818 try std.testing.expectEqual(@as(usize, 1), storage.status().shape.entries);
6819 try std.testing.expectEqual(@as(u64, 1), storage.status().shape.rollovers);
6820 try std.testing.expect(storage.lookupShape("a") == null);
6821 try std.testing.expectEqual(@as(i32, 3), storage.lookupShape("c").?.total_x_advance);
6822
6823 var payload = try shapePayloadBoundaryStorage();
6824 defer payload.deinit(std.testing.allocator);
6825 try payload.admitShapePayload(3);
6826 try std.testing.expectError(error.EntryTooLarge, payload.admitShapePayload(4));
6827 _ = try payload.storeShape("abc", testShapedRun(4));
6828 const before = payload.status();
6829 try std.testing.expectError(
6830 error.EntryTooLarge,
6831 payload.storeShape("abcd", testShapedRun(5)),
6832 );
6833 const after = payload.status();
6834 try expectEpochUnchangedExceptOversize(before.shape, after.shape);
6835 }
6836
6837 fn shapePayloadBoundaryStorage() !AtlasCacheStorage {
6838 var storage = try AtlasCacheStorage.init(std.testing.allocator, .{
6839 .measure_entries = 0,
6840 .measure_payload_bytes = 0,
6841 .shape_entries = 2,
6842 .shape_payload_bytes = 3,
6843 });
6844 storage.activate();
6845 return storage;
6846 }
6847
6848 fn expectMeasureCacheBoundaries() !void {
6849 var storage = try AtlasCacheStorage.init(std.testing.allocator, .{
6850 .measure_entries = 2,
6851 .measure_payload_bytes = 3,
6852 .shape_entries = 0,
6853 .shape_payload_bytes = 0,
6854 });
6855 defer storage.deinit(std.testing.allocator);
6856 storage.activate();
6857 _ = try storage.storeMeasure(testMeasureKey("a"), .{ .width = 1 });
6858 _ = try storage.storeMeasure(testMeasureKey("b"), .{ .width = 2 });
6859 try std.testing.expectEqual(@as(usize, 2), storage.status().measure.entries);
6860 _ = try storage.storeMeasure(testMeasureKey("c"), .{ .width = 3 });
6861 try std.testing.expectEqual(@as(usize, 1), storage.status().measure.entries);
6862 try std.testing.expectEqual(@as(u64, 1), storage.status().measure.rollovers);
6863 try std.testing.expect(storage.lookupMeasure(testMeasureKey("a")) == null);
6864 const before = storage.status();
6865 try std.testing.expectError(
6866 error.EntryTooLarge,
6867 storage.storeMeasure(testMeasureKey("abcd"), .{ .width = 4 }),
6868 );
6869 try expectEpochUnchangedExceptOversize(before.measure, storage.status().measure);
6870 }
6871
6872 fn expectEpochUnchangedExceptOversize(
6873 before: AtlasCacheStorage.EpochStatus,
6874 after: AtlasCacheStorage.EpochStatus,
6875 ) !void {
6876 try std.testing.expectEqual(before.entries, after.entries);
6877 try std.testing.expectEqual(before.payload_bytes, after.payload_bytes);
6878 try std.testing.expectEqual(before.physical_payload_bytes, after.physical_payload_bytes);
6879 try std.testing.expectEqual(before.rollovers, after.rollovers);
6880 try std.testing.expectEqual(before.disabled_bypasses, after.disabled_bypasses);
6881 try std.testing.expectEqual(before.oversize_bypasses + 1, after.oversize_bypasses);
6882 }
6883
6884 fn expectDisabledAtlasCaches() !void {
6885 var storage = try AtlasCacheStorage.init(std.testing.allocator, .{
6886 .measure_entries = 0,
6887 .measure_payload_bytes = 0,
6888 .shape_entries = 0,
6889 .shape_payload_bytes = 0,
6890 });
6891 defer storage.deinit(std.testing.allocator);
6892 storage.activate();
6893 try std.testing.expectError(error.CacheDisabled, storage.admitShapePayload(0));
6894 try std.testing.expectError(
6895 error.CacheDisabled,
6896 storage.storeShape("a", testShapedRun(1)),
6897 );
6898 try std.testing.expectError(
6899 error.CacheDisabled,
6900 storage.storeMeasure(testMeasureKey("a"), .{}),
6901 );
6902 const status = storage.status();
6903 try std.testing.expectEqual(@as(usize, 0), status.storage_bytes);
6904 try std.testing.expectEqual(@as(u64, 1), status.shape.disabled_bypasses);
6905 try std.testing.expectEqual(@as(u64, 1), status.measure.disabled_bypasses);
6906 }
6907
6908 fn exerciseColdAtlasCache(storage: *AtlasCacheStorage) !void {
6909 _ = try storage.storeShape("a", testShapedRun(1));
6910 _ = try storage.storeShape("b", testShapedRun(2));
6911 _ = try storage.storeShape("c", testShapedRun(3));
6912 try std.testing.expectEqual(@as(i32, 3), storage.lookupShape("c").?.total_x_advance);
6913 _ = try storage.storeMeasure(testMeasureKey("a"), .{ .width = 1 });
6914 _ = try storage.storeMeasure(testMeasureKey("b"), .{ .width = 2 });
6915 _ = try storage.storeMeasure(testMeasureKey("c"), .{ .width = 3 });
6916 try std.testing.expectEqual(@as(f32, 3), storage.lookupMeasure(testMeasureKey("c")).?.width);
6917 try std.testing.expectEqual(@as(u64, 1), storage.status().shape.rollovers);
6918 try std.testing.expectEqual(@as(u64, 1), storage.status().measure.rollovers);
6919 }
6920
6921 const CollidingShapeKeys = struct {
6922 first: []const u8,
6923 second: []const u8,
6924 };
6925
6926 fn collidingShapeKeys(slot_count: usize) !CollidingShapeKeys {
6927 const candidates = [_][]const u8{ "a", "b", "c", "d", "e", "f", "g", "h" };
6928 for (candidates, 0..) |first, first_index| {
6929 for (candidates[first_index + 1 ..]) |second| {
6930 const first_slot = cacheStartSlot(std.hash_map.hashString(first), slot_count);
6931 const second_slot = cacheStartSlot(std.hash_map.hashString(second), slot_count);
6932 if (first_slot == second_slot) return .{ .first = first, .second = second };
6933 }
6934 }
6935 return error.NoCollision;
6936 }
6937
6938 const AtlasCachePointers = struct {
6939 bytes: usize,
6940 measure_slots: usize,
6941 measure_entries: usize,
6942 measure_payload: usize,
6943 shape_slots: usize,
6944 shape_entries: usize,
6945 shape_payload: usize,
6946 };
6947
6948 fn atlasCachePointers(storage: AtlasCacheStorage) AtlasCachePointers {
6949 return .{
6950 .bytes = @intFromPtr(storage.bytes.ptr),
6951 .measure_slots = @intFromPtr(storage.measure_slots.ptr),
6952 .measure_entries = @intFromPtr(storage.measure_entries.ptr),
6953 .measure_payload = @intFromPtr(storage.measure_payload.bytes.ptr),
6954 .shape_slots = @intFromPtr(storage.shape_slots.ptr),
6955 .shape_entries = @intFromPtr(storage.shape_entries.ptr),
6956 .shape_payload = @intFromPtr(storage.shape_payload.bytes.ptr),
6957 };
6958 }
6959
6960 fn testShapedRun(advance: i32) filigree.GlyphRun {
6961 return .{
6962 .glyphs = &.{},
6963 .clusters = &.{},
6964 .ligature_carets = &.{},
6965 .total_x_advance = advance,
6966 .total_y_advance = 0,
6967 .direction = .ltr,
6968 .writing_mode = .horizontal,
6969 .output_order = .visual,
6970 };
6971 }
6972
6973 fn testMeasureKey(content: []const u8) MeasureKey {
6974 return .{
6975 .content = content,
6976 .styles = &.{},
6977 .runs = &.{},
6978 .atlas_entries = &.{},
6979 .fallback_entries = &.{},
6980 .fallback_identity = 0,
6981 .font_asset_id = 0,
6982 .point_size_bits = 0,
6983 .line_height_bits = 0,
6984 .wrap_width_bits = 0,
6985 .device_scale_bits = 0,
6986 };
6987 }
6988
6989 const ShapeCacheReference = struct {
6990 limits: AtlasCacheStorage.Limits,
6991 keys: [3]?[]const u8 = @splat(null),
6992 values: [3]i32 = @splat(0),
6993 entries: usize = 0,
6994 payload_bytes: usize = 0,
6995 rollovers: u64 = 0,
6996 oversize_bypasses: u64 = 0,
6997
6998 fn lookup(self: ShapeCacheReference, content: []const u8) ?i32 {
6999 for (self.keys[0..self.entries], self.values[0..self.entries]) |key, value| {
7000 if (std.mem.eql(u8, key.?, content)) return value;
7001 }
7002 return null;
7003 }
7004
7005 fn store(self: *ShapeCacheReference, content: []const u8, value: i32) bool {
7006 if (content.len > self.limits.shape_payload_bytes) {
7007 self.oversize_bypasses +|= 1;
7008 return false;
7009 }
7010 const payload_full = content.len >
7011 self.limits.shape_payload_bytes - self.payload_bytes;
7012 if (self.entries == self.limits.shape_entries or payload_full) {
7013 self.entries = 0;
7014 self.payload_bytes = 0;
7015 self.rollovers +|= 1;
7016 }
7017 self.keys[self.entries] = content;
7018 self.values[self.entries] = value;
7019 self.entries += 1;
7020 self.payload_bytes += content.len;
7021 return true;
7022 }
7023 };
7024
7025 fn expectAtlasCacheDifferential() !void {
7026 const limits = AtlasCacheStorage.Limits{
7027 .measure_entries = 0,
7028 .measure_payload_bytes = 0,
7029 .shape_entries = 3,
7030 .shape_payload_bytes = 8,
7031 };
7032 var storage = try AtlasCacheStorage.init(std.testing.allocator, limits);
7033 defer storage.deinit(std.testing.allocator);
7034 storage.activate();
7035 var reference = ShapeCacheReference{ .limits = limits };
7036 const keys = [_][]const u8{ "a", "bb", "ccc", "dddd", "012345678" };
7037 var state: u32 = 0x9E37_79B9;
7038 var step: usize = 0;
7039 while (step < 512) : (step += 1) {
7040 state = state *% 1_664_525 +% 1_013_904_223;
7041 const content = keys[state % keys.len];
7042 try compareAtlasCacheLookup(&storage, reference, content);
7043 if (reference.lookup(content) == null) {
7044 const value: i32 = @intCast(step);
7045 const admitted = reference.store(content, value);
7046 if (admitted) {
7047 _ = try storage.storeShape(content, testShapedRun(value));
7048 } else {
7049 try std.testing.expectError(
7050 error.EntryTooLarge,
7051 storage.storeShape(content, testShapedRun(value)),
7052 );
7053 }
7054 }
7055 try compareAtlasCacheStatus(storage.status().shape, reference);
7056 }
7057 }
7058
7059 fn compareAtlasCacheLookup(
7060 storage: *const AtlasCacheStorage,
7061 reference: ShapeCacheReference,
7062 content: []const u8,
7063 ) !void {
7064 const expected = reference.lookup(content);
7065 const actual = storage.lookupShape(content);
7066 try std.testing.expectEqual(expected != null, actual != null);
7067 if (expected) |value| try std.testing.expectEqual(value, actual.?.total_x_advance);
7068 }
7069
7070 fn compareAtlasCacheStatus(
7071 actual: AtlasCacheStorage.EpochStatus,
7072 expected: ShapeCacheReference,
7073 ) !void {
7074 try std.testing.expectEqual(expected.entries, actual.entries);
7075 try std.testing.expectEqual(expected.payload_bytes, actual.payload_bytes);
7076 try std.testing.expectEqual(expected.rollovers, actual.rollovers);
7077 try std.testing.expectEqual(expected.oversize_bypasses, actual.oversize_bypasses);
7078 }
7079
7080 fn cacheEpochText(content: []const u8) UiText {
7081 return .{ .content = content, .point_size = 16, .line_height = 1 };
7082 }
7083
7084 fn expectGlyphRunEqual(expected: filigree.GlyphRun, actual: filigree.GlyphRun) !void {
7085 try std.testing.expectEqualSlices(filigree.ShapedGlyph, expected.glyphs, actual.glyphs);
7086 try std.testing.expectEqualSlices(filigree.Cluster, expected.clusters, actual.clusters);
7087 try std.testing.expectEqualSlices(filigree.LigatureCaret, expected.ligature_carets, actual.ligature_carets);
7088 try std.testing.expectEqual(expected.total_x_advance, actual.total_x_advance);
7089 try std.testing.expectEqual(expected.total_y_advance, actual.total_y_advance);
7090 try std.testing.expectEqual(expected.direction, actual.direction);
7091 try std.testing.expectEqual(expected.writing_mode, actual.writing_mode);
7092 try std.testing.expectEqual(expected.output_order, actual.output_order);
7093 }
7094
7095 test "bitmap atlas skips glyphs missing from the map without question mark" {
7096 const allocator = std.testing.allocator;
7097 const glyphs = [_]BitmapGlyph{
7098 .{ .codepoint = 'x', .rows = &.{ 0xFF, 0xFF } },
7099 };
7100 var atlas = try Atlas.initFromBitmapGlyphs(
7101 allocator,
7102 glyphs[0..],
7103 .{ .width = 8, .height = 2, .stride = 1 },
7104 16,
7105 test_atlas_cache_limits,
7106 );
7107 defer atlas.deinit();
7108
7109 const run = try atlas.shape("xy");
7110 try std.testing.expectEqual(@as(usize, 2), run.glyphs.len);
7111 try std.testing.expectEqual(@as(u32, 'x'), run.glyphs[0].glyph_id);
7112 try std.testing.expectEqual(std.math.maxInt(u32), run.glyphs[1].glyph_id);
7113
7114 var recorder = TestRecorder{};
7115 drawGlyphRun(&recorder, 0, atlas.atlas, run, 0, 0, .{ .a = 255 });
7116 try std.testing.expectEqual(@as(usize, 1), recorder.glyph_count);
7117 }
7118
7119 test "Atlas shapes fixture bytes and appends frame glyph commands" {
7120 const allocator = std.testing.allocator;
7121 var scratch = try AtlasScratch.init(allocator, .{ .bytes = 1024 * 1024 });
7122 defer scratch.deinit(allocator);
7123 const bytes = try filigree.fixtures.createWithOutlines(allocator);
7124 var atlas = try Atlas.initFromOwnedBytes(
7125 allocator,
7126 &scratch,
7127 bytes,
7128 18,
7129 test_atlas_cache_limits,
7130 test_output_limits,
7131 );
7132 defer atlas.deinit();
7133 const child = [_]gui.model.UiNode{.{
7134 .widget_id = 2,
7135 .kind = .label,
7136 .text = .{ .content = "AB" },
7137 .paint = .{ .foreground = .{ .r = 12, .g = 24, .b = 36, .a = 255 } },
7138 .size = .{ .width = 32, .height = 18 },
7139 }};
7140 const surface = gui.model.UiSurfaceTree{
7141 .available_size = .{ .width = 48, .height = 24 },
7142 .root = .{
7143 .widget_id = 1,
7144 .children = child[0..],
7145 },
7146 };
7147 var frame_workspace = gui.frame.Workspace.init(allocator);
7148 defer frame_workspace.deinit();
7149 const frame = try frame_workspace.buildSurface(&surface, .{});
7150 var commands = command.CommandBuffer.init(allocator);
7151 defer commands.deinit();
7152
7153 const entries = [_]AtlasSet.Entry{.{ .face = 0, .image_index = 0, .atlas = &atlas }};
7154 const atlases = AtlasSet{ .entries = entries[0..] };
7155 try appendFrameCommands(&commands, frame, &atlases, 1, 0);
7156
7157 try std.testing.expect(commands.items().len > 0);
7158 try std.testing.expectEqual(command.Kind.glyph, commands.items()[0].kind);
7159 try std.testing.expectEqual(@as(u32, 0), commands.items()[0].image_index);
7160 try std.testing.expectEqual(@as(u8, 255), commands.items()[0].color.a);
7161 try std.testing.expect(atlas.image.image.width > 0);
7162 }
7163
7164 test "missing terminal glyphs synthesize fills instead of blank advances" {
7165 const allocator = std.testing.allocator;
7166 var scratch = try AtlasScratch.init(allocator, .{ .bytes = 1024 * 1024 });
7167 defer scratch.deinit(allocator);
7168 const bytes = try filigree.fixtures.createWithOutlines(allocator);
7169 var atlas = try Atlas.initFromOwnedBytes(
7170 allocator,
7171 &scratch,
7172 bytes,
7173 18,
7174 test_atlas_cache_limits,
7175 test_output_limits,
7176 );
7177 defer atlas.deinit();
7178 const child = [_]gui.model.UiNode{.{
7179 .widget_id = 2,
7180 .kind = .label,
7181 .text = .{ .content = "\u{258F}\u{280B}\u{276F}" },
7182 .paint = .{ .foreground = .{ .r = 251, .g = 73, .b = 52, .a = 255 } },
7183 .size = .{ .width = 48, .height = 24 },
7184 }};
7185 const surface = gui.model.UiSurfaceTree{
7186 .available_size = .{ .width = 64, .height = 32 },
7187 .root = .{
7188 .widget_id = 1,
7189 .children = child[0..],
7190 },
7191 };
7192 var frame_workspace = gui.frame.Workspace.init(allocator);
7193 defer frame_workspace.deinit();
7194 const frame = try frame_workspace.buildSurface(&surface, .{});
7195 var commands = command.CommandBuffer.init(allocator);
7196 defer commands.deinit();
7197
7198 const entries = [_]AtlasSet.Entry{.{ .face = 0, .image_index = 0, .atlas = &atlas }};
7199 const atlases = AtlasSet{ .entries = entries[0..] };
7200 try appendFrameCommands(&commands, frame, &atlases, 1, 0);
7201
7202 var fills: usize = 0;
7203 for (commands.items()) |item| {
7204 if (item.kind == .fill) fills += 1;
7205 }
7206 try std.testing.expect(fills >= 5);
7207 for (commands.items()) |item| {
7208 try std.testing.expectEqual(@as(u8, 251), item.color.r);
7209 }
7210 }
7211
7212 test "atlas set picks matching face and nearest downscale size" {
7213 const allocator = std.testing.allocator;
7214 const glyphs = [_]BitmapGlyph{.{ .codepoint = 'A', .rows = &.{ 0xFF, 0xFF } }};
7215 var small = try Atlas.initFromBitmapGlyphs(
7216 allocator,
7217 glyphs[0..],
7218 .{ .width = 8, .height = 2, .stride = 1 },
7219 13,
7220 test_atlas_cache_limits,
7221 );
7222 defer small.deinit();
7223 var body = try Atlas.initFromBitmapGlyphs(
7224 allocator,
7225 glyphs[0..],
7226 .{ .width = 8, .height = 2, .stride = 1 },
7227 16,
7228 test_atlas_cache_limits,
7229 );
7230 defer body.deinit();
7231 var mono = try Atlas.initFromBitmapGlyphs(
7232 allocator,
7233 glyphs[0..],
7234 .{ .width = 8, .height = 2, .stride = 1 },
7235 15,
7236 test_atlas_cache_limits,
7237 );
7238 defer mono.deinit();
7239
7240 const entries = [_]AtlasSet.Entry{
7241 .{ .face = 0, .image_index = 0, .atlas = &small },
7242 .{ .face = 0, .image_index = 1, .atlas = &body },
7243 .{ .face = 2, .image_index = 2, .atlas = &mono },
7244 };
7245 const atlases = AtlasSet{ .entries = entries[0..] };
7246
7247 const body_pick = atlases.forText(.{ .content = "A", .point_size = 16 }).?;
7248 try std.testing.expectEqual(@as(u32, 1), body_pick.image_index);
7249
7250 const caption_pick = atlases.forText(.{ .content = "A", .point_size = 13 }).?;
7251 try std.testing.expectEqual(@as(u32, 0), caption_pick.image_index);
7252
7253 const near_pick = atlases.forText(.{ .content = "A", .point_size = 14 }).?;
7254 try std.testing.expectEqual(@as(u32, 1), near_pick.image_index);
7255
7256 const mono_pick = atlases.forText(.{ .content = "A", .point_size = 15, .font_asset_id = 2 }).?;
7257 try std.testing.expectEqual(@as(u32, 2), mono_pick.image_index);
7258
7259 const missing_face_pick = atlases.forText(.{ .content = "A", .point_size = 16, .font_asset_id = 9 }).?;
7260 try std.testing.expectEqual(@as(u32, 1), missing_face_pick.image_index);
7261
7262 const empty = AtlasSet{};
7263 try std.testing.expect(empty.forText(.{ .content = "A" }) == null);
7264 }