a modern tui library written in zig
1const std = @import("std");
2const vaxis = @import("../main.zig");
3
4const vxfw = @import("vxfw.zig");
5
6const Allocator = std.mem.Allocator;
7
8const RichText = @This();
9
10pub const TextSpan = vaxis.Segment;
11
12text: []const TextSpan,
13text_align: enum { left, center, right } = .left,
14base_style: vaxis.Style = .{},
15softwrap: bool = true,
16overflow: enum { ellipsis, clip } = .ellipsis,
17width_basis: enum { parent, longest_line } = .longest_line,
18
19pub fn widget(self: *const RichText) vxfw.Widget {
20 return .{
21 .userdata = @constCast(self),
22 .drawFn = typeErasedDrawFn,
23 };
24}
25
26fn typeErasedDrawFn(ptr: *anyopaque, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface {
27 const self: *const RichText = @ptrCast(@alignCast(ptr));
28 return self.draw(ctx);
29}
30
31pub fn draw(self: *const RichText, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface {
32 if (ctx.max.width != null and ctx.max.width.? == 0) {
33 return .{
34 .size = ctx.min,
35 .widget = self.widget(),
36 .buffer = &.{},
37 .children = &.{},
38 };
39 }
40 var iter = try SoftwrapIterator.init(self.text, ctx);
41 const container_size = self.findContainerSize(&iter);
42
43 // Create a surface of target width and max height. We'll trim the result after drawing
44 const surface = try vxfw.Surface.init(
45 ctx.arena,
46 self.widget(),
47 container_size,
48 );
49 const base: vaxis.Cell = .{ .style = self.base_style };
50 @memset(surface.buffer, base);
51
52 var row: u16 = 0;
53 if (self.softwrap) {
54 while (iter.next()) |line| {
55 if (ctx.max.outsideHeight(row)) break;
56 defer row += 1;
57 var col: u16 = switch (self.text_align) {
58 .left => 0,
59 .center => (container_size.width - line.width) / 2,
60 .right => container_size.width - line.width,
61 };
62 for (line.cells) |cell| {
63 surface.writeCell(col, row, cell);
64 col += cell.char.width;
65 }
66 }
67 } else {
68 while (iter.nextHardBreak()) |line| {
69 if (ctx.max.outsideHeight(row)) break;
70 const line_width = blk: {
71 var w: u16 = 0;
72 for (line) |cell| {
73 w +|= cell.char.width;
74 }
75 break :blk w;
76 };
77 defer row += 1;
78 var col: u16 = switch (self.text_align) {
79 .left => 0,
80 .center => (container_size.width -| line_width) / 2,
81 .right => container_size.width -| line_width,
82 };
83 for (line) |cell| {
84 if (col + cell.char.width >= container_size.width and
85 line_width > container_size.width and
86 self.overflow == .ellipsis)
87 {
88 surface.writeCell(col, row, .{
89 .char = .{ .grapheme = "…", .width = 1 },
90 .style = cell.style,
91 });
92 col = container_size.width;
93 continue;
94 } else {
95 surface.writeCell(col, row, cell);
96 col += @intCast(cell.char.width);
97 }
98 }
99 }
100 }
101 return surface.trimHeight(@max(row, ctx.min.height));
102}
103
104/// Finds the widest line within the viewable portion of ctx
105fn findContainerSize(self: RichText, iter: *SoftwrapIterator) vxfw.Size {
106 defer iter.reset();
107 var row: u16 = 0;
108 var max_width: u16 = iter.ctx.min.width;
109 if (self.softwrap) {
110 while (iter.next()) |line| {
111 if (iter.ctx.max.outsideHeight(row)) break;
112 defer row += 1;
113 max_width = @max(max_width, line.width);
114 }
115 } else {
116 while (iter.nextHardBreak()) |line| {
117 if (iter.ctx.max.outsideHeight(row)) break;
118 defer row += 1;
119 var w: u16 = 0;
120 for (line) |cell| {
121 w +|= cell.char.width;
122 }
123 max_width = @max(max_width, w);
124 }
125 }
126 const result_width = switch (self.width_basis) {
127 .longest_line => blk: {
128 if (iter.ctx.max.width) |max|
129 break :blk @min(max, max_width)
130 else
131 break :blk max_width;
132 },
133 .parent => blk: {
134 std.debug.assert(iter.ctx.max.width != null);
135 break :blk iter.ctx.max.width.?;
136 },
137 };
138 return .{ .width = result_width, .height = @max(row, iter.ctx.min.height) };
139}
140
141pub const SoftwrapIterator = struct {
142 arena: std.heap.ArenaAllocator,
143 ctx: vxfw.DrawContext,
144 text: []const vaxis.Cell,
145 line: []const vaxis.Cell,
146 index: usize = 0,
147 // Index of the hard iterator
148 hard_index: usize = 0,
149
150 const soft_breaks = " \t";
151
152 pub const Line = struct {
153 width: u16,
154 cells: []const vaxis.Cell,
155 };
156
157 fn init(spans: []const TextSpan, ctx: vxfw.DrawContext) Allocator.Error!SoftwrapIterator {
158 // Estimate the number of cells we need
159 var len: usize = 0;
160 for (spans) |span| {
161 len += span.text.len;
162 }
163 var arena = std.heap.ArenaAllocator.init(ctx.arena);
164 const alloc = arena.allocator();
165 var list: std.ArrayList(vaxis.Cell) = try .initCapacity(alloc, len);
166
167 for (spans) |span| {
168 var iter = ctx.graphemeIterator(span.text);
169 while (iter.next()) |grapheme| {
170 const char = grapheme.bytes(span.text);
171 if (std.mem.eql(u8, char, "\t")) {
172 const cell: vaxis.Cell = .{
173 .char = .{ .grapheme = " ", .width = 1 },
174 .style = span.style,
175 .link = span.link,
176 };
177 for (0..8) |_| {
178 try list.append(alloc, cell);
179 }
180 continue;
181 }
182 const width = ctx.stringWidth(char);
183 const cell: vaxis.Cell = .{
184 .char = .{ .grapheme = char, .width = @intCast(width) },
185 .style = span.style,
186 .link = span.link,
187 };
188 try list.append(alloc, cell);
189 }
190 }
191 return .{
192 .arena = arena,
193 .ctx = ctx,
194 .text = list.items,
195 .line = &.{},
196 };
197 }
198
199 fn reset(self: *SoftwrapIterator) void {
200 self.index = 0;
201 self.hard_index = 0;
202 self.line = &.{};
203 }
204
205 fn deinit(self: *SoftwrapIterator) void {
206 self.arena.deinit();
207 }
208
209 fn nextHardBreak(self: *SoftwrapIterator) ?[]const vaxis.Cell {
210 if (self.hard_index >= self.text.len) return null;
211 const start = self.hard_index;
212 var saw_cr: bool = false;
213 while (self.hard_index < self.text.len) : (self.hard_index += 1) {
214 const cell = self.text[self.hard_index];
215 if (std.mem.eql(u8, cell.char.grapheme, "\r")) {
216 saw_cr = true;
217 }
218 if (std.mem.eql(u8, cell.char.grapheme, "\n")) {
219 self.hard_index += 1;
220 if (saw_cr) {
221 return self.text[start .. self.hard_index - 2];
222 }
223 return self.text[start .. self.hard_index - 1];
224 }
225 if (saw_cr) {
226 // back up one
227 self.hard_index -= 1;
228 return self.text[start .. self.hard_index - 1];
229 }
230 } else return self.text[start..];
231 }
232
233 fn trimWSPRight(text: []const vaxis.Cell) []const vaxis.Cell {
234 // trim linear whitespace
235 var i: usize = text.len;
236 while (i > 0) : (i -= 1) {
237 if (std.mem.eql(u8, text[i - 1].char.grapheme, " ") or
238 std.mem.eql(u8, text[i - 1].char.grapheme, "\t"))
239 {
240 continue;
241 }
242 break;
243 }
244 return text[0..i];
245 }
246
247 fn trimWSPLeft(text: []const vaxis.Cell) []const vaxis.Cell {
248 // trim linear whitespace
249 var i: usize = 0;
250 while (i < text.len) : (i += 1) {
251 if (std.mem.eql(u8, text[i].char.grapheme, " ") or
252 std.mem.eql(u8, text[i].char.grapheme, "\t"))
253 {
254 continue;
255 }
256 break;
257 }
258 return text[i..];
259 }
260
261 fn next(self: *SoftwrapIterator) ?Line {
262 // Advance the hard iterator
263 if (self.index == self.line.len) {
264 self.line = self.nextHardBreak() orelse return null;
265 // trim linear whitespace
266 self.line = trimWSPRight(self.line);
267 self.index = 0;
268 }
269
270 const max_width = self.ctx.max.width orelse {
271 var width: u16 = 0;
272 for (self.line) |cell| {
273 width += cell.char.width;
274 }
275 self.index = self.line.len;
276 return .{
277 .width = width,
278 .cells = self.line,
279 };
280 };
281
282 const start = self.index;
283 var cur_width: u16 = 0;
284 while (self.index < self.line.len) {
285 // Find the width from current position to next word break
286 const idx = self.nextWrap();
287 const word = self.line[self.index..idx];
288 const next_width = blk: {
289 var w: usize = 0;
290 for (word) |ch| {
291 w += ch.char.width;
292 }
293 break :blk w;
294 };
295
296 if (cur_width + next_width > max_width) {
297 // Trim the word to see if it can fit on a line by itself
298 const trimmed = trimWSPLeft(word);
299 // New width is the previous width minus the number of cells we trimmed because we
300 // are only trimming cells that would have been 1 wide (' ' and '\t' both measure as
301 // 1 wide)
302 const trimmed_width = next_width -| (word.len - trimmed.len);
303 if (trimmed_width > max_width) {
304 // Won't fit on line by itself, so fit as much on this line as we can
305 for (word) |cell| {
306 if (cur_width + cell.char.width > max_width) {
307 const end = self.index;
308 return .{ .width = cur_width, .cells = self.line[start..end] };
309 }
310 cur_width += @intCast(cell.char.width);
311 self.index += 1;
312 }
313 }
314 const end = self.index;
315 // We are softwrapping, advance index to the start of the next word. This is equal
316 // to the difference in our word length and trimmed word length
317 self.index += (word.len - trimmed.len);
318 return .{ .width = cur_width, .cells = self.line[start..end] };
319 }
320
321 self.index = idx;
322 cur_width += @intCast(next_width);
323 }
324 return .{ .width = cur_width, .cells = self.line[start..] };
325 }
326
327 fn nextWrap(self: *SoftwrapIterator) usize {
328 var i: usize = self.index;
329
330 // Find the first non-whitespace character
331 while (i < self.line.len) : (i += 1) {
332 if (std.mem.eql(u8, self.line[i].char.grapheme, " ") or
333 std.mem.eql(u8, self.line[i].char.grapheme, "\t"))
334 {
335 continue;
336 }
337 break;
338 }
339
340 // Now find the first whitespace
341 while (i < self.line.len) : (i += 1) {
342 if (std.mem.eql(u8, self.line[i].char.grapheme, " ") or
343 std.mem.eql(u8, self.line[i].char.grapheme, "\t"))
344 {
345 return i;
346 }
347 continue;
348 }
349
350 return self.line.len;
351 }
352};
353
354test RichText {
355 var rich_text: RichText = .{
356 .text = &.{
357 .{ .text = "Hello, " },
358 .{ .text = "World", .style = .{ .bold = true } },
359 },
360 };
361
362 const rich_widget = rich_text.widget();
363
364 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
365 defer arena.deinit();
366
367 vxfw.DrawContext.init(.unicode);
368
369 // Center expands to the max size. It must therefore have non-null max width and max height.
370 // These values are asserted in draw
371 const ctx: vxfw.DrawContext = .{
372 .arena = arena.allocator(),
373 .min = .{},
374 .max = .{ .width = 7, .height = 2 },
375 .cell_size = .{ .width = 10, .height = 20 },
376 };
377
378 {
379 // RichText softwraps by default
380 const surface = try rich_widget.draw(ctx);
381 try std.testing.expectEqual(@as(vxfw.Size, .{ .width = 6, .height = 2 }), surface.size);
382 }
383
384 {
385 rich_text.softwrap = false;
386 rich_text.overflow = .ellipsis;
387 const surface = try rich_widget.draw(ctx);
388 try std.testing.expectEqual(@as(vxfw.Size, .{ .width = 7, .height = 1 }), surface.size);
389 // The last character will be an ellipsis
390 try std.testing.expectEqualStrings("…", surface.buffer[surface.buffer.len - 1].char.grapheme);
391 }
392}
393
394test "long word wrapping" {
395 var rich_text: RichText = .{
396 .text = &.{
397 .{ .text = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" },
398 },
399 };
400
401 const rich_widget = rich_text.widget();
402
403 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
404 defer arena.deinit();
405
406 vxfw.DrawContext.init(.unicode);
407
408 const len = rich_text.text[0].text.len;
409 const width: u16 = 8;
410
411 const ctx: vxfw.DrawContext = .{
412 .arena = arena.allocator(),
413 .min = .{},
414 .max = .{ .width = width, .height = null },
415 .cell_size = .{ .width = 10, .height = 20 },
416 };
417
418 const surface = try rich_widget.draw(ctx);
419 // Height should be length / width
420 try std.testing.expectEqual(len / width, surface.size.height);
421}
422
423test "refAllDecls" {
424 std.testing.refAllDecls(@This());
425}