this repo has no description
13
fork

Configure Feed

Select the types of activity you want to include in your feed.

vxfw: add Text widget

Text widget is a general purpose text layout widget. Includes support
for soft wrapping, ellipsis truncation, and text alignment. A single
style is applied to the entire widget area.

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