a modern tui library written in zig
at main 18 kB view raw
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}