lib/gui/src/paint/synth.zig

daab053ee43316e1809a84551d573ddd1e5bf3d2

  1 const std = @import("std");
  2 
  3 const gui = @import("../root.zig");
  4 
  5 const Color = gui.model.UiColor;
  6 const Rect = gui.layout.Rect;
  7 
  8 pub fn covered(codepoint: u21) bool {
  9     return switch (codepoint) {
 10         0x2500...0x257F => boxSegments(codepoint) != null,
 11         0x2580...0x259F => true,
 12         0x2800...0x28FF => true,
 13         0x2295...0x2299 => true,
 14         0x22EF, 0x276F, 0x2726 => true,
 15         else => false,
 16     };
 17 }
 18 
 19 pub fn draw(writer: anytype, codepoint: u21, cell: Rect, color: Color) bool {
 20     switch (codepoint) {
 21         0x2500...0x257F => {
 22             const segments = boxSegments(codepoint) orelse return false;
 23             drawBox(writer, segments, cell, color);
 24             return true;
 25         },
 26         0x2580...0x259F => {
 27             drawBlock(writer, codepoint, cell, color);
 28             return true;
 29         },
 30         0x2800...0x28FF => {
 31             drawBraille(writer, codepoint, cell, color);
 32             return true;
 33         },
 34         0x2295...0x2299 => {
 35             drawCircled(writer, codepoint, cell, color);
 36             return true;
 37         },
 38         0x22EF => {
 39             drawMidlineEllipsis(writer, cell, color);
 40             return true;
 41         },
 42         0x276F => {
 43             drawChevron(writer, cell, color);
 44             return true;
 45         },
 46         0x2726 => {
 47             drawStar(writer, cell, color);
 48             return true;
 49         },
 50         else => return false,
 51     }
 52 }
 53 
 54 pub fn drawNotdef(writer: anytype, cell: Rect, color: Color) void {
 55     const thickness = strokeThickness(cell);
 56     const x = snap(cell.x + thickness);
 57     const y = snap(cell.y + thickness);
 58     const width = @max(thickness, snap(cell.width - thickness * 2));
 59     const height = @max(thickness, snap(cell.height - thickness * 2));
 60     fill(writer, .{ .x = x, .y = y, .width = width, .height = thickness }, color);
 61     fill(writer, .{ .x = x, .y = y + height - thickness, .width = width, .height = thickness }, color);
 62     fill(writer, .{ .x = x, .y = y, .width = thickness, .height = height }, color);
 63     fill(writer, .{ .x = x + width - thickness, .y = y, .width = thickness, .height = height }, color);
 64 }
 65 
 66 fn drawCircled(writer: anytype, codepoint: u21, cell: Rect, color: Color) void {
 67     const t = strokeThickness(cell);
 68     const side = @min(cell.width, cell.height * 0.72);
 69     const x0 = snap(cell.x + (cell.width - side) / 2);
 70     const y0 = snap(cell.y + (cell.height - side) / 2);
 71     const x1 = x0 + side;
 72     const y1 = y0 + side;
 73     const corner = side / 4;
 74     fill(writer, .{ .x = x0 + corner, .y = y0, .width = side - corner * 2, .height = t }, color);
 75     fill(writer, .{ .x = x0 + corner, .y = y1 - t, .width = side - corner * 2, .height = t }, color);
 76     fill(writer, .{ .x = x0, .y = y0 + corner, .width = t, .height = side - corner * 2 }, color);
 77     fill(writer, .{ .x = x1 - t, .y = y0 + corner, .width = t, .height = side - corner * 2 }, color);
 78     fill(writer, .{ .x = x0 + corner / 2, .y = y0 + corner / 2, .width = t, .height = t }, color);
 79     fill(writer, .{ .x = x1 - corner / 2 - t, .y = y0 + corner / 2, .width = t, .height = t }, color);
 80     fill(writer, .{ .x = x0 + corner / 2, .y = y1 - corner / 2 - t, .width = t, .height = t }, color);
 81     fill(writer, .{ .x = x1 - corner / 2 - t, .y = y1 - corner / 2 - t, .width = t, .height = t }, color);
 82 
 83     const cx = x0 + side / 2;
 84     const cy = y0 + side / 2;
 85     const reach = side / 2 - corner / 2 - t;
 86     switch (codepoint) {
 87         0x2295 => {
 88             fill(writer, .{ .x = cx - reach, .y = cy - t / 2, .width = reach * 2, .height = t }, color);
 89             fill(writer, .{ .x = cx - t / 2, .y = cy - reach, .width = t, .height = reach * 2 }, color);
 90         },
 91         0x2296 => fill(writer, .{ .x = cx - reach, .y = cy - t / 2, .width = reach * 2, .height = t }, color),
 92         0x2297 => {
 93             drawDiagonal(writer, cx, cy, reach, t, color, .both);
 94         },
 95         0x2298 => drawDiagonal(writer, cx, cy, reach, t, color, .rising),
 96         0x2299 => fill(writer, .{ .x = cx - t, .y = cy - t, .width = t * 2, .height = t * 2 }, color),
 97         else => unreachable,
 98     }
 99 }
100 
101 const DiagonalArms = enum { rising, both };
102 
103 fn drawDiagonal(writer: anytype, cx: f32, cy: f32, reach: f32, t: f32, color: Color, arms: DiagonalArms) void {
104     const steps: usize = 3;
105     const step = reach / @as(f32, @floatFromInt(steps));
106     for (0..steps) |index| {
107         const offset = step * @as(f32, @floatFromInt(index)) + step / 2;
108         fill(writer, .{ .x = cx + offset - t / 2, .y = cy - offset - t / 2, .width = t, .height = t }, color);
109         fill(writer, .{ .x = cx - offset - t / 2, .y = cy + offset - t / 2, .width = t, .height = t }, color);
110         if (arms == .both) {
111             fill(writer, .{ .x = cx - offset - t / 2, .y = cy - offset - t / 2, .width = t, .height = t }, color);
112             fill(writer, .{ .x = cx + offset - t / 2, .y = cy + offset - t / 2, .width = t, .height = t }, color);
113         }
114     }
115 }
116 
117 fn drawMidlineEllipsis(writer: anytype, cell: Rect, color: Color) void {
118     const t = strokeThickness(cell);
119     const cy = cell.y + (cell.height - t) / 2;
120     const spacing = cell.width / 4;
121     for (0..3) |index| {
122         const offset: f32 = @floatFromInt(index + 1);
123         fill(writer, .{ .x = cell.x + spacing * offset - t / 2, .y = cy, .width = t, .height = t }, color);
124     }
125 }
126 
127 const BoxSegments = struct {
128     left: bool = false,
129     right: bool = false,
130     up: bool = false,
131     down: bool = false,
132 };
133 
134 fn boxSegments(codepoint: u21) ?BoxSegments {
135     return switch (codepoint) {
136         0x2500, 0x2501 => .{ .left = true, .right = true },
137         0x2502, 0x2503 => .{ .up = true, .down = true },
138         0x250C, 0x250F, 0x256D => .{ .right = true, .down = true },
139         0x2510, 0x2513, 0x256E => .{ .left = true, .down = true },
140         0x2514, 0x2517, 0x2570 => .{ .right = true, .up = true },
141         0x2518, 0x251B, 0x256F => .{ .left = true, .up = true },
142         0x251C => .{ .up = true, .down = true, .right = true },
143         0x2524 => .{ .up = true, .down = true, .left = true },
144         0x252C => .{ .left = true, .right = true, .down = true },
145         0x2534 => .{ .left = true, .right = true, .up = true },
146         0x253C => .{ .left = true, .right = true, .up = true, .down = true },
147         0x2574 => .{ .left = true },
148         0x2575 => .{ .up = true },
149         0x2576 => .{ .right = true },
150         0x2577 => .{ .down = true },
151         else => null,
152     };
153 }
154 
155 fn drawBox(writer: anytype, segments: BoxSegments, cell: Rect, color: Color) void {
156     const t = strokeThickness(cell);
157     const cx = snap(cell.x + (cell.width - t) / 2);
158     const cy = snap(cell.y + (cell.height - t) / 2);
159     if (segments.left) fill(writer, .{ .x = cell.x, .y = cy, .width = cx - cell.x + t, .height = t }, color);
160     if (segments.right) fill(writer, .{ .x = cx, .y = cy, .width = cell.x + cell.width - cx, .height = t }, color);
161     if (segments.up) fill(writer, .{ .x = cx, .y = cell.y, .width = t, .height = cy - cell.y + t }, color);
162     if (segments.down) fill(writer, .{ .x = cx, .y = cy, .width = t, .height = cell.y + cell.height - cy }, color);
163 }
164 
165 fn drawBlock(writer: anytype, codepoint: u21, cell: Rect, color: Color) void {
166     switch (codepoint) {
167         0x2580 => fillFraction(writer, cell, color, 0, 0, 8, 4),
168         0x2581...0x2588 => {
169             const eighths: f32 = @floatFromInt(codepoint - 0x2580);
170             const height = cell.height * eighths / 8;
171             fill(writer, .{ .x = cell.x, .y = cell.y + cell.height - height, .width = cell.width, .height = height }, color);
172         },
173         0x2589...0x258F => {
174             const eighths: f32 = @floatFromInt(8 - (codepoint - 0x2588));
175             fill(writer, .{ .x = cell.x, .y = cell.y, .width = cell.width * eighths / 8, .height = cell.height }, color);
176         },
177         0x2590 => fillFraction(writer, cell, color, 4, 0, 8, 8),
178         0x2591 => fill(writer, cell, shaded(color, 64)),
179         0x2592 => fill(writer, cell, shaded(color, 128)),
180         0x2593 => fill(writer, cell, shaded(color, 192)),
181         0x2594 => fill(writer, .{ .x = cell.x, .y = cell.y, .width = cell.width, .height = cell.height / 8 }, color),
182         0x2595 => fill(writer, .{ .x = cell.x + cell.width * 7 / 8, .y = cell.y, .width = cell.width / 8, .height = cell.height }, color),
183         0x2596...0x259F => {
184             const quads = quadrants(codepoint);
185             if (quads.upper_left) fillFraction(writer, cell, color, 0, 0, 4, 4);
186             if (quads.upper_right) fillFraction(writer, cell, color, 4, 0, 8, 4);
187             if (quads.lower_left) fillFraction(writer, cell, color, 0, 4, 4, 8);
188             if (quads.lower_right) fillFraction(writer, cell, color, 4, 4, 8, 8);
189         },
190         else => unreachable,
191     }
192 }
193 
194 const Quadrants = struct {
195     upper_left: bool = false,
196     upper_right: bool = false,
197     lower_left: bool = false,
198     lower_right: bool = false,
199 };
200 
201 fn quadrants(codepoint: u21) Quadrants {
202     return switch (codepoint) {
203         0x2596 => .{ .lower_left = true },
204         0x2597 => .{ .lower_right = true },
205         0x2598 => .{ .upper_left = true },
206         0x2599 => .{ .upper_left = true, .lower_left = true, .lower_right = true },
207         0x259A => .{ .upper_left = true, .lower_right = true },
208         0x259B => .{ .upper_left = true, .upper_right = true, .lower_left = true },
209         0x259C => .{ .upper_left = true, .upper_right = true, .lower_right = true },
210         0x259D => .{ .upper_right = true },
211         0x259E => .{ .upper_right = true, .lower_left = true },
212         0x259F => .{ .upper_right = true, .lower_left = true, .lower_right = true },
213         else => .{},
214     };
215 }
216 
217 fn drawBraille(writer: anytype, codepoint: u21, cell: Rect, color: Color) void {
218     const bits: u8 = @intCast(codepoint - 0x2800);
219     const dot_width = @max(1, snap(cell.width / 4));
220     const dot_height = @max(1, snap(cell.height / 8));
221     const columns = [2]f32{ cell.x + cell.width * 0.18, cell.x + cell.width * 0.58 };
222     const rows = [4]f32{
223         cell.y + cell.height * 0.12,
224         cell.y + cell.height * 0.34,
225         cell.y + cell.height * 0.56,
226         cell.y + cell.height * 0.78,
227     };
228     const dots = [8]struct { column: usize, row: usize }{
229         .{ .column = 0, .row = 0 },
230         .{ .column = 0, .row = 1 },
231         .{ .column = 0, .row = 2 },
232         .{ .column = 1, .row = 0 },
233         .{ .column = 1, .row = 1 },
234         .{ .column = 1, .row = 2 },
235         .{ .column = 0, .row = 3 },
236         .{ .column = 1, .row = 3 },
237     };
238     for (dots, 0..) |dot, bit| {
239         if ((bits >> @intCast(bit)) & 1 == 0) continue;
240         fill(writer, .{
241             .x = snap(columns[dot.column]),
242             .y = snap(rows[dot.row]),
243             .width = dot_width,
244             .height = dot_height,
245         }, color);
246     }
247 }
248 
249 fn drawChevron(writer: anytype, cell: Rect, color: Color) void {
250     const steps: usize = 4;
251     const inset_x = cell.width * 0.2;
252     const inset_y = cell.height * 0.22;
253     const span_x = cell.width - inset_x * 2;
254     const half_y = cell.height / 2 - inset_y;
255     const step_x = span_x / @as(f32, @floatFromInt(steps));
256     const step_y = half_y / @as(f32, @floatFromInt(steps));
257     const thickness = @max(step_y, strokeThickness(cell));
258     for (0..steps) |index| {
259         const offset: f32 = @floatFromInt(index);
260         fill(writer, .{
261             .x = snap(cell.x + inset_x + step_x * offset),
262             .y = snap(cell.y + inset_y + step_y * offset),
263             .width = @max(1, snap(step_x + 1)),
264             .height = @max(1, snap(thickness)),
265         }, color);
266         fill(writer, .{
267             .x = snap(cell.x + inset_x + step_x * offset),
268             .y = snap(cell.y + cell.height - inset_y - step_y * (offset + 1)),
269             .width = @max(1, snap(step_x + 1)),
270             .height = @max(1, snap(thickness)),
271         }, color);
272     }
273 }
274 
275 fn drawStar(writer: anytype, cell: Rect, color: Color) void {
276     const rows = [5]f32{ 1, 3, 5, 3, 1 };
277     const unit_height = cell.height / 7;
278     const unit_width = cell.width / 6;
279     for (rows, 0..) |width_units, index| {
280         const row_width = unit_width * width_units;
281         const row_index: f32 = @floatFromInt(index);
282         fill(writer, .{
283             .x = snap(cell.x + (cell.width - row_width) / 2),
284             .y = snap(cell.y + unit_height * (row_index + 1)),
285             .width = @max(1, snap(row_width)),
286             .height = @max(1, snap(unit_height)),
287         }, color);
288     }
289 }
290 
291 fn fillFraction(writer: anytype, cell: Rect, color: Color, x0: f32, y0: f32, x1: f32, y1: f32) void {
292     fill(writer, .{
293         .x = cell.x + cell.width * x0 / 8,
294         .y = cell.y + cell.height * y0 / 8,
295         .width = cell.width * (x1 - x0) / 8,
296         .height = cell.height * (y1 - y0) / 8,
297     }, color);
298 }
299 
300 fn fill(writer: anytype, rect: Rect, color: Color) void {
301     writer.fillRect(.{
302         .x = snap(rect.x),
303         .y = snap(rect.y),
304         .width = @max(1, snap(rect.width)),
305         .height = @max(1, snap(rect.height)),
306     }, color);
307 }
308 
309 fn shaded(color: Color, alpha: u8) Color {
310     return .{ .r = color.r, .g = color.g, .b = color.b, .a = @intCast(@as(u16, color.a) * alpha / 255) };
311 }
312 
313 fn strokeThickness(cell: Rect) f32 {
314     return @max(1, snap(cell.height / 12));
315 }
316 
317 fn snap(value: f32) f32 {
318     return @round(value);
319 }
320 
321 const RecordingWriter = struct {
322     rects: std.ArrayListUnmanaged(Rect) = .empty,
323     colors: std.ArrayListUnmanaged(Color) = .empty,
324     allocator: std.mem.Allocator,
325 
326     fn fillRect(self: *RecordingWriter, rect: Rect, color: Color) void {
327         self.rects.append(self.allocator, rect) catch @panic("synth test allocation failed");
328         self.colors.append(self.allocator, color) catch @panic("synth test allocation failed");
329     }
330 
331     fn deinit(self: *RecordingWriter) void {
332         self.rects.deinit(self.allocator);
333         self.colors.deinit(self.allocator);
334     }
335 };
336 
337 const test_cell = Rect{ .x = 10, .y = 20, .width = 10, .height = 24 };
338 const test_color = Color{ .r = 200, .g = 100, .b = 50, .a = 255 };
339 
340 test "covered spans the terminal vocabulary and rejects letters" {
341     try std.testing.expect(covered(0x2502));
342     try std.testing.expect(covered(0x258F));
343     try std.testing.expect(covered(0x280B));
344     try std.testing.expect(covered(0x276F));
345     try std.testing.expect(covered(0x2726));
346     try std.testing.expect(covered(0x2297));
347     try std.testing.expect(covered(0x22EF));
348     try std.testing.expect(!covered('a'));
349     try std.testing.expect(!covered(0x2560));
350 }
351 
352 test "notdef draws a closed box without a font glyph" {
353     var writer = RecordingWriter{ .allocator = std.testing.allocator };
354     defer writer.deinit();
355     drawNotdef(&writer, test_cell, test_color);
356     try std.testing.expectEqual(@as(usize, 4), writer.rects.items.len);
357     try std.testing.expectEqual(writer.rects.items[0].y, writer.rects.items[2].y);
358     try std.testing.expectEqual(writer.rects.items[1].y + writer.rects.items[1].height, writer.rects.items[2].y + writer.rects.items[2].height);
359 }
360 
361 test "circled operators draw a ring with an inner mark" {
362     var ring_only = RecordingWriter{ .allocator = std.testing.allocator };
363     defer ring_only.deinit();
364     try std.testing.expect(draw(&ring_only, 0x2299, test_cell, test_color));
365     const dot_rects = ring_only.rects.items.len;
366     try std.testing.expectEqual(@as(usize, 9), dot_rects);
367 
368     var crossed = RecordingWriter{ .allocator = std.testing.allocator };
369     defer crossed.deinit();
370     try std.testing.expect(draw(&crossed, 0x2297, test_cell, test_color));
371     try std.testing.expect(crossed.rects.items.len > dot_rects);
372 }
373 
374 test "midline ellipsis draws three centered dots" {
375     var writer = RecordingWriter{ .allocator = std.testing.allocator };
376     defer writer.deinit();
377     try std.testing.expect(draw(&writer, 0x22EF, test_cell, test_color));
378     try std.testing.expectEqual(@as(usize, 3), writer.rects.items.len);
379     for (writer.rects.items) |rect| {
380         try std.testing.expect(rect.y > test_cell.y + test_cell.height / 4);
381         try std.testing.expect(rect.y < test_cell.y + test_cell.height * 3 / 4);
382     }
383 }
384 
385 test "left one eighth block fills a narrow left bar" {
386     var writer = RecordingWriter{ .allocator = std.testing.allocator };
387     defer writer.deinit();
388     try std.testing.expect(draw(&writer, 0x258F, test_cell, test_color));
389     try std.testing.expectEqual(@as(usize, 1), writer.rects.items.len);
390     const rect = writer.rects.items[0];
391     try std.testing.expectEqual(test_cell.x, rect.x);
392     try std.testing.expectEqual(test_cell.y, rect.y);
393     try std.testing.expectApproxEqAbs(test_cell.width / 8, rect.width, 1.0);
394     try std.testing.expectEqual(test_cell.height, rect.height);
395 }
396 
397 test "full block covers the cell and shades keep the hue at reduced alpha" {
398     var writer = RecordingWriter{ .allocator = std.testing.allocator };
399     defer writer.deinit();
400     try std.testing.expect(draw(&writer, 0x2588, test_cell, test_color));
401     try std.testing.expectEqual(test_cell.width, writer.rects.items[0].width);
402     try std.testing.expectEqual(test_cell.height, writer.rects.items[0].height);
403 
404     try std.testing.expect(draw(&writer, 0x2592, test_cell, test_color));
405     try std.testing.expectEqual(@as(u8, 128), writer.colors.items[1].a);
406     try std.testing.expectEqual(test_color.r, writer.colors.items[1].r);
407 }
408 
409 test "braille dot count follows the bit pattern" {
410     var writer = RecordingWriter{ .allocator = std.testing.allocator };
411     defer writer.deinit();
412     try std.testing.expect(draw(&writer, 0x280B, test_cell, test_color));
413     try std.testing.expectEqual(@as(usize, 3), writer.rects.items.len);
414 
415     var full = RecordingWriter{ .allocator = std.testing.allocator };
416     defer full.deinit();
417     try std.testing.expect(draw(&full, 0x28FF, test_cell, test_color));
418     try std.testing.expectEqual(@as(usize, 8), full.rects.items.len);
419 }
420 
421 test "box drawing tee composes three segments" {
422     var writer = RecordingWriter{ .allocator = std.testing.allocator };
423     defer writer.deinit();
424     try std.testing.expect(draw(&writer, 0x251C, test_cell, test_color));
425     try std.testing.expectEqual(@as(usize, 3), writer.rects.items.len);
426 }
427 
428 test "chevron and star emit stepped fills inside the cell" {
429     var writer = RecordingWriter{ .allocator = std.testing.allocator };
430     defer writer.deinit();
431     try std.testing.expect(draw(&writer, 0x276F, test_cell, test_color));
432     try std.testing.expect(draw(&writer, 0x2726, test_cell, test_color));
433     for (writer.rects.items) |rect| {
434         try std.testing.expect(rect.x >= test_cell.x - 1);
435         try std.testing.expect(rect.x + rect.width <= test_cell.x + test_cell.width + 2);
436         try std.testing.expect(rect.y >= test_cell.y - 1);
437         try std.testing.expect(rect.y + rect.height <= test_cell.y + test_cell.height + 2);
438     }
439 }