╭ changed ──╮ │src/web.zig│ ╰───────────╯ ╭───────────╮ │← all files│ ╰───────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────╮ │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 } │ ╰────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────╮ │2329 } │ │2330 │ │2331 // builds an HTML ` style="color:…;background-color:…"` attribute from a │ │2320 -// cell's fg/bg into `buf`, omitting whichever color is unset. an inverted │ │2332 +// cell's style into `buf`, omitting whichever color is unset. an inverted │ │2333 // cell swaps the two, falling back to the page's --fg/--bg variables for │ │2322 -// whichever side is unset. returns an empty slice when nothing is set. │ │2323 -fn styleAttr(buf: []u8, fg: ?Grid.Color, bg: ?Grid.Color, inverted: bool) []const u8 {│ │2324 - if (fg == null and bg == null and !inverted) return buf[0..0]; │ │2334 +// whichever side is unset. text attributes follow the colors. returns an │ │2335 +// empty slice when nothing is set. │ │2336 +fn styleAttr(buf: []u8, style: Grid.Style) []const u8 { │ │2337 + if (style.eql(.{})) return buf[0..0]; │ │2338 const prefix = " style=\""; │ │2339 @memcpy(buf[0..prefix.len], prefix); │ │2340 var i: usize = prefix.len; │ ╰────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │2328 - i = cssColor(buf, i, "color", if (inverted) bg else fg, if (inverted) "var(--bg)" else null) orelse return buf[0..0]; │ │2329 - i = cssColor(buf, i, "background-color", if (inverted) fg else bg, if (inverted) "var(--fg)" else null) orelse return buf[0..0]; │ │2341 + const inverted = style.inverted; │ │2342 + i = cssColor(buf, i, "color", if (inverted) style.bg else style.fg, if (inverted) "var(--bg)" else null) orelse return buf[0..0]; │ │2343 + i = cssColor(buf, i, "background-color", if (inverted) style.fg else style.bg, if (inverted) "var(--fg)" else null) orelse return buf[0..0];│ │2344 + const decoration: []const u8 = if (style.underline and style.strikethrough) │ │2345 + "text-decoration:underline line-through;" │ │2346 + else if (style.underline) │ │2347 + "text-decoration:underline;" │ │2348 + else if (style.strikethrough) │ │2349 + "text-decoration:line-through;" │ │2350 + else │ │2351 + ""; │ │2352 + const attributes = std.fmt.bufPrint(buf[i..], "{s}{s}{s}{s}", .{ │ │2353 + if (style.bold) "font-weight:bold;" else "", │ │2354 + if (style.italic) "font-style:italic;" else "", │ │2355 + decoration, │ │2356 + if (style.dim) "opacity:.6;" else "", │ │2357 + }) catch return buf[0..0]; │ │2358 + i += attributes.len; │ │2359 + if (i >= buf.len) return buf[0..0]; │ │2360 buf[i] = '"'; │ │2361 return buf[0 .. i + 1]; │ │2362 } │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │2375 return i + s.len; │ │2376 } │ │2377 │ │2378 +// a cell's link as an href, when it's a web or mail url │ │2379 +fn webLink(link: ?[]const u8) ?[]const u8 { │ │2380 + const url = link orelse return null; │ │2381 + for ([_][]const u8{ "http://", "https://", "mailto:" }) |scheme| { │ │2382 + if (std.ascii.startsWithIgnoreCase(url, scheme)) return url; │ │2383 + } │ │2384 + return null; │ │2385 +} │ │2386 + │ │2387 +fn sameLink(a: ?[]const u8, b: ?[]const u8) bool { │ │2388 + if (a) |x| return if (b) |y| std.mem.eql(u8, x, y) else false; │ │2389 + return b == null; │ │2390 +} │ │2391 + │ │2392 fn appendEscapedHtml(allocator: std.mem.Allocator, out: *std.ArrayList(u8), input: []const u8) !void {│ │2393 for (input) |ch| { │ │2394 switch (ch) { │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────╯