a modern tui library written in zig
1const std = @import("std");
2const vaxis = @import("../main.zig");
3
4const Allocator = std.mem.Allocator;
5
6const vxfw = @import("vxfw.zig");
7
8const Text = @This();
9
10text: []const u8,
11style: vaxis.Style = .{},
12text_align: enum { left, center, right } = .left,
13softwrap: bool = true,
14overflow: enum { ellipsis, clip } = .ellipsis,
15width_basis: enum { parent, longest_line } = .longest_line,
16
17pub fn widget(self: *const Text) vxfw.Widget {
18 return .{
19 .userdata = @constCast(self),
20 .drawFn = typeErasedDrawFn,
21 };
22}
23
24fn typeErasedDrawFn(ptr: *anyopaque, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface {
25 const self: *const Text = @ptrCast(@alignCast(ptr));
26 return self.draw(ctx);
27}
28
29pub fn draw(self: *const Text, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface {
30 if (ctx.max.width != null and ctx.max.width.? == 0) {
31 return .{
32 .size = ctx.min,
33 .widget = self.widget(),
34 .buffer = &.{},
35 .children = &.{},
36 };
37 }
38 const container_size = self.findContainerSize(ctx);
39
40 // Create a surface of target width and max height. We'll trim the result after drawing
41 const surface = try vxfw.Surface.init(
42 ctx.arena,
43 self.widget(),
44 container_size,
45 );
46 const base_style: vaxis.Style = .{
47 .fg = self.style.fg,
48 .bg = self.style.bg,
49 .reverse = self.style.reverse,
50 };
51 const base: vaxis.Cell = .{ .style = base_style };
52 @memset(surface.buffer, base);
53
54 var row: u16 = 0;
55 if (self.softwrap) {
56 var iter = SoftwrapIterator.init(self.text, ctx);
57 while (iter.next()) |line| {
58 if (row >= container_size.height) break;
59 defer row += 1;
60 var col: u16 = switch (self.text_align) {
61 .left => 0,
62 .center => (container_size.width - line.width) / 2,
63 .right => container_size.width - line.width,
64 };
65 var char_iter = ctx.graphemeIterator(line.bytes);
66 while (char_iter.next()) |char| {
67 const grapheme = char.bytes(line.bytes);
68 if (std.mem.eql(u8, grapheme, "\t")) {
69 for (0..8) |i| {
70 surface.writeCell(@intCast(col + i), row, .{
71 .char = .{ .grapheme = " ", .width = 1 },
72 .style = self.style,
73 });
74 }
75 col += 8;
76 continue;
77 }
78 const grapheme_width: u8 = @intCast(ctx.stringWidth(grapheme));
79 surface.writeCell(col, row, .{
80 .char = .{ .grapheme = grapheme, .width = grapheme_width },
81 .style = self.style,
82 });
83 col += grapheme_width;
84 }
85 }
86 } else {
87 var line_iter: LineIterator = .{ .buf = self.text };
88 while (line_iter.next()) |line| {
89 if (row >= container_size.height) break;
90 // \t is default 1 wide. We add 7x the count of tab characters to get the full width
91 const line_width = ctx.stringWidth(line) + 7 * std.mem.count(u8, line, "\t");
92 defer row += 1;
93 const resolved_line_width = @min(container_size.width, line_width);
94 var col: u16 = switch (self.text_align) {
95 .left => 0,
96 .center => (container_size.width - resolved_line_width) / 2,
97 .right => container_size.width - resolved_line_width,
98 };
99 var char_iter = ctx.graphemeIterator(line);
100 while (char_iter.next()) |char| {
101 if (col >= container_size.width) break;
102 const grapheme = char.bytes(line);
103 const grapheme_width: u8 = @intCast(ctx.stringWidth(grapheme));
104
105 if (col + grapheme_width >= container_size.width and
106 line_width > container_size.width and
107 self.overflow == .ellipsis)
108 {
109 surface.writeCell(col, row, .{
110 .char = .{ .grapheme = "…", .width = 1 },
111 .style = self.style,
112 });
113 col = container_size.width;
114 } else {
115 surface.writeCell(col, row, .{
116 .char = .{ .grapheme = grapheme, .width = grapheme_width },
117 .style = self.style,
118 });
119 col += @intCast(grapheme_width);
120 }
121 }
122 }
123 }
124 return surface.trimHeight(@max(row, ctx.min.height));
125}
126
127/// Determines the container size by finding the widest line in the viewable area
128fn findContainerSize(self: Text, ctx: vxfw.DrawContext) vxfw.Size {
129 var row: u16 = 0;
130 var max_width: u16 = ctx.min.width;
131 if (self.softwrap) {
132 var iter = SoftwrapIterator.init(self.text, ctx);
133 while (iter.next()) |line| {
134 if (ctx.max.outsideHeight(row))
135 break;
136
137 defer row += 1;
138 max_width = @max(max_width, line.width);
139 }
140 } else {
141 var line_iter: LineIterator = .{ .buf = self.text };
142 while (line_iter.next()) |line| {
143 if (ctx.max.outsideHeight(row))
144 break;
145 const line_width: u16 = @truncate(ctx.stringWidth(line));
146 defer row += 1;
147 const resolved_line_width = if (ctx.max.width) |max|
148 @min(max, line_width)
149 else
150 line_width;
151 max_width = @max(max_width, resolved_line_width);
152 }
153 }
154 const result_width = switch (self.width_basis) {
155 .longest_line => blk: {
156 if (ctx.max.width) |max|
157 break :blk @min(max, max_width)
158 else
159 break :blk max_width;
160 },
161 .parent => blk: {
162 std.debug.assert(ctx.max.width != null);
163 break :blk ctx.max.width.?;
164 },
165 };
166 return .{ .width = result_width, .height = @max(row, ctx.min.height) };
167}
168
169/// Iterates a slice of bytes by linebreaks. Lines are split by '\r', '\n', or '\r\n'
170pub const LineIterator = struct {
171 buf: []const u8,
172 index: usize = 0,
173
174 fn next(self: *LineIterator) ?[]const u8 {
175 if (self.index >= self.buf.len) return null;
176
177 const start = self.index;
178 const end = std.mem.indexOfAnyPos(u8, self.buf, self.index, "\r\n") orelse {
179 self.index = self.buf.len;
180 return self.buf[start..];
181 };
182
183 self.index = end;
184 self.consumeCR();
185 self.consumeLF();
186 return self.buf[start..end];
187 }
188
189 // consumes a \n byte
190 fn consumeLF(self: *LineIterator) void {
191 if (self.index >= self.buf.len) return;
192 if (self.buf[self.index] == '\n') self.index += 1;
193 }
194
195 // consumes a \r byte
196 fn consumeCR(self: *LineIterator) void {
197 if (self.index >= self.buf.len) return;
198 if (self.buf[self.index] == '\r') self.index += 1;
199 }
200};
201
202pub const SoftwrapIterator = struct {
203 ctx: vxfw.DrawContext,
204 line: []const u8 = "",
205 index: usize = 0,
206 hard_iter: LineIterator,
207
208 pub const Line = struct {
209 width: u16,
210 bytes: []const u8,
211 };
212
213 const soft_breaks = " \t";
214
215 fn init(buf: []const u8, ctx: vxfw.DrawContext) SoftwrapIterator {
216 return .{
217 .ctx = ctx,
218 .hard_iter = .{ .buf = buf },
219 };
220 }
221
222 fn next(self: *SoftwrapIterator) ?Line {
223 // Advance the hard iterator
224 if (self.index == self.line.len) {
225 self.line = self.hard_iter.next() orelse return null;
226 self.line = std.mem.trimRight(u8, self.line, " \t");
227 self.index = 0;
228 }
229
230 const start = self.index;
231 var cur_width: u16 = 0;
232 while (self.index < self.line.len) {
233 const idx = self.nextWrap();
234 const word = self.line[self.index..idx];
235 const next_width = self.ctx.stringWidth(word);
236
237 if (self.ctx.max.width) |max| {
238 if (cur_width + next_width > max) {
239 // Trim the word to see if it can fit on a line by itself
240 const trimmed = std.mem.trimLeft(u8, word, " \t");
241 const trimmed_bytes = word.len - trimmed.len;
242 // The number of bytes we trimmed is equal to the reduction in length
243 const trimmed_width = next_width - trimmed_bytes;
244 if (trimmed_width > max) {
245 // Won't fit on line by itself, so fit as much on this line as we can
246 var iter = self.ctx.graphemeIterator(word);
247 while (iter.next()) |item| {
248 const grapheme = item.bytes(word);
249 const w = self.ctx.stringWidth(grapheme);
250 if (cur_width + w > max) {
251 const end = self.index;
252 return .{ .width = cur_width, .bytes = self.line[start..end] };
253 }
254 cur_width += @intCast(w);
255 self.index += grapheme.len;
256 }
257 }
258 // We are softwrapping, advance index to the start of the next word
259 const end = self.index;
260 self.index = std.mem.indexOfNonePos(u8, self.line, self.index, soft_breaks) orelse self.line.len;
261 return .{ .width = cur_width, .bytes = self.line[start..end] };
262 }
263 }
264
265 self.index = idx;
266 cur_width += @intCast(next_width);
267 }
268 return .{ .width = cur_width, .bytes = self.line[start..] };
269 }
270
271 /// Determines the index of the end of the next word
272 fn nextWrap(self: *SoftwrapIterator) usize {
273 // Find the first linear whitespace char
274 const start_pos = std.mem.indexOfNonePos(u8, self.line, self.index, soft_breaks) orelse
275 return self.line.len;
276 if (std.mem.indexOfAnyPos(u8, self.line, start_pos, soft_breaks)) |idx| {
277 return idx;
278 }
279 return self.line.len;
280 }
281
282 // consumes a \n byte
283 fn consumeLF(self: *SoftwrapIterator) void {
284 if (self.index >= self.buf.len) return;
285 if (self.buf[self.index] == '\n') self.index += 1;
286 }
287
288 // consumes a \r byte
289 fn consumeCR(self: *SoftwrapIterator) void {
290 if (self.index >= self.buf.len) return;
291 if (self.buf[self.index] == '\r') self.index += 1;
292 }
293};
294
295test "SoftwrapIterator: LF breaks" {
296 vxfw.DrawContext.init(.unicode);
297 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
298 defer arena.deinit();
299
300 const ctx: vxfw.DrawContext = .{
301 .min = .{ .width = 0, .height = 0 },
302 .max = .{ .width = 20, .height = 10 },
303 .arena = arena.allocator(),
304 .cell_size = .{ .width = 10, .height = 20 },
305 };
306 var iter = SoftwrapIterator.init("Hello, \n world", ctx);
307 const first = iter.next();
308 try std.testing.expect(first != null);
309 try std.testing.expectEqualStrings("Hello,", first.?.bytes);
310 try std.testing.expectEqual(6, first.?.width);
311
312 const second = iter.next();
313 try std.testing.expect(second != null);
314 try std.testing.expectEqualStrings(" world", second.?.bytes);
315 try std.testing.expectEqual(6, second.?.width);
316
317 const end = iter.next();
318 try std.testing.expect(end == null);
319}
320
321test "SoftwrapIterator: soft breaks that fit" {
322 vxfw.DrawContext.init(.unicode);
323 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
324 defer arena.deinit();
325
326 const ctx: vxfw.DrawContext = .{
327 .min = .{ .width = 0, .height = 0 },
328 .max = .{ .width = 6, .height = 10 },
329 .arena = arena.allocator(),
330 .cell_size = .{ .width = 10, .height = 20 },
331 };
332 var iter = SoftwrapIterator.init("Hello, \nworld", ctx);
333 const first = iter.next();
334 try std.testing.expect(first != null);
335 try std.testing.expectEqualStrings("Hello,", first.?.bytes);
336 try std.testing.expectEqual(6, first.?.width);
337
338 const second = iter.next();
339 try std.testing.expect(second != null);
340 try std.testing.expectEqualStrings("world", second.?.bytes);
341 try std.testing.expectEqual(5, second.?.width);
342
343 const end = iter.next();
344 try std.testing.expect(end == null);
345}
346
347test "SoftwrapIterator: soft breaks that are longer than width" {
348 vxfw.DrawContext.init(.unicode);
349 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
350 defer arena.deinit();
351
352 const ctx: vxfw.DrawContext = .{
353 .min = .{ .width = 0, .height = 0 },
354 .max = .{ .width = 6, .height = 10 },
355 .arena = arena.allocator(),
356 .cell_size = .{ .width = 10, .height = 20 },
357 };
358 var iter = SoftwrapIterator.init("very-long-word \nworld", ctx);
359 const first = iter.next();
360 try std.testing.expect(first != null);
361 try std.testing.expectEqualStrings("very-l", first.?.bytes);
362 try std.testing.expectEqual(6, first.?.width);
363
364 const second = iter.next();
365 try std.testing.expect(second != null);
366 try std.testing.expectEqualStrings("ong-wo", second.?.bytes);
367 try std.testing.expectEqual(6, second.?.width);
368
369 const third = iter.next();
370 try std.testing.expect(third != null);
371 try std.testing.expectEqualStrings("rd", third.?.bytes);
372 try std.testing.expectEqual(2, third.?.width);
373
374 const fourth = iter.next();
375 try std.testing.expect(fourth != null);
376 try std.testing.expectEqualStrings("world", fourth.?.bytes);
377 try std.testing.expectEqual(5, fourth.?.width);
378
379 const end = iter.next();
380 try std.testing.expect(end == null);
381}
382
383test "SoftwrapIterator: soft breaks with leading spaces" {
384 vxfw.DrawContext.init(.unicode);
385 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
386 defer arena.deinit();
387
388 const ctx: vxfw.DrawContext = .{
389 .min = .{ .width = 0, .height = 0 },
390 .max = .{ .width = 6, .height = 10 },
391 .arena = arena.allocator(),
392 .cell_size = .{ .width = 10, .height = 20 },
393 };
394 var iter = SoftwrapIterator.init("Hello, \n world", ctx);
395 const first = iter.next();
396 try std.testing.expect(first != null);
397 try std.testing.expectEqualStrings("Hello,", first.?.bytes);
398 try std.testing.expectEqual(6, first.?.width);
399
400 const second = iter.next();
401 try std.testing.expect(second != null);
402 try std.testing.expectEqualStrings(" world", second.?.bytes);
403 try std.testing.expectEqual(6, second.?.width);
404
405 const end = iter.next();
406 try std.testing.expect(end == null);
407}
408
409test "LineIterator: LF breaks" {
410 const input = "Hello, \n world";
411 var iter: LineIterator = .{ .buf = input };
412 const first = iter.next();
413 try std.testing.expect(first != null);
414 try std.testing.expectEqualStrings("Hello, ", first.?);
415
416 const second = iter.next();
417 try std.testing.expect(second != null);
418 try std.testing.expectEqualStrings(" world", second.?);
419
420 const end = iter.next();
421 try std.testing.expect(end == null);
422}
423
424test "LineIterator: CR breaks" {
425 const input = "Hello, \r world";
426 var iter: LineIterator = .{ .buf = input };
427 const first = iter.next();
428 try std.testing.expect(first != null);
429 try std.testing.expectEqualStrings("Hello, ", first.?);
430
431 const second = iter.next();
432 try std.testing.expect(second != null);
433 try std.testing.expectEqualStrings(" world", second.?);
434
435 const end = iter.next();
436 try std.testing.expect(end == null);
437}
438
439test "LineIterator: CRLF breaks" {
440 const input = "Hello, \r\n world";
441 var iter: LineIterator = .{ .buf = input };
442 const first = iter.next();
443 try std.testing.expect(first != null);
444 try std.testing.expectEqualStrings("Hello, ", first.?);
445
446 const second = iter.next();
447 try std.testing.expect(second != null);
448 try std.testing.expectEqualStrings(" world", second.?);
449
450 const end = iter.next();
451 try std.testing.expect(end == null);
452}
453
454test "LineIterator: CRLF breaks with empty line" {
455 const input = "Hello, \r\n\r\n world";
456 var iter: LineIterator = .{ .buf = input };
457 const first = iter.next();
458 try std.testing.expect(first != null);
459 try std.testing.expectEqualStrings("Hello, ", first.?);
460
461 const second = iter.next();
462 try std.testing.expect(second != null);
463 try std.testing.expectEqualStrings("", second.?);
464
465 const third = iter.next();
466 try std.testing.expect(third != null);
467 try std.testing.expectEqualStrings(" world", third.?);
468
469 const end = iter.next();
470 try std.testing.expect(end == null);
471}
472
473test Text {
474 var text: Text = .{ .text = "Hello, world" };
475 const text_widget = text.widget();
476
477 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
478 defer arena.deinit();
479 vxfw.DrawContext.init(.unicode);
480
481 // Center expands to the max size. It must therefore have non-null max width and max height.
482 // These values are asserted in draw
483 const ctx: vxfw.DrawContext = .{
484 .arena = arena.allocator(),
485 .min = .{},
486 .max = .{ .width = 7, .height = 2 },
487 .cell_size = .{ .width = 10, .height = 20 },
488 };
489
490 {
491 // Text softwraps by default
492 const surface = try text_widget.draw(ctx);
493 try std.testing.expectEqual(@as(vxfw.Size, .{ .width = 6, .height = 2 }), surface.size);
494 }
495
496 {
497 text.softwrap = false;
498 text.overflow = .ellipsis;
499 const surface = try text_widget.draw(ctx);
500 try std.testing.expectEqual(@as(vxfw.Size, .{ .width = 7, .height = 1 }), surface.size);
501 // The last character will be an ellipsis
502 try std.testing.expectEqualStrings("…", surface.buffer[surface.buffer.len - 1].char.grapheme);
503 }
504}
505
506test "refAllDecls" {
507 std.testing.refAllDecls(@This());
508}