this repo has no description
13
fork

Configure Feed

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

vxfw: add RichText widget

RichText is a general purpose text layout widget which can have more
than one style. This comes with a small performance penalty compared to
`Text` due to not being able to do the same index operations, however it
does support all of the same features

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