╭─────────────╮ │scroll to top│ ╰─────────────╯ ╭ web.zig ──────────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
1507 }); 1508 return; 1509 } 1510 1511 try request.respond("", .{ 1512 .status = .see_other, 1513 .extra_headers = &.{ 1514 .{ .name = "location", .value = "/events" }, 1515 .{ .name = "set-cookie", .value = sync_failure_cookie ++ "=; Path=/; Max-Age=0" }, 1516 }, 1517 }); 1518 } 1519 1520 fn renderIndexHtml( 1521 io: std.Io, 1522 allocator: std.mem.Allocator, 1523 host: Host, 1524 session_data: ui.Session.Data, 1525 ) ![]const u8 { 1526 const template = (findEmbed("/index.html") orelse return error.MissingIndexAsset).body; 1527 1528 var page_arena = std.heap.ArenaAllocator.init(allocator); 1529 defer page_arena.deinit(); 1530 1531 // the remote admin repo must outlive the page build, since the session's 1532 // moment reads from it. 1533 var repo_maybe: ?rp.Repo(.xit, evt.admin_repo_opts) = null; 1534 defer if (repo_maybe) |*repo| repo.deinit(io, allocator); 1535 1536 var session = switch (host) { 1537 .remote => |remote| blk: { 1538 // open the admin repo to read live user/repo data. 1539 repo_maybe = try rp.Repo(.xit, evt.admin_repo_opts).open(io, allocator, .{ .path = remote.admin_repo_path }); 1540 const repo = if (repo_maybe) |*repo| repo else unreachable; 1541 var session = try ui.Session.init(&page_arena, repo, session_data); 1542 // give the page builders filesystem access to the on-disk repos (a 1543 // sibling "repos" dir next to the admin repo) so the Repo page can 1544 // read its files. 1545 session.repos_dir = try std.fs.path.join(page_arena.allocator(), &.{ std.fs.path.dirname(remote.admin_repo_path) orelse ".", "repos" }); 1546 break :blk session; 1547 }, 1548 .local => |local| ui.Session{ 1549 .data = session_data, 1550 .arena = &page_arena, 1551 .page_arena = &page_arena, 1552 .local = local, 1553 }, 1554 }; 1555 session.io = io; 1556 1557 var page = try ui.Page.init(session.page_arena, &session, session.data.current_page); 1558 var root = try ui.initRoot(allocator, &page, &session); 1559 defer root.deinit(allocator); 1560 1561 const snapshot: ui.Snapshot = .{ .page = page, .session = session.data }; 1562 1563 const content = try generateHtml(allocator, &root, &session); 1564 defer allocator.free(content); 1565 1566 // serialize the snapshot so the wasm side can parse it back without making 1567 // a second request. it's embedded raw in a <script type="application/json"> 1568 // block, whose content is raw text terminated only by "</script". in json, 1569 // '<' appears solely inside string values, so escaping every '<' (and '>' 1570 // for good measure) to its \uXXXX form yields equivalent json that can't 1571 // break out of the tag. 1572 var json: std.Io.Writer.Allocating = .init(allocator); 1573 defer json.deinit(); 1574 try std.json.Stringify.value(snapshot, .{}, &json.writer); 1575 1576 var json_escaped: std.ArrayList(u8) = .empty; 1577 defer json_escaped.deinit(allocator); 1578 for (json.written()) |c| switch (c) { 1579 '<' => try json_escaped.appendSlice(allocator, "\\u003c"), 1580 '>' => try json_escaped.appendSlice(allocator, "\\u003e"), 1581 else => try json_escaped.append(allocator, c), 1582 }; 1583 1584 var out: std.ArrayList(u8) = .empty; 1585 errdefer out.deinit(allocator); 1586 { 1587 var cursor: usize = 0; 1588 for (&[_]struct { needle: []const u8, replacement: []const u8 }{ 1589 .{ .needle = "{{{ HAXY_HTML }}}", .replacement = content }, 1590 .{ .needle = "{{{ HAXY_JSON }}}", .replacement = json_escaped.items }, 1591 }) |sub| { 1592 const idx = std.mem.indexOfPos(u8, template, cursor, sub.needle) orelse return error.MissingTemplateToken; 1593 try out.appendSlice(allocator, template[cursor..idx]); 1594 try out.appendSlice(allocator, sub.replacement); 1595 cursor = idx + sub.needle.len; 1596 } 1597 try out.appendSlice(allocator, template[cursor..]); 1598 } 1599 return try out.toOwnedSlice(allocator); 1600 } 1601 1602 fn findEmbed(request_path: []const u8) ?Embed { 1603 const path = if (std.mem.eql(u8, request_path, "/")) 1604 "index.html" 1605 else if (request_path.len > 1 and request_path[0] == '/') 1606 request_path[1..] 1607 else 1608 return null; 1609 1610 for (embeds) |embed| { 1611 if (std.mem.eql(u8, path, embed.path)) return embed; 1612 } 1613 return null; 1614 } 1615 1616 // emits the TUI grid cells as static HTML. each web-native Scroll becomes its 1617 // own absolutely-positioned, natively-scrollable <div> holding its full content, 1618 // rendered recursively so nested scrolls work. 1619 pub fn generateHtml(allocator: std.mem.Allocator, root: *ui.Widget, session: *ui.Session) ![]const u8 { 1620 const grid = root.getGrid() orelse return error.MissingGrid; 1621 const root_focus = root.getFocus(); 1622 1623 var out: std.ArrayList(u8) = .empty; 1624 errdefer out.deinit(allocator); 1625 1626 switch (root.*) { 1627 .background => |*background| { 1628 if (background.art.getGrid()) |art| { 1629 var position_buf: [64]u8 = undefined; 1630 try out.appendSlice(allocator, try std.fmt.bufPrint(&position_buf, "<div class=\"ansi-art\" aria-hidden=\"true\" style=\"left:{d}ch\">", .{grid.size.width -| art.size.width})); 1631 try renderPanel(allocator, &out, background.art.getFocus(), art, session, root_focus); 1632 try out.appendSlice(allocator, "</div>"); 1633 } 1634 }, 1635 else => {}, 1636 } 1637 try out.appendSlice(allocator, "<div class=\"grid-content\" data-key=\"panel:root\">"); 1638 try renderPanel(allocator, &out, root_focus, grid, session, root_focus); 1639 try out.appendSlice(allocator, "</div>"); 1640 try renderForms(allocator, &out, root_focus); 1641 return try out.toOwnedSlice(allocator); 1642 } 1643 1644 // render one panel — a focus subtree and its content grid — as HTML, recursing 1645 // once per web-native Scroll inside it. the root panel is the whole page; each 1646 // Scroll becomes a nested, natively-scrollable div holding its full content. 1647 // emits the panel's grid cells as text, leaving holes where child scrolls sit, 1648 // then the child scroll divs over those holes. 1649 fn renderPanel(allocator: std.mem.Allocator, output: *std.ArrayList(u8), focus: *Focus, grid: Grid, session: *ui.Session, root_focus: *Focus) !void { 1650 // `direct` is this panel's scroll children, each drawn as an overlaid div. 1651 // `excluded` is every focus id that belongs to a scroll (the scroll nodes 1652 // plus their descendants, which the focus tree flattens into this panel's 1653 // children): the cell hit-test below skips them so this panel doesn't claim 1654 // cells that are really rendered inside a child scroll's own panel. 1655 // (scrolls aren't nested in practice, so every scroll child is direct here.) 1656 var direct: std.ArrayList(usize) = .empty; 1657 defer direct.deinit(allocator); 1658 var excluded: std.AutoHashMapUnmanaged(usize, void) = .empty; 1659 defer excluded.deinit(allocator); 1660 { 1661 var it = focus.children.iterator(); 1662 while (it.next()) |e| { 1663 if (e.value_ptr.focus.scroll == null) continue; 1664 try direct.append(allocator, e.key_ptr.*); 1665 try excluded.put(allocator, e.key_ptr.*, {}); 1666 var mit = e.value_ptr.focus.children.iterator(); 1667 while (mit.next()) |m| try excluded.put(allocator, m.key_ptr.*, {}); 1668 } 1669 } 1670 1671 // the HTML element a run of cells is wrapped in, chosen from the cell's 1672 // focusable kind 1673 const CellTag = union(enum) { 1674 span, // clickable focusable cell 1675 a: []const u8, // clickable focusable link 1676 plain, // non-focusable, colored 1677 1678 const a_prefix = "a:"; 1679 // an "in-page" anchor: rendered as a real <a href> so it works with js 1680 // disabled, but wasm handles it without navigating. 1681 fn init(str: []const u8) @This() { 1682 if (std.mem.startsWith(u8, str, ui.raw_link_prefix)) { 1683 return .{ .a = str[ui.raw_link_prefix.len..] }; 1684 } 1685 if (std.mem.startsWith(u8, str, ui.in_page_link_prefix)) { 1686 return .{ .a = str[ui.in_page_link_prefix.len..] }; 1687 } 1688 if (std.mem.startsWith(u8, str, a_prefix)) { 1689 return .{ .a = str[a_prefix.len..] }; 1690 } 1691 return .span; 1692 } 1693 1694 fn writeOpenTag(self: @This(), alloc: std.mem.Allocator, out: *std.ArrayList(u8), id: usize, style_attr: []const u8) !void { 1695 var id_buf: [32]u8 = undefined; 1696 switch (self) { 1697 .span => { 1698 try out.appendSlice(alloc, "<span class=\"clickable\" data-focus-id=\""); 1699 try out.appendSlice(alloc, try std.fmt.bufPrint(&id_buf, "{d}", .{id})); 1700 try out.appendSlice(alloc, "\""); 1701 try out.appendSlice(alloc, style_attr); 1702 try out.appendSlice(alloc, ">"); 1703 }, 1704 .a => |href| { 1705 // tabindex=-1 keeps these out of the browser's tab order 1706 try out.appendSlice(alloc, "<a class=\"clickable\" tabindex=\"-1\" data-focus-id=\""); 1707 try out.appendSlice(alloc, try std.fmt.bufPrint(&id_buf, "{d}", .{id})); 1708 try out.appendSlice(alloc, "\" href=\""); 1709 try appendEscapedHtml(alloc, out, href); 1710 try out.appendSlice(alloc, "\""); 1711 try out.appendSlice(alloc, style_attr); 1712 try out.appendSlice(alloc, ">"); 1713 }, 1714 .plain => { 1715 try out.appendSlice(alloc, "<span"); 1716 try out.appendSlice(alloc, style_attr); 1717 try out.appendSlice(alloc, ">"); 1718 }, 1719 } 1720 } 1721 1722 fn closeTag(self: @This()) []const u8 { 1723 return switch (self) { 1724 .span, .plain => "</span>", 1725 .a => "</a>", 1726 }; 1727 } 1728 }; 1729 1730 // the cells are the volatile part of a panel. js replaces this one subtree 1731 // wholesale while retaining the panel's native controls and scroll shells. 1732 try output.appendSlice(allocator, "<div class=\"grid-cells\">"); 1733 1734 // emit this panel's grid as rows of text, coalescing adjacent cells that 1735 // share a focus id and colors into one tag (a clickable span, a link, or a 1736 // plain colored span). cells under a child scroll keep only their background. 1737 for (0..grid.size.height) |y| { 1738 var cur_id: ?usize = null; 1739 var cur_fg: ?Grid.Color = null; 1740 var cur_bg: ?Grid.Color = null; 1741 var open_tag: ?CellTag = null; 1742 var first = true; 1743 for (0..grid.size.width) |x| { 1744 const cell = (try grid.cell(x, y)).*; 1745 // a cell covered by a child scroll's viewport is drawn by that scroll's 1746 // own div, so blank its glyph and make it non-clickable here — but keep 1747 // its background, so the backdrop still shows through the scroll's 1748 // transparent div instead of a bare hole. 1749 const covered_by_scroll = blk: { 1750 for (direct.items) |id| { 1751 const r = (focus.children.get(id) orelse continue).rect; 1752 if (x >= r.x and y >= r.y and x < r.x + r.size.width and y < r.y + r.size.height) break :blk true; 1753 } 1754 break :blk false; 1755 }; 1756 // the focusable cell at (x, y) among this panel's own focusables (skipping 1757 // any that belong to a child scroll, whose cells are drawn in their own 1758 // panel); none for a covered cell. 1759 const cell_id = if (covered_by_scroll) null else blk: { 1760 var iter = focus.children.iterator(); 1761 while (iter.next()) |entry| { 1762 const child = entry.value_ptr.*; 1763 if (!child.focus.focusable) continue; 1764 if (excluded.contains(entry.key_ptr.*)) continue; 1765 const r = child.rect; 1766 if (x >= r.x and y >= r.y and x < r.x + r.size.width and y < r.y + r.size.height) { 1767 break :blk entry.key_ptr.*; 1768 } 1769 } 1770 break :blk null; 1771 }; 1772 const fg = if (covered_by_scroll) null else cell.style.fg; 1773 const bg = cell.style.bg; 1774 1775 if (first or cell_id != cur_id or !colorEql(fg, cur_fg) or !colorEql(bg, cur_bg)) { 1776 if (open_tag) |t| try output.appendSlice(allocator, t.closeTag()); 1777 1778 var new_tag: ?CellTag = null; 1779 if (cell_id) |id| { 1780 if (focus.children.get(id)) |child| { 1781 new_tag = switch (child.focus.kind) { 1782 .custom => |custom| CellTag.init(custom), 1783 else => .span, 1784 }; 1785 } 1786 } 1787 if (new_tag == null and (fg != null or bg != null)) new_tag = .plain; 1788 1789 if (new_tag) |t| { 1790 var style_buf: [64]u8 = undefined; 1791 try t.writeOpenTag(allocator, output, cell_id orelse 0, styleAttr(&style_buf, fg, bg)); 1792 } 1793 1794 open_tag = new_tag; 1795 cur_id = cell_id; 1796 cur_fg = fg; 1797 cur_bg = bg; 1798 first = false; 1799 } 1800 1801 if (covered_by_scroll) { 1802 try appendEscapedHtml(allocator, output, " "); 1803 } else if (cell.continuation) { 1804 // the wide rune to the left was emitted 2ch wide, covering this column 1805 } else if (cell.rune) |rune| { 1806 var encoded: [4]u8 = undefined; 1807 const encoded_len = try std.unicode.utf8Encode(rune, &encoded); 1808 const rune_text = encoded[0..encoded_len]; 1809 // a double-width rune is pinned inside a 2ch span so the 1810 // fallback glyph's advance width can't shift the rest of the row 1811 const wide = x + 1 < grid.size.width and (try grid.cell(x + 1, y)).continuation; 1812 if (wide) { 1813 try output.appendSlice(allocator, "<span class=\"w2\">"); 1814 try appendEscapedHtml(allocator, output, rune_text); 1815 try output.appendSlice(allocator, "</span>"); 1816 } else { 1817 try appendEscapedHtml(allocator, output, rune_text); 1818 } 1819 } else { 1820 try appendEscapedHtml(allocator, output, " "); 1821 } 1822 } 1823 if (open_tag) |t| try output.appendSlice(allocator, t.closeTag()); 1824 try output.append(allocator, '\n'); 1825 } 1826 try output.appendSlice(allocator, "</div>"); 1827 1828 // native controls live in their panel, beside the replaceable cells. their 1829 // stable keys let JS update layout without recreating their DOM nodes, and 1830 // being actual descendants of a scroll gives them native movement/clipping. 1831 try renderPanelControls(allocator, output, focus, session, root_focus, &excluded); 1832 1833 // each child scroll becomes a natively-scrollable div positioned over its 1834 // viewport, holding its full content rendered recursively. 1835 for (direct.items) |id| { 1836 const child = focus.children.get(id) orelse continue; 1837 const info = child.focus.scroll orelse continue; 1838 const r = child.rect; 1839 // overflow only on the axes the widget scrolls; `auto` shows a bar only 1840 // when that axis actually overflows. the other axis is clipped (hidden), 1841 // matching how the terminal Scroll handles its non-scrolling axis. 1842 const overflow = switch (info.direction) { 1843 .vert => "overflow-x:hidden;overflow-y:auto", 1844 .horiz => "overflow-x:auto;overflow-y:hidden", 1845 .both => "overflow:auto", 1846 }; 1847 // the id carries a content version so JS preserves the native scroll 1848 // position across re-renders of the same content but resets it when the 1849 // content is replaced — e.g. selecting a different commit. the widget's 1850 // scroll offset (in cells) rides along so JS can apply it when it has no 1851 // preserved position, letting a wasm-side scrollToRect (the files list 1852 // scrolling to its selected row on page load) reach the browser. 1853 var buf: [256]u8 = undefined; 1854 try output.appendSlice(allocator, try std.fmt.bufPrint(&buf, "<div class=\"scroll\" data-key=\"scroll:{d}\" data-scroll-id=\"{d}-{d}\" data-scroll-direction=\"{s}\" data-scroll-x=\"{d}\" data-scroll-y=\"{d}\" style=\"left:{d}ch;top:{d}em;width:{d}ch;height:{d}em;{s}\">", .{ id, id, child.focus.version, @tagName(info.direction), info.offset_x, info.offset_y, r.x, r.y, r.size.width, r.size.height, overflow })); 1855 try renderPanel(allocator, output, child.focus, info.content, session, root_focus); 1856 try output.appendSlice(allocator, "</div>"); 1857 } 1858 } 1859 1860 const form_prefix = "form:"; 1861 const form_id_prefix = "haxy-form-"; 1862 1863 fn formAction(focus: *const Focus) ?[]const u8 { 1864 return switch (focus.kind) { 1865 .custom => |custom| if (std.mem.startsWith(u8, custom, form_prefix)) custom[form_prefix.len..] else null, 1866 else => null, 1867 }; 1868 } 1869 1870 // find the form ancestor of a flattened focus-tree child. html's `form` 1871 // attribute then associates the control with that form without requiring the 1872 // form and control to share a DOM parent. 1873 fn formOwner(root_focus: *Focus, focus_id: usize) ?usize { 1874 var id = focus_id; 1875 while (root_focus.children.get(id)) |child| { 1876 const parent_id = child.parent_id; 1877 if (parent_id == root_focus.id) return if (formAction(root_focus) != null) root_focus.id else null; 1878 const parent = root_focus.children.get(parent_id) orelse return null; 1879 if (formAction(parent.focus) != null) return parent_id; 1880 id = parent_id; 1881 } 1882 return null; 1883 } 1884 1885 fn appendFormAttribute(allocator: std.mem.Allocator, output: *std.ArrayList(u8), root_focus: *Focus, focus_id: usize) !void { 1886 const form_id = formOwner(root_focus, focus_id) orelse return; 1887 var buf: [64]u8 = undefined; 1888 try output.appendSlice(allocator, try std.fmt.bufPrint(&buf, " form=\"" ++ form_id_prefix ++ "{d}\"", .{form_id})); 1889 } 1890 1891 fn renderPanelControls( 1892 allocator: std.mem.Allocator, 1893 output: *std.ArrayList(u8), 1894 focus: *Focus, 1895 session: *ui.Session, 1896 root_focus: *Focus, 1897 excluded: *const std.AutoHashMapUnmanaged(usize, void), 1898 ) !void { 1899 var iter = focus.children.iterator(); 1900 while (iter.next()) |entry| { 1901 const id = entry.key_ptr.*; 1902 if (excluded.contains(id)) continue; 1903 const child = entry.value_ptr.*; 1904 const r = child.rect; 1905 1906 switch (child.focus.kind) { 1907 .text_input, .text_input_password, .text_area => { 1908 const ti = session.text_inputs.get(id) orelse continue; 1909 1910 const inner_left = r.x + 1; 1911 const inner_top = r.y + 1; 1912 const inner_width = if (r.size.width > 2) r.size.width - 2 else 0; 1913 const preserve = !ti.options.read_only; 1914 1915 if (child.focus.kind == .text_area) { 1916 const value = try ti.text(allocator); 1917 defer allocator.free(value); 1918 var id_buf: [128]u8 = undefined; 1919 try output.appendSlice(allocator, try std.fmt.bufPrint(&id_buf, "<textarea data-key=\"control:{d}\" data-focus-id=\"{d}\"", .{ id, id })); 1920 if (preserve) try output.appendSlice(allocator, " data-preserve-value") else try output.appendSlice(allocator, " readonly"); 1921 try appendFormAttribute(allocator, output, root_focus, id); 1922 if (ti.options.name.len > 0) { 1923 try output.appendSlice(allocator, " name=\""); 1924 try appendEscapedHtml(allocator, output, ti.options.name); 1925 try output.append(allocator, '"'); 1926 } 1927 const inner_height = if (r.size.height > 2) r.size.height - 2 else 0; 1928 var pos_buf: [128]u8 = undefined; 1929 try output.appendSlice(allocator, try std.fmt.bufPrint(&pos_buf, " style=\"left:{d}ch;top:{d}em;width:{d}ch;height:{d}em\">\n", .{ inner_left, inner_top, inner_width, inner_height })); 1930 try appendEscapedHtml(allocator, output, value); 1931 try output.appendSlice(allocator, "</textarea>"); 1932 continue; 1933 } 1934 1935 var id_buf: [160]u8 = undefined; 1936 try output.appendSlice(allocator, try std.fmt.bufPrint(&id_buf, "<input type=\"{s}\" data-key=\"control:{d}\" data-focus-id=\"{d}\"", .{ if (child.focus.kind == .text_input_password) "password" else "text", id, id })); 1937 if (preserve) try output.appendSlice(allocator, " data-preserve-value") else try output.appendSlice(allocator, " readonly"); 1938 try appendFormAttribute(allocator, output, root_focus, id); 1939 if (ti.options.name.len > 0) { 1940 try output.appendSlice(allocator, " name=\""); 1941 try appendEscapedHtml(allocator, output, ti.options.name); 1942 try output.append(allocator, '"'); 1943 } 1944 if (child.focus.kind != .text_input_password) { 1945 const value = try ti.text(allocator); 1946 defer allocator.free(value); 1947 try output.appendSlice(allocator, " value=\""); 1948 try appendEscapedHtml(allocator, output, value); 1949 try output.append(allocator, '"'); 1950 } 1951 var pos_buf: [96]u8 = undefined; 1952 try output.appendSlice(allocator, try std.fmt.bufPrint(&pos_buf, " style=\"left:{d}ch;top:{d}em;width:{d}ch;height:1em\">", .{ inner_left, inner_top, inner_width })); 1953 }, 1954 .custom => |custom| { 1955 if (std.mem.eql(u8, custom, "submit") or std.mem.startsWith(u8, custom, ui.submit_action_prefix)) { 1956 if (formOwner(root_focus, id) == null) continue; 1957 var id_buf: [128]u8 = undefined; 1958 try output.appendSlice(allocator, try std.fmt.bufPrint(&id_buf, "<button type=\"submit\" data-key=\"control:{d}\" data-focus-id=\"{d}\"", .{ id, id })); 1959 try appendFormAttribute(allocator, output, root_focus, id); 1960 if (std.mem.startsWith(u8, custom, ui.submit_action_prefix)) { 1961 try output.appendSlice(allocator, " formaction=\""); 1962 try appendEscapedHtml(allocator, output, custom[ui.submit_action_prefix.len..]); 1963 try output.append(allocator, '"'); 1964 } 1965 var pos_buf: [128]u8 = undefined; 1966 try output.appendSlice(allocator, try std.fmt.bufPrint(&pos_buf, " style=\"left:{d}ch;top:{d}em;width:{d}ch;height:{d}em\"></button>", .{ r.x, r.y, r.size.width, r.size.height })); 1967 } else if (std.mem.startsWith(u8, custom, ui.file_input_prefix)) { 1968 try output.appendSlice(allocator, "<input type=\"file\" data-preserve-value data-action=\""); 1969 try appendEscapedHtml(allocator, output, custom[ui.file_input_prefix.len..]); 1970 var id_buf: [128]u8 = undefined; 1971 try output.appendSlice(allocator, try std.fmt.bufPrint(&id_buf, "\" data-key=\"control:{d}\" data-focus-id=\"{d}\" style=\"opacity:0;left:{d}ch;top:{d}em;width:{d}ch;height:{d}em\">", .{ id, id, r.x, r.y, r.size.width, r.size.height })); 1972 } 1973 }, 1974 else => {}, 1975 } 1976 } 1977 } 1978 1979 // emit hidden form owners beside the rendered panels. their controls point 1980 // back here with the standard HTML `form` attribute. 1981 fn renderForms(allocator: std.mem.Allocator, output: *std.ArrayList(u8), root_focus: *Focus) !void { 1982 // the focus child map preserves insertion order, which matches the order 1983 // widgets were added in code 1984 var iter = root_focus.children.iterator(); 1985 while (iter.next()) |entry| { 1986 const child = entry.value_ptr.*; 1987 const action_url = formAction(child.focus) orelse continue; 1988 1989 try output.appendSlice(allocator, "<form hidden data-key=\"form:"); 1990 var id_buf: [96]u8 = undefined; 1991 try output.appendSlice(allocator, try std.fmt.bufPrint(&id_buf, "{d}\" id=\"" ++ form_id_prefix ++ "{d}\" action=\"", .{ entry.key_ptr.*, entry.key_ptr.* })); 1992 try appendEscapedHtml(allocator, output, action_url); 1993 try output.appendSlice(allocator, "\" method=\"post\"></form>"); 1994 } 1995 } 1996 1997 fn colorEql(a: ?Grid.Color, b: ?Grid.Color) bool { 1998 if (a) |av| return if (b) |bv| av.eql(bv) else false; 1999 return b == null; 2000 } 2001 2002 // builds an HTML ` style="color:#rrggbb;background-color:#rrggbb"` attribute 2003 // from a cell's fg/bg into `buf`, omitting whichever color is unset. returns an 2004 // empty slice when neither is set. 2005 fn styleAttr(buf: []u8, fg: ?Grid.Color, bg: ?Grid.Color) []const u8 { 2006 if (fg == null and bg == null) return buf[0..0]; 2007 const prefix = " style=\""; 2008 @memcpy(buf[0..prefix.len], prefix); 2009 var i: usize = prefix.len; 2010 if (fg) |c| { 2011 const s = std.fmt.bufPrint(buf[i..], "color:#{x:0>2}{x:0>2}{x:0>2};", .{ c.r, c.g, c.b }) catch return buf[0..0]; 2012 i += s.len; 2013 } 2014 if (bg) |c| { 2015 const s = std.fmt.bufPrint(buf[i..], "background-color:#{x:0>2}{x:0>2}{x:0>2};", .{ c.r, c.g, c.b }) catch return buf[0..0]; 2016 i += s.len; 2017 } 2018 buf[i] = '"'; 2019 i += 1; 2020 return buf[0..i]; 2021 } 2022 2023 fn appendEscapedHtml(allocator: std.mem.Allocator, out: *std.ArrayList(u8), input: []const u8) !void { 2024 for (input) |ch| { 2025 switch (ch) { 2026 '&' => try out.appendSlice(allocator, "&amp;"), 2027 '<' => try out.appendSlice(allocator, "&lt;"), 2028 '>' => try out.appendSlice(allocator, "&gt;"), 2029 '"' => try out.appendSlice(allocator, "&quot;"), 2030 '\'' => try out.appendSlice(allocator, "&#39;"), 2031 else => try out.append(allocator, ch), 2032 } 2033 } 2034 } 2035 2036 // --- helpers -------------------------------------------------------------- 2037 2038 fn getCookieValue(request: *std.http.Server.Request, name: []const u8) ?[]const u8 { 2039 var iter = request.iterateHeaders(); 2040 while (iter.next()) |header| { 2041 if (!std.ascii.eqlIgnoreCase(header.name, "cookie")) continue; 2042 var pairs = std.mem.splitScalar(u8, header.value, ';'); 2043 while (pairs.next()) |pair| { 2044 const trimmed = std.mem.trim(u8, pair, " \t"); 2045 const eq = std.mem.indexOfScalar(u8, trimmed, '=') orelse continue; 2046 if (std.mem.eql(u8, trimmed[0..eq], name)) { 2047 return trimmed[eq + 1 ..]; 2048 } 2049 } 2050 } 2051 return null; 2052 } 2053 2054 fn parseFormField(allocator: std.mem.Allocator, body: []const u8, key: []const u8) !?[]u8 { 2055 var iter = std.mem.splitScalar(u8, body, '&'); 2056 while (iter.next()) |pair| { 2057 const eq = std.mem.indexOfScalar(u8, pair, '=') orelse continue; 2058 if (std.mem.eql(u8, pair[0..eq], key)) { 2059 return try decodeFormValue(allocator, pair[eq + 1 ..]); 2060 } 2061 } 2062 return null; 2063 } 2064 2065 fn readFormBody(request: *std.http.Server.Request, allocator: std.mem.Allocator) ![]u8 { 2066 var buffer: [256]u8 = undefined; 2067 return request.readerExpectNone(&buffer).allocRemaining(allocator, .limited(65536)); 2068 } 2069 2070 fn decodeFormValue(allocator: std.mem.Allocator, encoded: []const u8) ![]u8 { 2071 var out: std.ArrayList(u8) = .empty; 2072 errdefer out.deinit(allocator); 2073 var i: usize = 0; 2074 while (i < encoded.len) : (i += 1) { 2075 switch (encoded[i]) { 2076 '+' => try out.append(allocator, ' '), 2077 '%' => { 2078 if (i + 2 >= encoded.len) return error.InvalidPercentEncoding; 2079 const hi = std.fmt.charToDigit(encoded[i + 1], 16) catch return error.InvalidPercentEncoding; 2080 const lo = std.fmt.charToDigit(encoded[i + 2], 16) catch return error.InvalidPercentEncoding; 2081 try out.append(allocator, hi * 16 + lo); 2082 i += 2; 2083 }, 2084 else => try out.append(allocator, encoded[i]), 2085 } 2086 } 2087 return try out.toOwnedSlice(allocator); 2088 } 2089 2090 // disk-backed web session store. each login mints a random, opaque token 2091 // (not the user's id) that is stored as a file named by the token hex, whose 2092 // contents are the raw user_id bytes. authenticating a request is a lookup of 2093 // the cookie's token; logout deletes the file, revoking the session. there is 2094 // deliberately no expiry — a session lives until logout. 2095 pub const SessionStore = struct { 2096 io: std.Io, 2097 dir: std.Io.Dir, 2098 auto_login: ?[token_hex_len]u8, 2099 2100 pub const token_hex_len = evt.event_id_size * 2; 2101 2102 pub fn init(io: std.Io, data_dir: std.Io.Dir) !SessionStore { 2103 try data_dir.createDirPath(io, "sessions"); 2104 const dir = try data_dir.openDir(io, "sessions", .{}); 2105 const auto_login = blk: { 2106 const file = dir.openFile(io, auto_login_name, .{ .mode = .read_only }) catch break :blk null; 2107 defer file.close(io); 2108 var token: [token_hex_len]u8 = undefined; 2109 var storage: [token_hex_len]u8 = undefined; 2110 var reader = file.reader(io, &storage); 2111 reader.interface.readSliceAll(&token) catch break :blk null; 2112 break :blk token; 2113 }; 2114 return .{ .io = io, .dir = dir, .auto_login = auto_login }; 2115 } 2116 2117 pub fn deinit(self: SessionStore) void { 2118 self.dir.close(self.io); 2119 } 2120 2121 // mint a new session for user_id, returning the token hex to set as the 2122 // cookie value. 2123 pub fn create(self: SessionStore, user_id: *const [evt.event_id_size]u8) ![token_hex_len]u8 { 2124 var token: [evt.event_id_size]u8 = undefined; 2125 self.io.random(&token); 2126 const token_hex = std.fmt.bytesToHex(token, .lower); 2127 const file = try self.dir.createFile(self.io, &token_hex, .{}); 2128 defer file.close(self.io); 2129 try file.writeStreamingAll(self.io, user_id); 2130 return token_hex; 2131 } 2132 2133 // resolve a cookie's token to its user_id. returns false (logged out) for a 2134 // missing, malformed, or unknown token. 2135 pub fn lookup(self: SessionStore, token_hex: []const u8, out: *[evt.event_id_size]u8) bool { 2136 if (!isToken(token_hex)) return false; 2137 const file = self.dir.openFile(self.io, token_hex, .{ .mode = .read_only }) catch return false; 2138 defer file.close(self.io); 2139 var storage: [evt.event_id_size]u8 = undefined; 2140 var file_reader = file.reader(self.io, &storage); 2141 file_reader.interface.readSliceAll(out) catch return false; 2142 return true; 2143 } 2144 2145 // revoke a session. a no-op for a missing or malformed token. 2146 pub fn remove(self: SessionStore, token_hex: []const u8) void { 2147 if (!isToken(token_hex)) return; 2148 self.dir.deleteFile(self.io, token_hex) catch {}; 2149 } 2150 2151 // leave `token_hex`'s session for visitors arriving without one. only the 2152 // `try` fixture writes one. the name can't collide with a session token. 2153 pub fn offerAutoLogin(self: SessionStore, token_hex: *const [token_hex_len]u8) !void { 2154 const file = try self.dir.createFile(self.io, auto_login_name, .{}); 2155 defer file.close(self.io); 2156 try file.writeStreamingAll(self.io, token_hex); 2157 } 2158 2159 pub fn autoLogin(self: SessionStore, out: *[token_hex_len]u8) bool { 2160 out.* = self.auto_login orelse return false; 2161 return true; 2162 } 2163 2164 const auto_login_name = "auto-login"; 2165 2166 // a token is exactly token_hex_len hex chars. validating before using the 2167 // value as a path keeps attacker-supplied cookies from escaping the 2168 // sessions dir (e.g. via "/" or ".." bytes). 2169 fn isToken(token_hex: []const u8) bool { 2170 if (token_hex.len != token_hex_len) return false; 2171 for (token_hex) |c| if (!std.ascii.isHex(c)) return false; 2172 return true; 2173 } 2174 }; 2175