╭─────────────╮ │scroll to top│ ╰─────────────╯ ╭ Markdown.zig ─────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─╯
174 // the focus ids of the laid-out links under `box`, in document order 175 fn collectStops(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), view_focus: *Focus, stops: *std.ArrayList(usize)) !void { 176 for (box.children.values()) |*child| switch (child.widget) { 177 .rich_text => |*rich_text| try addStops(allocator, rich_text, view_focus, stops), 178 .markdown_heading => |*heading| try addStops(allocator, &heading.text, view_focus, stops), 179 .markdown_gutter => |*gutter| try collectStops(allocator, &gutter.child, view_focus, stops), 180 .box => |*inner| try collectStops(allocator, inner, view_focus, stops), 181 else => {}, 182 }; 183 } 184 185 // each link's first piece is its stop 186 fn addStops(allocator: std.mem.Allocator, rich_text: *RichText, view_focus: *Focus, stops: *std.ArrayList(usize)) !void { 187 for (rich_text.link_focuses) |focuses| { 188 if (focuses.items.len > 0 and view_focus.children.contains(focuses.items[0].id)) try stops.append(allocator, focuses.items[0].id); 189 } 190 } 191 192 // turns blocks into widgets, resolving links against the viewed file. 193 const Builder = struct { 194 allocator: std.mem.Allocator, 195 data: ?*const Files, 196 // the viewed file's directory, which relative links resolve against 197 dir: []const u8, 198 page_arena: *std.heap.ArenaAllocator, 199 // whether headings may use the title fonts 200 fonts: bool, 201 202 // a vertical box of `blocks`, with a blank row between them when `gap` 203 fn blocksBox(b: Builder, blocks: []const md.Block, gap: bool) anyerror!wgt.Box(ui.Widget) { 204 var box = try wgt.Box(ui.Widget).init(b.allocator, .{ .border = null, .direction = .vert, .gap = @intFromBool(gap) }); 205 errdefer box.deinit(b.allocator); 206 for (blocks) |block| try b.addBlock(&box, block); 207 return box; 208 } 209 210 fn addBlock(b: Builder, box: *wgt.Box(ui.Widget), block: md.Block) !void { 211 const allocator = b.allocator; 212 switch (block) { 213 .heading => |heading| { 214 const spans = try b.runSpans(heading.inlines, .{ .bold = true }); 215 try put(allocator, box, .{ .markdown_heading = try Heading.init(allocator, b.page_arena, heading.level, spans, b.fonts) }); 216 }, 217 .paragraph => |inlines| try put(allocator, box, .{ .rich_text = try RichText.init(allocator, try b.runSpans(inlines, .{})) }), 218 .code => |lines| { 219 const text = try std.mem.join(b.page_arena.allocator(), "\n", lines); 220 try put(allocator, box, .{ .text_box = try wgt.TextBox.init(allocator, text, .{ .border = .single, .round_corners = true, .wrap_kind = .char }) }); 221 }, 222 .quote => |blocks| { 223 const gutter = gutter: { 224 var inner = try b.blocksBox(blocks, true); 225 errdefer inner.deinit(allocator); 226 break :gutter try Gutter.init(allocator, "│ ", true, .{ .dim = true }, inner); 227 }; 228 try put(allocator, box, .{ .markdown_gutter = gutter }); 229 }, 230 .list => |list| { 231 var list_box = try wgt.Box(ui.Widget).init(allocator, .{ .border = null, .direction = .vert }); 232 errdefer list_box.deinit(allocator); 233 // ordered markers are right-aligned to the widest number 234 const number_width = std.fmt.count("{d}", .{list.start + list.items.len -| 1}); 235 for (list.items, 0..) |item, i| { 236 const marker = if (item.task) |checked| 237 (if (checked) "☑ " else "☐ ") 238 else if (list.ordered) 239 try b.page_arena.allocator().print("{[n]d:>[w]}. ", .{ .n = list.start + i, .w = number_width }) 240 else 241 "• "; 242 const gutter = gutter: { 243 var inner = try b.blocksBox(item.blocks, false); 244 errdefer inner.deinit(allocator); 245 break :gutter try Gutter.init(allocator, marker, false, .{}, inner); 246 }; 247 try put(allocator, &list_box, .{ .markdown_gutter = gutter }); 248 } 249 try put(allocator, box, .{ .box = list_box }); 250 }, 251 // wider than any pane, and clipped to the width 252 .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 } }) }), 253 .raw => |lines| { 254 const text = try std.mem.join(b.page_arena.allocator(), "\n", lines); 255 try put(allocator, box, .{ .text_box = try wgt.TextBox.init(allocator, text, .{ .border = null, .wrap_kind = .none }) }); 256 }, 257 } 258 } 259 260 // styled spans for `inlines` over `base`, with each link resolved 261 fn runSpans(b: Builder, inlines: []const md.Inline, base: Grid.Style) ![]const RichText.Span { 262 const out = try b.page_arena.allocator().alloc(RichText.Span, inlines.len); 263 for (inlines, out) |run, *span| { 264 const link = if (run.link) |dest| try b.resolveLink(dest) else ""; 265 var style = base; 266 style.bold = style.bold or run.style.bold; 267 style.italic = run.style.italic; 268 style.strikethrough = run.style.strike; 269 if (run.style.code) style.fg = .{ .ansi = .yellow }; 270 if (link.len > 0) { 271 style.fg = .{ .ansi = .cyan }; 272 style.underline = true; 273 } 274 span.* = .{ .text = run.text, .style = style, .link = link }; 275 } 276 return out; 277 } 278 279 // the focus kind a link follows, or "" when it isn't clickable: web links 280 // are raw links, and a repo path is a files route relative to this file 281 fn resolveLink(b: Builder, dest: []const u8) ![]const u8 { 282 const aa = b.page_arena.allocator(); 283 for ([_][]const u8{ "http:", "https:", "mailto:" }) |scheme| { 284 if (std.ascii.startsWithIgnoreCase(dest, scheme)) return aa.print("{s}{s}", .{ ui.raw_link_prefix, dest }); 285 } 286 if (hasScheme(dest) or std.mem.startsWith(u8, dest, "//")) return ""; 287 const data = b.data orelse return ""; 288 const end = std.mem.indexOfAny(u8, dest, "?#") orelse dest.len; 289 if (end == 0) return ""; 290 const decoded = std.Uri.percentDecodeInPlace(try aa.dupe(u8, dest[0..end])); 291 292 var segments: std.ArrayList([]const u8) = .empty; 293 if (decoded[0] != '/') { 294 var base = std.mem.tokenizeScalar(u8, b.dir, '/'); 295 while (base.next()) |segment| try segments.append(aa, segment); 296 } 297 var parts = std.mem.tokenizeScalar(u8, decoded, '/'); 298 while (parts.next()) |part| { 299 if (std.mem.eql(u8, part, ".")) continue; 300 if (std.mem.eql(u8, part, "..")) { 301 // climbing above the root isn't a repo path 302 if (segments.pop() == null) return ""; 303 continue; 304 } 305 try segments.append(aa, part); 306 } 307 const path = try std.mem.join(aa, "/", segments.items); 308 const route = data.filesRoute(path, 0) orelse return ""; 309 return aa.print("a:{s}", .{try route.toUrl(b.page_arena)}); 310 } 311 }; 312 313 // whether any heading the title fonts would draw, however nested, has a char 314 // they lack, in which case none use them so they all look alike 315 fn hasUncoveredHeading(blocks: []const md.Block) bool { 316 for (blocks) |block| switch (block) { 317 .heading => |heading| if (heading.level <= 2) for (heading.inlines) |run| { 318 if (!ui.Title.covers(run.text) or !ui.SubTitle.covers(run.text)) return true; 319 }, 320 .quote => |inner| if (hasUncoveredHeading(inner)) return true, 321 .list => |list| for (list.items) |item| { 322 if (hasUncoveredHeading(item.blocks)) return true; 323 }, 324 else => {}, 325 }; 326 return false; 327 } 328 329 // whether `dest` starts with a url scheme 330 fn hasScheme(dest: []const u8) bool { 331 const colon = std.mem.indexOfScalar(u8, dest, ':') orelse return false; 332 if (colon == 0 or !std.ascii.isAlphabetic(dest[0])) return false; 333 for (dest[0..colon]) |c| { 334 if (!std.ascii.isAlphanumeric(c) and c != '+' and c != '.' and c != '-') return false; 335 } 336 return true; 337 } 338 339 fn put(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), widget: ui.Widget) !void { 340 var owned = widget; 341 errdefer owned.deinit(allocator); 342 try box.children.put(allocator, owned.getFocus().id, .{ .widget = owned, .rect = null, .min_size = null }); 343 } 344 345 // a heading in its level's title font, word-wrapped to the width, or bold 346 // text when there's no font for it. 347 pub const Heading = struct { 348 focus: *Focus, 349 text: RichText, 350 // the font this heading draws in, if any, and the text it draws 351 font: ?enum { title, sub_title } = null, 352 plain: []const u8 = "", 353 font_box: wgt.TextBox, 354 355 fn init(allocator: std.mem.Allocator, page_arena: *std.heap.ArenaAllocator, level: u3, spans: []const RichText.Span, fonts: bool) !Heading { 356 const focus = try Focus.create(allocator, .container); 357 errdefer focus.destroy(allocator); 358 359 // the bold fallback leads with the level's #s, so levels stay apart 360 const marked = try page_arena.allocator().alloc(RichText.Span, spans.len + 1); 361 marked[0] = .{ .text = try page_arena.allocator().print("{s} ", .{"######"[0..level]}), .style = .{ .bold = true } }; 362 @memcpy(marked[1..], spans); 363 var text = try RichText.init(allocator, marked); 364 errdefer text.deinit(allocator); 365 366 var font_box = try wgt.TextBox.init(allocator, "", .{ .border = null, .wrap_kind = .none }); 367 errdefer font_box.deinit(allocator); 368 var self: Heading = .{ .focus = focus, .text = text, .font_box = font_box }; 369 370 var plain: std.ArrayList(u8) = .empty; 371 var has_links = false; 372 for (spans) |span| { 373 try plain.appendSlice(page_arena.allocator(), span.text); 374 has_links = has_links or span.link.len > 0; 375 } 376 self.plain = std.mem.trim(u8, plain.items, " "); 377 if (fonts and level <= 2 and !has_links and self.plain.len > 0) self.font = if (level == 1) .title else .sub_title; 378 return self; 379 } 380 381 pub fn deinit(self: *Heading, allocator: std.mem.Allocator) void { 382 self.focus.destroy(allocator); 383 self.text.deinit(allocator); 384 self.font_box.deinit(allocator); 385 } 386 387 pub fn build(self: *Heading, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 388 self.clearGrid(); 389 self.focus.clear(); 390 if (try self.wrapFont(allocator, constraint.max_size.width)) { 391 try self.buildShown(allocator, constraint, root_focus, &self.font_box); 392 } else { 393 try self.buildShown(allocator, constraint, root_focus, &self.text); 394 } 395 } 396 397 // fill the font box with the heading wrapped at `max_width`, or return 398 // false when there's no font 399 fn wrapFont(self: *Heading, allocator: std.mem.Allocator, max_width: ?usize) !bool { 400 const font = self.font orelse return false; 401 // a glyph and the gap after it, so n glyphs take n * char_width - 1 columns 402 const char_width: usize = if (font == .title) 4 else 3; 403 const max_chars = if (max_width) |w| (w + 1) / char_width else null; 404 405 var arena = std.heap.ArenaAllocator.init(allocator); 406 defer arena.deinit(); 407 const aa = arena.allocator(); 408 // the fonts only cover ascii, so each byte is one char 409 const chars = try aa.alloc(u21, self.plain.len); 410 for (self.plain, chars) |c, *char| char.* = c; 411 var lines: std.ArrayList(wgt.Line) = .empty; 412 try wgt.wrapLines(aa, &lines, chars, .word, max_chars); 413 414 // a blank row between lines keeps the glyphs from touching 415 var out: std.ArrayList(u8) = .empty; 416 for (lines.items, 0..) |line, i| { 417 if (i > 0) try out.appendSlice(aa, "\n\n"); 418 const text = std.mem.trimEnd(u8, self.plain[line.start..line.end], " "); 419 try out.appendSlice(aa, switch (font) { 420 .title => (try ui.Title.init(&arena, text, .solid)).content, 421 .sub_title => (try ui.SubTitle.init(&arena, text)).content, 422 }); 423 } 424 try self.font_box.setContent(allocator, out.items); 425 return true; 426 } 427 428 fn buildShown(self: *Heading, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus, widget: anytype) !void { 429 try widget.build(allocator, constraint, root_focus); 430 const grid = widget.getGrid() orelse return; 431 try self.focus.addChild(allocator, widget.getFocus(), grid.size, 0, 0); 432 } 433 434 pub fn input(self: *Heading, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 435 _ = self; 436 _ = allocator; 437 _ = key; 438 _ = root_focus; 439 } 440 441 pub fn clearGrid(self: *Heading) void { 442 self.text.clearGrid(); 443 self.font_box.clearGrid(); 444 } 445 446 pub fn getGrid(self: Heading) ?Grid { 447 // only the one last built has a grid 448 return self.font_box.getGrid() orelse self.text.getGrid(); 449 } 450 451 pub fn getFocus(self: *Heading) *Focus { 452 return self.focus; 453 } 454 }; 455 456 // a marker column beside a child, so wrapped lines hang under the child's 457 // first line: a list item's bullet, or a quote's bar on every row. 458 pub const Gutter = struct { 459 focus: *Focus, 460 grid: ?Grid, 461 child: wgt.Box(ui.Widget), 462 marker: []const u8, 463 // draw the marker on every row rather than only the first 464 repeat: bool, 465 style: Grid.Style, 466 467 // takes ownership of `child` on success 468 fn init(allocator: std.mem.Allocator, marker: []const u8, repeat: bool, style: Grid.Style, child: wgt.Box(ui.Widget)) !Gutter { 469 return .{ .focus = try Focus.create(allocator, .container), .grid = null, .child = child, .marker = marker, .repeat = repeat, .style = style }; 470 } 471 472 pub fn deinit(self: *Gutter, allocator: std.mem.Allocator) void { 473 self.focus.destroy(allocator); 474 self.clearGrid(); 475 self.child.deinit(allocator); 476 } 477 478 pub fn build(self: *Gutter, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 479 self.clearGrid(); 480 self.focus.clear(); 481 const marker_width = try xitui.width.displayWidth(self.marker); 482 if (constraint.max_size.width) |w| if (w <= marker_width) return; 483 if (constraint.max_size.height == 0) return; 484 485 try self.child.build(allocator, .{ 486 .min_size = .{ .width = null, .height = null }, 487 .max_size = .{ .width = if (constraint.max_size.width) |w| w - marker_width else null, .height = constraint.max_size.height }, 488 }, root_focus); 489 const child_grid = self.child.getGrid(); 490 const child_size: layout.Size = if (child_grid) |g| g.size else .{ .width = 0, .height = 0 }; 491 492 var grid = try Grid.init(allocator, .{ .width = marker_width + child_size.width, .height = @max(1, child_size.height) }); 493 errdefer grid.deinit(); 494 for (0..if (self.repeat) grid.size.height else 1) |y| { 495 var x: usize = 0; 496 var utf8 = (try std.unicode.Utf8View.init(self.marker)).iterator(); 497 while (utf8.nextCodepoint()) |rune| { 498 (try grid.cell(x, y)).style = self.style; 499 try grid.setRune(x, y, rune); 500 x += xitui.width.cellWidth(rune); 501 } 502 } 503 if (child_grid) |g| { 504 try grid.drawGrid(g, marker_width, 0); 505 try self.focus.addChild(allocator, self.child.getFocus(), g.size, marker_width, 0); 506 } 507 self.grid = grid; 508 } 509 510 pub fn input(self: *Gutter, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 511 _ = self; 512 _ = allocator; 513 _ = key; 514 _ = root_focus; 515 } 516 517 pub fn clearGrid(self: *Gutter) void { 518 if (self.grid) |*grid| { 519 grid.deinit(); 520 self.grid = null; 521 } 522 self.child.clearGrid(); 523 } 524 525 pub fn getGrid(self: Gutter) ?Grid { 526 return self.grid; 527 } 528 529 pub fn getFocus(self: *Gutter) *Focus { 530 return self.focus; 531 } 532 }; 533