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