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 }