╭──────────╮ │← previous│ ╰──────────╯ ╭ changed ───────╮ │src/ui/Title.zig│ ╰────────────────╯ ╭──────────────────────────────────────────────────────────────────────────╮ │504 }, │ │505 else => null, │ │506 }; │ │507 +} │ │508 + │ │509 +// whether every char of `text` has a glyph │ │510 +pub fn covers(text: []const u8) bool { │ │511 + for (text) |c| { │ │512 + if (glyphFor(c) == null) return false; │ │513 + } │ │514 + return true; │ │515 } │ │516 │ │517 // map a 2x3 sub-pixel pattern to its corresponding sextant codepoint│ ╰──────────────────────────────────────────────────────────────────────────╯ ╭ changed ────────╮ │src/ui/widget.zig│ ╰─────────────────╯ ╭────────────────────────────────────────╮ │21 flow_box: FlowBox, │ │22 flow_box_scroll: FlowBox.Scroll,│ │23 word_flow: WordFlow, │ │24 + rich_text: RichText, │ │25 spacer: Spacer, │ │26 center: Center, │ │27 section_label: SectionLabel, │ ╰────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────╮ │45 repo_patches_header: ui.Repo.Patches.Header, │ │46 repo_discussions_header: ui.Repo.Discussions.Header,│ │47 repo_files: ui.Repo.Files.View, │ │48 + markdown: ui.Repo.Markdown.View, │ │49 + markdown_heading: ui.Repo.Markdown.Heading, │ │50 + markdown_gutter: ui.Repo.Markdown.Gutter, │ │51 repo_commits: ui.Repo.Commits.View, │ │52 diff_view: ui.Repo.Diff.View, │ │53 repo_refs: ui.Repo.Refs.View, │ ╰────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │624 while (i < rects.len and rects[i].y == y) i += 1; │ │625 } │ │626 return prev_first; │ │627 + } │ │628 +}; │ │629 + │ │630 +// a word-wrapped paragraph of styled runs. each line a link touches is a │ │631 +// focusable piece, and a focused link draws all its pieces inverted. │ │632 +pub const RichText = struct { │ │633 + focus: *Focus, │ │634 + grid: ?Grid, │ │635 + // backs the decoded content and the link targets │ │636 + arena: std.heap.ArenaAllocator, │ │637 + runes: []const u21, │ │638 + // each rune's span │ │639 + span_of: []const usize, │ │640 + styles: []const Grid.Style, │ │641 + // each span's link index, null outside a link │ │642 + span_links: []const ?usize, │ │643 + links: []const []const u8, │ │644 + // each link's piece focuses in line order, kept across builds so a link │ │645 + // keeps its ids when the text rewraps │ │646 + link_focuses: []std.ArrayList(*Focus), │ │647 + lines: std.ArrayList(wgt.Line), │ │648 + // scratch for each build's link pieces │ │649 + pieces: std.ArrayList(Piece), │ │650 + │ │651 + pub const Span = struct { │ │652 + text: []const u8, │ │653 + style: Grid.Style = .{}, │ │654 + // the focus kind a click or enter follows; empty for plain text │ │655 + link: []const u8 = "", │ │656 + }; │ │657 + │ │658 + const Piece = struct { │ │659 + link: usize, │ │660 + rect: layout.URect, │ │661 + }; │ │662 + │ │663 + pub fn init(allocator: std.mem.Allocator, spans: []const Span) !RichText { │ │664 + var arena = std.heap.ArenaAllocator.init(allocator); │ │665 + errdefer arena.deinit(); │ │666 + const aa = arena.allocator(); │ │667 + │ │668 + var runes: std.ArrayList(u21) = .empty; │ │669 + var span_of: std.ArrayList(usize) = .empty; │ │670 + const styles = try aa.alloc(Grid.Style, spans.len); │ │671 + const span_links = try aa.alloc(?usize, spans.len); │ │672 + var links: std.ArrayList([]const u8) = .empty; │ │673 + for (spans, 0..) |span, i| { │ │674 + styles[i] = span.style; │ │675 + span_links[i] = null; │ │676 + if (span.link.len > 0) { │ │677 + // adjacent spans with the same target are one link │ │678 + const same = i > 0 and spans[i - 1].link.len > 0 and std.mem.eql(u8, spans[i - 1].link, span.link); │ │679 + if (!same) try links.append(aa, try aa.dupe(u8, span.link)); │ │680 + span_links[i] = links.items.len - 1; │ │681 + } │ │682 + var utf8 = (try std.unicode.Utf8View.init(span.text)).iterator(); │ │683 + while (utf8.nextCodepoint()) |codepoint| { │ │684 + try runes.append(aa, codepoint); │ │685 + try span_of.append(aa, i); │ │686 + } │ │687 + } │ │688 + │ │689 + const link_focuses = try aa.alloc(std.ArrayList(*Focus), links.items.len); │ │690 + @memset(link_focuses, .empty); │ │691 + │ │692 + return .{ │ │693 + .focus = try Focus.create(allocator, .container), │ │694 + .grid = null, │ │695 + .arena = arena, │ │696 + .runes = runes.items, │ │697 + .span_of = span_of.items, │ │698 + .styles = styles, │ │699 + .span_links = span_links, │ │700 + .links = links.items, │ │701 + .link_focuses = link_focuses, │ │702 + .lines = .empty, │ │703 + .pieces = .empty, │ │704 + }; │ │705 + } │ │706 + │ │707 + pub fn deinit(self: *RichText, allocator: std.mem.Allocator) void { │ │708 + self.focus.destroy(allocator); │ │709 + self.clearGrid(); │ │710 + for (self.link_focuses) |*focuses| { │ │711 + for (focuses.items) |focus| focus.destroy(allocator); │ │712 + focuses.deinit(allocator); │ │713 + } │ │714 + self.pieces.deinit(allocator); │ │715 + self.lines.deinit(allocator); │ │716 + self.arena.deinit(); │ │717 + } │ │718 + │ │719 + // the url a terminal can open for a link: raw links to absolute urls │ │720 + fn terminalLink(link: []const u8) ?[]const u8 { │ │721 + if (!std.mem.startsWith(u8, link, ui.raw_link_prefix)) return null; │ │722 + const url = link[ui.raw_link_prefix.len..]; │ │723 + return if (std.mem.startsWith(u8, url, "/")) null else url; │ │724 + } │ │725 + │ │726 + // the link one of whose pieces has focus id `id` │ │727 + fn linkOf(self: *const RichText, id: ?usize) ?usize { │ │728 + for (self.link_focuses, 0..) |focuses, l| { │ │729 + for (focuses.items) |focus| if (focus.id == id) return l; │ │730 + } │ │731 + return null; │ │732 + } │ │733 + │ │734 + pub fn build(self: *RichText, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { │ │735 + self.clearGrid(); │ │736 + self.focus.clear(); │ │737 + const max_width = constraint.max_size.width; │ │738 + if (max_width == 0 or constraint.max_size.height == 0) return; │ │739 + const focused_link = self.linkOf(root_focus.grandchild_id); │ │740 + │ │741 + try wgt.wrapLines(allocator, &self.lines, self.runes, .word, max_width); │ │742 + // trailing spaces are dropped so a wrapped link doesn't highlight them │ │743 + for (self.lines.items) |*line| { │ │744 + while (line.end > line.start and self.runes[line.end - 1] == ' ') { │ │745 + line.end -= 1; │ │746 + line.width -= 1; │ │747 + } │ │748 + } │ │749 + const visible = @min(self.lines.items.len, constraint.max_size.height orelse self.lines.items.len); │ │750 + var width: usize = 1; │ │751 + for (self.lines.items[0..visible]) |line| width = @max(width, line.width); │ │752 + if (max_width) |w| width = @min(width, w); │ │753 + │ │754 + var grid = try Grid.init(allocator, .{ .width = width, .height = visible }); │ │755 + errdefer grid.deinit(); │ │756 + self.pieces.clearRetainingCapacity(); │ │757 + for (self.lines.items[0..visible], 0..) |line, y| { │ │758 + var x: usize = 0; │ │759 + for (self.runes[line.start..line.end], line.start..) |rune, i| { │ │760 + const rune_width = xitui.width.cellWidth(rune); │ │761 + if (x + rune_width > width) break; │ │762 + const link = self.span_links[self.span_of[i]]; │ │763 + const cell = try grid.cell(x, y); │ │764 + cell.style = self.styles[self.span_of[i]]; │ │765 + if (link) |l| { │ │766 + cell.link = terminalLink(self.links[l]); │ │767 + if (l == focused_link) cell.style.inverted = true; │ │768 + // extend this line's piece of the link, or start one │ │769 + const items = self.pieces.items; │ │770 + if (items.len > 0 and items[items.len - 1].link == l and items[items.len - 1].rect.y == y) { │ │771 + items[items.len - 1].rect.size.width += rune_width; │ │772 + } else { │ │773 + try self.pieces.append(allocator, .{ .link = l, .rect = .{ .x = x, .y = y, .size = .{ .width = rune_width, .height = 1 } } });│ │774 + } │ │775 + } │ │776 + try grid.setRune(x, y, rune); │ │777 + x += rune_width; │ │778 + } │ │779 + } │ │780 + │ │781 + // a link's pieces are consecutive, since its runes are │ │782 + var piece_index: usize = 0; │ │783 + for (self.pieces.items, 0..) |piece, i| { │ │784 + piece_index = if (i > 0 and self.pieces.items[i - 1].link == piece.link) piece_index + 1 else 0; │ │785 + const focuses = &self.link_focuses[piece.link]; │ │786 + if (piece_index == focuses.items.len) { │ │787 + const focus = try Focus.create(allocator, .{ .custom = self.links[piece.link] }); │ │788 + errdefer focus.destroy(allocator); │ │789 + focus.mode = .all; │ │790 + try focuses.append(allocator, focus); │ │791 + } │ │792 + try self.focus.addChild(allocator, focuses.items[piece_index], piece.rect.size, piece.rect.x, piece.rect.y); │ │793 + } │ │794 + │ │795 + // focus rests on a link's first piece, which stays while the link shows │ │796 + if (focused_link) |l| if (self.link_focuses[l].items.len > 0) { │ │797 + const first = self.link_focuses[l].items[0].id; │ │798 + if (self.focus.children.contains(first)) { │ │799 + root_focus.grandchild_id = first; │ │800 + self.focus.child_id = first; │ │801 + } │ │802 + }; │ │803 + │ │804 + self.grid = grid; │ │805 } │ │806 + │ │807 + pub fn input(self: *RichText, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { │ │808 + _ = self; │ │809 + _ = allocator; │ │810 + _ = key; │ │811 + _ = root_focus; │ │812 + } │ │813 + │ │814 + pub fn clearGrid(self: *RichText) void { │ │815 + if (self.grid) |*grid| { │ │816 + grid.deinit(); │ │817 + self.grid = null; │ │818 + } │ │819 + } │ │820 + │ │821 + pub fn getGrid(self: RichText) ?Grid { │ │822 + return self.grid; │ │823 + } │ │824 + │ │825 + pub fn getFocus(self: *RichText) *Focus { │ │826 + return self.focus; │ │827 + } │ │828 }; │ │829 │ │830 // an invisible widget that fills the horizontal space granted by its parent. │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ──╮ │src/web.zig│ ╰───────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────╮ │2036 try output.appendSlice(allocator, "<div class=\"grid-cells\">"); │ │2037 │ │2038 // emit this panel's grid as rows of text, coalescing adjacent cells that │ │2039 - // share a focus id and colors into one tag (a clickable span, a link, or a │ │2040 - // plain colored span). cells under a child scroll keep only their background.│ │2039 + // share a focus id and style into one tag (a clickable span, a link, or a │ │2040 + // plain styled span). cells under a child scroll keep only their background. │ │2041 + // a cell's own link is an anchor nested in that tag. │ │2042 for (0..grid.size.height) |y| { │ │2043 var cur_id: ?usize = null; │ │2043 - var cur_fg: ?Grid.Color = null; │ │2044 - var cur_bg: ?Grid.Color = null; │ │2045 - var cur_inverted = false; │ │2044 + var cur_style: Grid.Style = .{}; │ │2045 var open_tag: ?CellTag = null; │ │2046 + var open_link: ?[]const u8 = null; │ │2047 var first = true; │ │2048 for (0..grid.size.width) |x| { │ │2049 const cell = (try grid.cell(x, y)).*; │ ╰────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │2060 }; │ │2061 // the focusable owning the cell at (x, y); none for a covered cell. │ │2062 const cell_id = if (covered_by_scroll) null else owners[y * grid.size.width + x]; │ │2063 - const fg = if (covered_by_scroll) null else cell.style.fg; │ │2064 - const bg = cell.style.bg; │ │2065 - const inverted = !covered_by_scroll and cell.style.inverted; │ │2063 + const style: Grid.Style = if (covered_by_scroll) .{ .bg = cell.style.bg } else cell.style; │ │2064 │ │2067 - if (first or cell_id != cur_id or !std.meta.eql(fg, cur_fg) or !std.meta.eql(bg, cur_bg) or inverted != cur_inverted) {│ │2065 + if (first or cell_id != cur_id or !style.eql(cur_style)) { │ │2066 + if (open_link != null) try output.appendSlice(allocator, "</a>"); │ │2067 + open_link = null; │ │2068 if (open_tag) |t| try output.appendSlice(allocator, t.closeTag()); │ │2069 │ │2070 var new_tag: ?CellTag = null; │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │2076 }; │ │2077 } │ │2078 } │ │2079 - if (new_tag == null and (fg != null or bg != null or inverted)) new_tag = .plain; │ │2079 + if (new_tag == null and !style.eql(.{})) new_tag = .plain; │ │2080 │ │2081 if (new_tag) |t| { │ │2082 - var style_buf: [64]u8 = undefined; │ │2083 - try t.writeOpenTag(allocator, output, cell_id orelse 0, styleAttr(&style_buf, fg, bg, inverted));│ │2082 + var style_buf: [192]u8 = undefined; │ │2083 + try t.writeOpenTag(allocator, output, cell_id orelse 0, styleAttr(&style_buf, style)); │ │2084 } │ │2085 │ │2086 open_tag = new_tag; │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────╮ │2087 cur_id = cell_id; │ │2088 - cur_fg = fg; │ │2089 - cur_bg = bg; │ │2090 - cur_inverted = inverted;│ │2088 + cur_style = style; │ │2089 first = false; │ │2090 } │ │2091 │ ╰──────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │2092 + // an anchor can't nest in another, so a link tag's cells skip theirs │ │2093 + const in_anchor = if (open_tag) |t| t == .a else false; │ │2094 + const link = if (covered_by_scroll or in_anchor) null else webLink(cell.link); │ │2095 + if (!sameLink(link, open_link)) { │ │2096 + if (open_link != null) try output.appendSlice(allocator, "</a>"); │ │2097 + if (link) |url| { │ │2098 + try output.appendSlice(allocator, "<a class=\"cell-link\" tabindex=\"-1\" target=\"_blank\" rel=\"noopener noreferrer\" href=\"");│ │2099 + try appendEscapedHtml(allocator, output, url); │ │2100 + try output.appendSlice(allocator, "\">"); │ │2101 + } │ │2102 + open_link = link; │ │2103 + } │ │2104 + │ │2105 if (covered_by_scroll) { │ │2106 try appendEscapedHtml(allocator, output, " "); │ │2107 } else if (cell.continuation) { │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────╮ │2124 try appendEscapedHtml(allocator, output, " "); │ │2125 } │ │2126 } │ │2127 + if (open_link != null) try output.appendSlice(allocator, "</a>"); │ │2128 if (open_tag) |t| try output.appendSlice(allocator, t.closeTag());│ │2129 try output.append(allocator, '\n'); │ │2130 } │ ╰────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯