╭ Markdown.zig ─────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
1 const std = @import("std"); 2 const ui = @import("../../ui.zig"); 3 const md = @import("../../markdown.zig"); 4 const Files = @import("Files.zig"); 5 const xit = @import("xit"); 6 const xitui = xit.xitui; 7 const wgt = xitui.widget; 8 const layout = xitui.layout; 9 const Key = xitui.input.Key; 10 const Grid = xitui.grid.Grid; 11 const Focus = xitui.focus.Focus; 12 const RichText = ui.widget.RichText; 13 14 // a rendered markdown file: one child per block, word-wrapped to the width. 15 // without `data` (a file outside any repo), repo-relative links aren't clickable. 16 pub const View = struct { 17 box: wgt.Box(ui.Widget), 18 // where focus rests off the links, covering the whole document; it's 19 // registered after the links so they win hit-testing 20 body: *Focus, 21 22 pub fn init(allocator: std.mem.Allocator, doc: md.Document, data: ?*const Files, file_path: []const u8, page_arena: *std.heap.ArenaAllocator) !View { 23 const builder: Builder = .{ 24 .allocator = allocator, 25 .data = data, 26 .dir = if (std.mem.lastIndexOfScalar(u8, file_path, '/')) |slash| file_path[0..slash] else "", 27 .page_arena = page_arena, 28 .fonts = !hasUncoveredHeading(doc.blocks), 29 }; 30 var box = try builder.blocksBox(doc.blocks, true); 31 errdefer box.deinit(allocator); 32 const body = try Focus.create(allocator, .container); 33 body.mode = .all; 34 box.getFocus().child_id = body.id; 35 return .{ .box = box, .body = body }; 36 } 37 38 pub fn deinit(self: *View, allocator: std.mem.Allocator) void { 39 self.box.deinit(allocator); 40 self.body.destroy(allocator); 41 } 42 43 pub fn build(self: *View, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 44 self.clearGrid(); 45 try self.box.build(allocator, constraint, root_focus); 46 const grid = self.box.getGrid() orelse return; 47 try self.box.getFocus().addChild(allocator, self.body, grid.size, 0, 0); 48 } 49 50 pub fn input(self: *View, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 51 _ = self; 52 _ = allocator; 53 _ = key; 54 _ = root_focus; 55 } 56 57 pub fn clearGrid(self: *View) void { 58 self.box.clearGrid(); 59 } 60 61 pub fn getGrid(self: View) ?Grid { 62 return self.box.getGrid(); 63 } 64 65 pub fn getFocus(self: *View) *Focus { 66 return self.box.getFocus(); 67 } 68 69 // whether `id` is the body or one of the links 70 pub fn owns(self: *View, id: usize) bool { 71 return self.box.getFocus().children.contains(id); 72 } 73 74 // focus the next or previous link in document order, scrolling it into 75 // view; from the body, start at the first link in view. stepping past 76 // either end returns to the body. `x` and `y` place the view in `scroll`'s 77 // content. 78 pub fn stepLink(self: *View, allocator: std.mem.Allocator, root_focus: *Focus, scroll: *wgt.Scroll(ui.Widget), x: isize, y: isize, forward: bool) !void { 79 var stops: std.ArrayList(usize) = .empty; 80 defer stops.deinit(allocator); 81 try collectStops(allocator, &self.box, self.box.getFocus(), &stops); 82 const children = self.box.getFocus().children; 83 84 const current = if (root_focus.grandchild_id) |id| std.mem.indexOfScalar(usize, stops.items, id) else null; 85 const target: ?usize = if (current) |c| 86 (if (forward) (if (c + 1 < stops.items.len) c + 1 else null) else (if (c > 0) c - 1 else null)) 87 else entry: { 88 // the visible rows, in the view's coordinates 89 const top = @max(scroll.y, 0) - y; 90 const bottom = if (scroll.grid) |g| top + @as(isize, @intCast(g.size.height - scroll.bar_h)) else std.math.maxInt(isize); 91 if (forward) { 92 for (stops.items, 0..) |id, i| { 93 if ((children.get(id) orelse unreachable).rect.y >= top) break :entry i; 94 } 95 break :entry null; 96 } 97 var i = stops.items.len; 98 while (i > 0) { 99 i -= 1; 100 if ((children.get(stops.items[i]) orelse unreachable).rect.y < bottom) break :entry i; 101 } 102 break :entry null; 103 }; 104 105 const id = stops.items[target orelse return root_focus.setFocus(self.body.id)]; 106 root_focus.setFocus(id); 107 const rect = (children.get(id) orelse unreachable).rect; 108 scroll.scrollToRect(.{ .x = x + @as(isize, @intCast(rect.x)), .y = y + @as(isize, @intCast(rect.y)), .size = rect.size }); 109 } 110 }; 111 112 // word-wrapped markdown in a rounded border that turns double while focus is inside 113 pub const Frame = struct { 114 box: wgt.Box(ui.Widget), 115 116 pub fn init(allocator: std.mem.Allocator, text: []const u8, label: []const u8, bottom_label: []const u8, page_arena: *std.heap.ArenaAllocator) !Frame { 117 const doc = try md.parseText(page_arena.allocator(), text); 118 119 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border = .single, .round_corners = true, .direction = .vert, .top_label = .{ .text = label }, .bottom_label = .{ .text = bottom_label } }); 120 errdefer box.deinit(allocator); 121 var markdown = try View.init(allocator, doc, null, "", page_arena); 122 errdefer markdown.deinit(allocator); 123 try box.children.put(allocator, markdown.getFocus().id, .{ .widget = .{ .markdown = markdown }, .rect = null, .min_size = null }); 124 box.getFocus().child_id = markdown.getFocus().id; 125 return .{ .box = box }; 126 } 127 128 pub fn deinit(self: *Frame, allocator: std.mem.Allocator) void { 129 self.box.deinit(allocator); 130 } 131 132 pub fn build(self: *Frame, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 133 const focused = if (root_focus.grandchild_id) |id| self.markdownView().owns(id) else false; 134 self.box.options.border = if (focused) .double else .single; 135 try self.box.build(allocator, constraint, root_focus); 136 } 137 138 pub fn input(self: *Frame, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 139 _ = self; 140 _ = allocator; 141 _ = key; 142 _ = root_focus; 143 } 144 145 pub fn clearGrid(self: *Frame) void { 146 self.box.clearGrid(); 147 } 148 149 pub fn getGrid(self: Frame) ?Grid { 150 return self.box.getGrid(); 151 } 152 153 pub fn getFocus(self: *Frame) *Focus { 154 return self.box.getFocus(); 155 } 156 157 // step between the links, `x` and `y` placing the frame in `scroll`'s content 158 pub fn stepLink(self: *Frame, allocator: std.mem.Allocator, root_focus: *Focus, scroll: *wgt.Scroll(ui.Widget), x: isize, y: isize, forward: bool) !void { 159 // the view sits inside the border 160 try self.markdownView().stepLink(allocator, root_focus, scroll, x + 1, y + 1, forward); 161 } 162 163 // where focus rests off the links 164 pub fn body(self: *Frame) *Focus { 165 return self.markdownView().body; 166 } 167 168 fn markdownView(self: *Frame) *View { 169 return &self.box.children.values()[0].widget.markdown; 170 } 171 }; 172 173 // the focus ids of the laid-out links under `box`, in document order 174 fn collectStops(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), view_focus: *Focus, stops: *std.ArrayList(usize)) !void { 175 for (box.children.values()) |*child| switch (child.widget) { 176 .rich_text => |*rich_text| try addStops(allocator, rich_text, view_focus, stops), 177 .markdown_heading => |*heading| try addStops(allocator, &heading.text, view_focus, stops), 178 .markdown_gutter => |*gutter| try collectStops(allocator, &gutter.child, view_focus, stops), 179 .box => |*inner| try collectStops(allocator, inner, view_focus, stops), 180 else => {}, 181 }; 182 } 183 184 // each link's first piece is its stop 185 fn addStops(allocator: std.mem.Allocator, rich_text: *RichText, view_focus: *Focus, stops: *std.ArrayList(usize)) !void { 186 for (rich_text.link_focuses) |focuses| { 187 if (focuses.items.len > 0 and view_focus.children.contains(focuses.items[0].id)) try stops.append(allocator, focuses.items[0].id); 188 } 189 } 190 191 // turns blocks into widgets, resolving links against the viewed file. 192 const Builder = struct { 193 allocator: std.mem.Allocator, 194 data: ?*const Files, 195 // the viewed file's directory, which relative links resolve against 196 dir: []const u8, 197 page_arena: *std.heap.ArenaAllocator, 198 // whether headings may use the title fonts 199 fonts: bool, 200 201 // a vertical box of `blocks`, with a blank row between them when `gap` 202 fn blocksBox(b: Builder, blocks: []const md.Block, gap: bool) anyerror!wgt.Box(ui.Widget) { 203 var box = try wgt.Box(ui.Widget).init(b.allocator, .{ .border = null, .direction = .vert, .gap = @intFromBool(gap) }); 204 errdefer box.deinit(b.allocator); 205 for (blocks) |block| try b.addBlock(&box, block); 206 return box; 207 } 208 209 fn addBlock(b: Builder, box: *wgt.Box(ui.Widget), block: md.Block) !void { 210 const allocator = b.allocator; 211 switch (block) { 212 .heading => |heading| { 213 const spans = try b.runSpans(heading.inlines, .{ .bold = true }); 214 try put(allocator, box, .{ .markdown_heading = try Heading.init(allocator, b.page_arena, heading.level, spans, b.fonts) }); 215 }, 216 .paragraph => |inlines| try put(allocator, box, .{ .rich_text = try RichText.init(allocator, try b.runSpans(inlines, .{})) }), 217 .code => |lines| { 218 const text = try std.mem.join(b.page_arena.allocator(), "\n", lines); 219 try put(allocator, box, .{ .text_box = try wgt.TextBox.init(allocator, text, .{ .border = .single, .round_corners = true, .wrap_kind = .char }) }); 220 }, 221 .quote => |blocks| { 222 const gutter = gutter: { 223 var inner = try b.blocksBox(blocks, true); 224 errdefer inner.deinit(allocator); 225 break :gutter try Gutter.init(allocator, "│ ", true, .{ .dim = true }, inner); 226 }; 227 try put(allocator, box, .{ .markdown_gutter = gutter }); 228 }, 229 .list => |list| { 230 var list_box = try wgt.Box(ui.Widget).init(allocator, .{ .border = null, .direction = .vert }); 231 errdefer list_box.deinit(allocator); 232 // ordered markers are right-aligned to the widest number 233 const number_width = std.fmt.count("{d}", .{list.start + list.items.len -| 1}); 234 for (list.items, 0..) |item, i| { 235 const marker = if (item.task) |checked| 236 (if (checked) "☑ " else "☐ ") 237 else if (list.ordered) 238 try b.page_arena.allocator().print("{[n]d:>[w]}. ", .{ .n = list.start + i, .w = number_width }) 239 else 240 "• "; 241 const gutter = gutter: { 242 var inner = try b.blocksBox(item.blocks, false); 243 errdefer inner.deinit(allocator); 244 break :gutter try Gutter.init(allocator, marker, false, .{}, inner); 245 }; 246 try put(allocator, &list_box, .{ .markdown_gutter = gutter }); 247 } 248 try put(allocator, box, .{ .box = list_box }); 249 }, 250 // wider than any pane, and clipped to the width 251 .rule => try put(allocator, box, .{ .text_box = try wgt.TextBox.init(allocator, std.mem.asBytes(&@as([512]["─".len]u8, @splat("─".*))), .{ .border = null, .wrap_kind = .none, .style = .{ .dim = true } }) }), 252 .raw => |lines| { 253 const text = try std.mem.join(b.page_arena.allocator(), "\n", lines); 254 try put(allocator, box, .{ .text_box = try wgt.TextBox.init(allocator, text, .{ .border = null, .wrap_kind = .none }) }); 255 }, 256 } 257 } 258 259 // styled spans for `inlines` over `base`, with each link resolved 260 fn runSpans(b: Builder, inlines: []const md.Inline, base: Grid.Style) ![]const RichText.Span { 261 const out = try b.page_arena.allocator().alloc(RichText.Span, inlines.len); 262 for (inlines, out) |run, *span| { 263 const link = if (run.link) |dest| try b.resolveLink(dest) else ""; 264 var style = base; 265 style.bold = style.bold or run.style.bold; 266 style.italic = run.style.italic; 267 style.strikethrough = run.style.strike; 268 if (run.style.code) style.fg = .{ .ansi = .yellow }; 269 if (link.len > 0) { 270 style.fg = .{ .ansi = .cyan }; 271 style.underline = true; 272 } 273 span.* = .{ .text = run.text, .style = style, .link = link }; 274 } 275 return out; 276 } 277 278 // the focus kind a link follows, or "" when it isn't clickable: web links 279 // are raw links, and a repo path is a files route relative to this file 280 fn resolveLink(b: Builder, dest: []const u8) ![]const u8 { 281 const aa = b.page_arena.allocator(); 282 for ([_][]const u8{ "http:", "https:", "mailto:" }) |scheme| { 283 if (std.ascii.startsWithIgnoreCase(dest, scheme)) return aa.print("{s}{s}", .{ ui.raw_link_prefix, dest }); 284 } 285 if (hasScheme(dest) or std.mem.startsWith(u8, dest, "//")) return ""; 286 const data = b.data orelse return ""; 287 const end = std.mem.indexOfAny(u8, dest, "?#") orelse dest.len; 288 if (end == 0) return ""; 289 const decoded = std.Uri.percentDecodeInPlace(try aa.dupe(u8, dest[0..end])); 290 291 var segments: std.ArrayList([]const u8) = .empty; 292 if (decoded[0] != '/') { 293 var base = std.mem.tokenizeScalar(u8, b.dir, '/'); 294 while (base.next()) |segment| try segments.append(aa, segment); 295 } 296 var parts = std.mem.tokenizeScalar(u8, decoded, '/'); 297 while (parts.next()) |part| { 298 if (std.mem.eql(u8, part, ".")) continue; 299 if (std.mem.eql(u8, part, "..")) { 300 // climbing above the root isn't a repo path 301 if (segments.pop() == null) return ""; 302 continue; 303 } 304 try segments.append(aa, part); 305 } 306 const path = try std.mem.join(aa, "/", segments.items); 307 const route = data.filesRoute(path, 0) orelse return ""; 308 return aa.print("a:{s}", .{try route.toUrl(b.page_arena)}); 309 } 310 }; 311 312 // whether any heading the title fonts would draw, however nested, has a char 313 // they lack, in which case none use them so they all look alike 314 fn hasUncoveredHeading(blocks: []const md.Block) bool { 315 for (blocks) |block| switch (block) { 316 .heading => |heading| if (heading.level <= 2) for (heading.inlines) |run| { 317 if (!ui.Title.covers(run.text) or !ui.SubTitle.covers(run.text)) return true; 318 }, 319 .quote => |inner| if (hasUncoveredHeading(inner)) return true, 320 .list => |list| for (list.items) |item| { 321 if (hasUncoveredHeading(item.blocks)) return true; 322 }, 323 else => {}, 324 }; 325 return false; 326 } 327 328 // whether `dest` starts with a url scheme 329 fn hasScheme(dest: []const u8) bool { 330 const colon = std.mem.indexOfScalar(u8, dest, ':') orelse return false; 331 if (colon == 0 or !std.ascii.isAlphabetic(dest[0])) return false; 332 for (dest[0..colon]) |c| { 333 if (!std.ascii.isAlphanumeric(c) and c != '+' and c != '.' and c != '-') return false; 334 } 335 return true; 336 } 337 338 fn put(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), widget: ui.Widget) !void { 339 var owned = widget; 340 errdefer owned.deinit(allocator); 341 try box.children.put(allocator, owned.getFocus().id, .{ .widget = owned, .rect = null, .min_size = null }); 342 } 343 344 // a heading in its level's title font, word-wrapped to the width, or bold 345 // text when there's no font for it. 346 pub const Heading = struct { 347 focus: *Focus, 348 text: RichText, 349 // the font this heading draws in, if any, and the text it draws 350 font: ?enum { title, sub_title } = null, 351 plain: []const u8 = "", 352 font_box: wgt.TextBox, 353 354 fn init(allocator: std.mem.Allocator, page_arena: *std.heap.ArenaAllocator, level: u3, spans: []const RichText.Span, fonts: bool) !Heading { 355 const focus = try Focus.create(allocator, .container); 356 errdefer focus.destroy(allocator); 357 358 // the bold fallback leads with the level's #s, so levels stay apart 359 const marked = try page_arena.allocator().alloc(RichText.Span, spans.len + 1); 360 marked[0] = .{ .text = try page_arena.allocator().print("{s} ", .{"######"[0..level]}), .style = .{ .bold = true } }; 361 @memcpy(marked[1..], spans); 362 var text = try RichText.init(allocator, marked); 363 errdefer text.deinit(allocator); 364 365 var font_box = try wgt.TextBox.init(allocator, "", .{ .border = null, .wrap_kind = .none }); 366 errdefer font_box.deinit(allocator); 367 var self: Heading = .{ .focus = focus, .text = text, .font_box = font_box }; 368 369 var plain: std.ArrayList(u8) = .empty; 370 var has_links = false; 371 for (spans) |span| { 372 try plain.appendSlice(page_arena.allocator(), span.text); 373 has_links = has_links or span.link.len > 0; 374 } 375 self.plain = std.mem.trim(u8, plain.items, " "); 376 if (fonts and level <= 2 and !has_links and self.plain.len > 0) self.font = if (level == 1) .title else .sub_title; 377 return self; 378 } 379 380 pub fn deinit(self: *Heading, allocator: std.mem.Allocator) void { 381 self.focus.destroy(allocator); 382 self.text.deinit(allocator); 383 self.font_box.deinit(allocator); 384 } 385 386 pub fn build(self: *Heading, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 387 self.clearGrid(); 388 self.focus.clear(); 389 if (try self.wrapFont(allocator, constraint.max_size.width)) { 390 try self.buildShown(allocator, constraint, root_focus, &self.font_box); 391 } else { 392 try self.buildShown(allocator, constraint, root_focus, &self.text); 393 } 394 } 395 396 // fill the font box with the heading wrapped at `max_width`, or return 397 // false when there's no font 398 fn wrapFont(self: *Heading, allocator: std.mem.Allocator, max_width: ?usize) !bool { 399 const font = self.font orelse return false; 400 // a glyph and the gap after it, so n glyphs take n * char_width - 1 columns 401 const char_width: usize = if (font == .title) 4 else 3; 402 const max_chars = if (max_width) |w| (w + 1) / char_width else null; 403 404 var arena = std.heap.ArenaAllocator.init(allocator); 405 defer arena.deinit(); 406 const aa = arena.allocator(); 407 // the fonts only cover ascii, so each byte is one char 408 const chars = try aa.alloc(u21, self.plain.len); 409 for (self.plain, chars) |c, *char| char.* = c; 410 var lines: std.ArrayList(wgt.Line) = .empty; 411 try wgt.wrapLines(aa, &lines, chars, .word, max_chars); 412 413 // a blank row between lines keeps the glyphs from touching 414 var out: std.ArrayList(u8) = .empty; 415 for (lines.items, 0..) |line, i| { 416 if (i > 0) try out.appendSlice(aa, "\n\n"); 417 const text = std.mem.trimEnd(u8, self.plain[line.start..line.end], " "); 418 try out.appendSlice(aa, switch (font) { 419 .title => (try ui.Title.init(&arena, text, .solid)).content, 420 .sub_title => (try ui.SubTitle.init(&arena, text)).content, 421 }); 422 } 423 try self.font_box.setContent(allocator, out.items); 424 return true; 425 } 426 427 fn buildShown(self: *Heading, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus, widget: anytype) !void { 428 try widget.build(allocator, constraint, root_focus); 429 const grid = widget.getGrid() orelse return; 430 try self.focus.addChild(allocator, widget.getFocus(), grid.size, 0, 0); 431 } 432 433 pub fn input(self: *Heading, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 434 _ = self; 435 _ = allocator; 436 _ = key; 437 _ = root_focus; 438 } 439 440 pub fn clearGrid(self: *Heading) void { 441 self.text.clearGrid(); 442 self.font_box.clearGrid(); 443 } 444 445 pub fn getGrid(self: Heading) ?Grid { 446 // only the one last built has a grid 447 return self.font_box.getGrid() orelse self.text.getGrid(); 448 } 449 450 pub fn getFocus(self: *Heading) *Focus { 451 return self.focus; 452 } 453 }; 454 455 // a marker column beside a child, so wrapped lines hang under the child's 456 // first line: a list item's bullet, or a quote's bar on every row. 457 pub const Gutter = struct { 458 focus: *Focus, 459 grid: ?Grid, 460 child: wgt.Box(ui.Widget), 461 marker: []const u8, 462 // draw the marker on every row rather than only the first 463 repeat: bool, 464 style: Grid.Style, 465 466 // takes ownership of `child` on success 467 fn init(allocator: std.mem.Allocator, marker: []const u8, repeat: bool, style: Grid.Style, child: wgt.Box(ui.Widget)) !Gutter { 468 return .{ .focus = try Focus.create(allocator, .container), .grid = null, .child = child, .marker = marker, .repeat = repeat, .style = style }; 469 } 470 471 pub fn deinit(self: *Gutter, allocator: std.mem.Allocator) void { 472 self.focus.destroy(allocator); 473 self.clearGrid(); 474 self.child.deinit(allocator); 475 } 476 477 pub fn build(self: *Gutter, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 478 self.clearGrid(); 479 self.focus.clear(); 480 const marker_width = try xitui.width.displayWidth(self.marker); 481 if (constraint.max_size.width) |w| if (w <= marker_width) return; 482 if (constraint.max_size.height == 0) return; 483 484 try self.child.build(allocator, .{ 485 .min_size = .{ .width = null, .height = null }, 486 .max_size = .{ .width = if (constraint.max_size.width) |w| w - marker_width else null, .height = constraint.max_size.height }, 487 }, root_focus); 488 const child_grid = self.child.getGrid(); 489 const child_size: layout.Size = if (child_grid) |g| g.size else .{ .width = 0, .height = 0 }; 490 491 var grid = try Grid.init(allocator, .{ .width = marker_width + child_size.width, .height = @max(1, child_size.height) }); 492 errdefer grid.deinit(); 493 for (0..if (self.repeat) grid.size.height else 1) |y| { 494 var x: usize = 0; 495 var utf8 = (try std.unicode.Utf8View.init(self.marker)).iterator(); 496 while (utf8.nextCodepoint()) |rune| { 497 (try grid.cell(x, y)).style = self.style; 498 try grid.setRune(x, y, rune); 499 x += xitui.width.cellWidth(rune); 500 } 501 } 502 if (child_grid) |g| { 503 try grid.drawGrid(g, marker_width, 0); 504 try self.focus.addChild(allocator, self.child.getFocus(), g.size, marker_width, 0); 505 } 506 self.grid = grid; 507 } 508 509 pub fn input(self: *Gutter, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 510 _ = self; 511 _ = allocator; 512 _ = key; 513 _ = root_focus; 514 } 515 516 pub fn clearGrid(self: *Gutter) void { 517 if (self.grid) |*grid| { 518 grid.deinit(); 519 self.grid = null; 520 } 521 self.child.clearGrid(); 522 } 523 524 pub fn getGrid(self: Gutter) ?Grid { 525 return self.grid; 526 } 527 528 pub fn getFocus(self: *Gutter) *Focus { 529 return self.focus; 530 } 531 }; 532