╭ changed ──╮ │src/web.zig│ ╰───────────╯ ╭───────────╮ │← all files│ ╰───────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────╮ │1498 │ │1499 const content = try generateHtml(allocator, &root, &session); │ │1500 defer allocator.free(content); │ │1501 - const overlay = try generateOverlay(allocator, &root, &session); │ │1502 - defer allocator.free(overlay); │ │1501 │ │1502 // serialize the snapshot so the wasm side can parse it back without making │ │1503 // a second request. it's embedded raw in a <script type="application/json">│ ╰──────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │1523 var cursor: usize = 0; │ │1524 for (&[_]struct { needle: []const u8, replacement: []const u8 }{ │ │1525 .{ .needle = "{{{ HAXY_HTML }}}", .replacement = content }, │ │1528 - .{ .needle = "{{{ HAXY_OVERLAY }}}", .replacement = overlay }, │ │1526 .{ .needle = "{{{ HAXY_JSON }}}", .replacement = json_escaped.items }, │ │1527 }) |sub| { │ │1528 const idx = std.mem.indexOfPos(u8, template, cursor, sub.needle) orelse return error.MissingTemplateToken;│ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │1554 // rendered recursively so nested scrolls work. │ │1555 pub fn generateHtml(allocator: std.mem.Allocator, root: *ui.Widget, session: *ui.Session) ![]const u8 {│ │1556 const grid = root.getGrid() orelse return error.MissingGrid; │ │1557 + const root_focus = root.getFocus(); │ │1558 │ │1559 var out: std.ArrayList(u8) = .empty; │ │1560 errdefer out.deinit(allocator); │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │1564 if (background.art.getGrid()) |art| { │ │1565 var position_buf: [64]u8 = undefined; │ │1566 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}));│ │1569 - try renderPanel(allocator, &out, background.art.getFocus(), art, session); │ │1567 + try renderPanel(allocator, &out, background.art.getFocus(), art, session, root_focus); │ │1568 try out.appendSlice(allocator, "</div>"); │ │1569 } │ │1570 }, │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────╮ │1571 else => {}, │ │1572 } │ │1575 - try out.appendSlice(allocator, "<div class=\"grid-content\">"); │ │1576 - try renderPanel(allocator, &out, root.getFocus(), grid, session); │ │1573 + try out.appendSlice(allocator, "<div class=\"grid-content\" data-key=\"panel:root\">");│ │1574 + try renderPanel(allocator, &out, root_focus, grid, session, root_focus); │ │1575 try out.appendSlice(allocator, "</div>"); │ │1576 + try renderForms(allocator, &out, root_focus); │ │1577 return try out.toOwnedSlice(allocator); │ │1578 } │ │1579 │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │1582 // Scroll becomes a nested, natively-scrollable div holding its full content. │ │1583 // emits the panel's grid cells as text, leaving holes where child scrolls sit, │ │1584 // then the child scroll divs over those holes. │ │1586 -fn renderPanel(allocator: std.mem.Allocator, output: *std.ArrayList(u8), focus: *Focus, grid: Grid, session: *ui.Session) !void { │ │1585 +fn renderPanel(allocator: std.mem.Allocator, output: *std.ArrayList(u8), focus: *Focus, grid: Grid, session: *ui.Session, root_focus: *Focus) !void {│ │1586 // `direct` is this panel's scroll children, each drawn as an overlaid div. │ │1587 // `excluded` is every focus id that belongs to a scroll (the scroll nodes │ │1588 // plus their descendants, which the focus tree flattens into this panel's │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────╮ │1663 } │ │1664 }; │ │1665 │ │1666 + // the cells are the volatile part of a panel. js replaces this one subtree │ │1667 + // wholesale while retaining the panel's native controls and scroll shells. │ │1668 + try output.appendSlice(allocator, "<div class=\"grid-cells\">"); │ │1669 + │ │1670 // emit this panel's grid as rows of text, coalescing adjacent cells that │ │1671 // share a focus id and colors into one tag (a clickable span, a link, or a │ │1672 // plain colored span). cells under a child scroll keep only their background.│ ╰────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │1759 if (open_tag) |t| try output.appendSlice(allocator, t.closeTag()); │ │1760 try output.append(allocator, '\n'); │ │1761 } │ │1762 + try output.appendSlice(allocator, "</div>"); │ │1763 │ │1760 - // read-only inputs have no live form state to preserve, so emit them in │ │1761 - // their panel and let a native scroll move and clip them with the grid. │ │1762 - var input_iter = focus.children.iterator(); │ │1763 - while (input_iter.next()) |input_entry| { │ │1764 - const input_id = input_entry.key_ptr.*; │ │1765 - if (excluded.contains(input_id)) continue; │ │1766 - const child = input_entry.value_ptr.*; │ │1767 - switch (child.focus.kind) { │ │1768 - .text_input => {}, │ │1769 - else => continue, │ │1770 - } │ │1771 - const text_input = session.text_inputs.get(input_id) orelse continue; │ │1772 - if (!text_input.options.read_only) continue; │ │1773 - const value = session.input_values.get(input_id) orelse continue; │ │1774 - const r = child.rect; │ │1775 - const inner_width = if (r.size.width > 2) r.size.width - 2 else 0; │ │1776 - │ │1777 - try output.appendSlice(allocator, "<input type=\"text\" readonly data-focus-id=\""); │ │1778 - var id_buf: [32]u8 = undefined; │ │1779 - try output.appendSlice(allocator, try std.fmt.bufPrint(&id_buf, "{d}", .{input_id})); │ │1780 - try output.appendSlice(allocator, "\" value=\""); │ │1781 - try appendEscapedHtml(allocator, output, value); │ │1782 - try output.appendSlice(allocator, "\" style=\"left:"); │ │1783 - var pos_buf: [64]u8 = undefined; │ │1784 - try output.appendSlice(allocator, try std.fmt.bufPrint(&pos_buf, "{d}ch;top:{d}em;width:{d}ch;height:1em\">", .{ r.x + 1, r.y + 1, inner_width }));│ │1785 - } │ │1764 + // native controls live in their panel, beside the replaceable cells. their │ │1765 + // stable keys let JS update layout without recreating their DOM nodes, and │ │1766 + // being actual descendants of a scroll gives them native movement/clipping. │ │1767 + try renderPanelControls(allocator, output, focus, session, root_focus, &excluded); │ │1768 │ │1769 // each child scroll becomes a natively-scrollable div positioned over its │ │1770 // viewport, holding its full content rendered recursively. │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │1787 // preserved position, letting a wasm-side scrollToRect (the files list │ │1788 // scrolling to its selected row on page load) reach the browser. │ │1789 var buf: [256]u8 = undefined; │ │1808 - try output.appendSlice(allocator, try std.fmt.bufPrint(&buf, "<div class=\"scroll\" 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, child.focus.version, @tagName(info.direction), info.offset_x, info.offset_y, r.x, r.y, r.size.width, r.size.height, overflow })); │ │1809 - try renderPanel(allocator, output, child.focus, info.content, session); │ │1790 + 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 }));│ │1791 + try renderPanel(allocator, output, child.focus, info.content, session, root_focus); │ │1792 try output.appendSlice(allocator, "</div>"); │ │1793 } │ │1794 } │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │1795 │ │1814 -// emits the form overlay — one <form> per "form:<url>" focus subtree in the │ │1815 -// widget tree, each wrapping the text inputs and submit button inside it, │ │1816 -// positioned absolutely over the matching grid cells. │ │1817 -pub fn generateOverlay(allocator: std.mem.Allocator, root: *ui.Widget, session: *ui.Session) ![]const u8 { │ │1818 - const root_focus = root.getFocus(); │ │1796 +const form_prefix = "form:"; │ │1797 +const form_id_prefix = "haxy-form-"; │ │1798 + │ │1799 +fn formAction(focus: *const Focus) ?[]const u8 { │ │1800 + return switch (focus.kind) { │ │1801 + .custom => |custom| if (std.mem.startsWith(u8, custom, form_prefix)) custom[form_prefix.len..] else null, │ │1802 + else => null, │ │1803 + }; │ │1804 +} │ │1805 │ │1820 - var out: std.ArrayList(u8) = .empty; │ │1821 - errdefer out.deinit(allocator); │ │1806 +// find the form ancestor of a flattened focus-tree child. html's `form` │ │1807 +// attribute then associates the control with that form without requiring the │ │1808 +// form and control to share a DOM parent. │ │1809 +fn formOwner(root_focus: *Focus, focus_id: usize) ?usize { │ │1810 + var id = focus_id; │ │1811 + while (root_focus.children.get(id)) |child| { │ │1812 + const parent_id = child.parent_id; │ │1813 + if (parent_id == root_focus.id) return if (formAction(root_focus) != null) root_focus.id else null; │ │1814 + const parent = root_focus.children.get(parent_id) orelse return null; │ │1815 + if (formAction(parent.focus) != null) return parent_id; │ │1816 + id = parent_id; │ │1817 + } │ │1818 + return null; │ │1819 +} │ │1820 │ │1823 - const form_prefix = "form:"; │ │1821 +fn appendFormAttribute(allocator: std.mem.Allocator, output: *std.ArrayList(u8), root_focus: *Focus, focus_id: usize) !void { │ │1822 + const form_id = formOwner(root_focus, focus_id) orelse return; │ │1823 + var buf: [64]u8 = undefined; │ │1824 + try output.appendSlice(allocator, try std.fmt.bufPrint(&buf, " form=\"" ++ form_id_prefix ++ "{d}\"", .{form_id})); │ │1825 +} │ │1826 │ │1825 - // Focus.children is an AutoArrayHashMap that preserves insertion order, │ │1826 - // which matches the order widgets were added in code │ │1827 - var iter = root_focus.children.iterator(); │ │1827 +fn renderPanelControls( │ │1828 + allocator: std.mem.Allocator, │ │1829 + output: *std.ArrayList(u8), │ │1830 + focus: *Focus, │ │1831 + session: *ui.Session, │ │1832 + root_focus: *Focus, │ │1833 + excluded: *const std.AutoHashMapUnmanaged(usize, void), │ │1834 +) !void { │ │1835 + var iter = focus.children.iterator(); │ │1836 while (iter.next()) |entry| { │ │1837 + const id = entry.key_ptr.*; │ │1838 + if (excluded.contains(id)) continue; │ │1839 const child = entry.value_ptr.*; │ │1830 - const action_url = switch (child.focus.kind) { │ │1831 - .custom => |custom| if (std.mem.startsWith(u8, custom, form_prefix)) │ │1832 - custom[form_prefix.len..] │ │1833 - else │ │1834 - continue, │ │1835 - else => continue, │ │1836 - }; │ │1840 + const r = child.rect; │ │1841 │ │1838 - try out.appendSlice(allocator, "<form action=\""); │ │1839 - try appendEscapedHtml(allocator, &out, action_url); │ │1840 - try out.appendSlice(allocator, "\" method=\"post\""); │ │1841 - try out.appendSlice(allocator, ">"); │ │1842 + switch (child.focus.kind) { │ │1843 + .text_input, .text_input_password, .text_area => { │ │1844 + const ti = session.text_inputs.get(id) orelse continue; │ │1845 │ │1843 - // form.focus.children was populated by addChild, which flattens the │ │1844 - // whole subtree into one map — so descendants (inputs + the submit │ │1845 - // button) all appear here. positions on form.focus are relative to │ │1846 - // the form, so we look up absolute rects via root_focus for layout. │ │1847 - var inner_iter = child.focus.children.iterator(); │ │1848 - while (inner_iter.next()) |inner_entry| { │ │1849 - const inner_id = inner_entry.key_ptr.*; │ │1850 - const root_child = root_focus.children.get(inner_id) orelse continue; │ │1851 - const r = root_child.rect; │ │1852 - switch (root_child.focus.kind) { │ │1853 - .text_input, .text_input_password => { │ │1854 - const ti = session.text_inputs.get(inner_id) orelse continue; │ │1855 - if (ti.options.read_only) continue; // rendered with the grid │ │1856 - const inner_left = r.x + 1; │ │1857 - const inner_top = r.y + 1; │ │1858 - const inner_width = if (r.size.width > 2) r.size.width - 2 else 0; │ │1846 + const inner_left = r.x + 1; │ │1847 + const inner_top = r.y + 1; │ │1848 + const inner_width = if (r.size.width > 2) r.size.width - 2 else 0; │ │1849 + const preserve = !ti.options.read_only; │ │1850 │ │1860 - try out.appendSlice(allocator, "<input type=\""); │ │1861 - try out.appendSlice(allocator, if (root_child.focus.kind == .text_input_password) "password" else "text"); │ │1862 - try out.appendSlice(allocator, "\" data-focus-id=\""); │ │1863 - var id_buf: [32]u8 = undefined; │ │1864 - try out.appendSlice(allocator, try std.fmt.bufPrint(&id_buf, "{d}", .{inner_id})); │ │1851 + if (child.focus.kind == .text_area) { │ │1852 + const value = try ti.text(allocator); │ │1853 + defer allocator.free(value); │ │1854 + var id_buf: [128]u8 = undefined; │ │1855 + try output.appendSlice(allocator, try std.fmt.bufPrint(&id_buf, "<textarea data-key=\"control:{d}\" data-focus-id=\"{d}\"", .{ id, id })); │ │1856 + if (preserve) try output.appendSlice(allocator, " data-preserve-value") else try output.appendSlice(allocator, " readonly"); │ │1857 + try appendFormAttribute(allocator, output, root_focus, id); │ │1858 if (ti.options.name.len > 0) { │ │1866 - try out.appendSlice(allocator, "\" name=\""); │ │1867 - try appendEscapedHtml(allocator, &out, ti.options.name); │ │1859 + try output.appendSlice(allocator, " name=\""); │ │1860 + try appendEscapedHtml(allocator, output, ti.options.name); │ │1861 + try output.append(allocator, '"'); │ │1862 } │ │1869 - // never the live content as the `value`: the browser tracks user │ │1870 - // input natively, and including the wasm-side value here would make │ │1871 - // the overlay HTML differ on every keystroke — that would trip the │ │1872 - // diff in _setOverlay and rebuild the <input>, eating the user's │ │1873 - // caret. a page-constant initial value is safe. │ │1874 - if (session.input_values.get(inner_id)) |value| { │ │1875 - try out.appendSlice(allocator, "\" value=\""); │ │1876 - try appendEscapedHtml(allocator, &out, value); │ │1877 - } │ │1878 - try out.appendSlice(allocator, "\" style=\"left:"); │ │1879 - var pos_buf: [64]u8 = undefined; │ │1880 - try out.appendSlice(allocator, try std.fmt.bufPrint(&pos_buf, "{d}ch;top:{d}em;width:{d}ch;height:1em", .{ inner_left, inner_top, inner_width })); │ │1881 - try out.appendSlice(allocator, "\">"); │ │1882 - }, │ │1883 - .text_area => { │ │1884 - const ti = session.text_inputs.get(inner_id) orelse continue; │ │1885 - if (ti.options.read_only) continue; // rendered with the grid │ │1886 - const inner_left = r.x + 1; │ │1887 - const inner_top = r.y + 1; │ │1888 - const inner_width = if (r.size.width > 2) r.size.width - 2 else 0; │ │1863 const inner_height = if (r.size.height > 2) r.size.height - 2 else 0; │ │1864 + var pos_buf: [128]u8 = undefined; │ │1865 + 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 })); │ │1866 + try appendEscapedHtml(allocator, output, value); │ │1867 + try output.appendSlice(allocator, "</textarea>"); │ │1868 + continue; │ │1869 + } │ │1870 │ │1891 - try out.appendSlice(allocator, "<textarea data-focus-id=\""); │ │1892 - var id_buf: [32]u8 = undefined; │ │1893 - try out.appendSlice(allocator, try std.fmt.bufPrint(&id_buf, "{d}", .{inner_id})); │ │1894 - if (ti.options.name.len > 0) { │ │1895 - try out.appendSlice(allocator, "\" name=\""); │ │1896 - try appendEscapedHtml(allocator, &out, ti.options.name); │ │1871 + var id_buf: [160]u8 = undefined; │ │1872 + 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 })); │ │1873 + if (preserve) try output.appendSlice(allocator, " data-preserve-value") else try output.appendSlice(allocator, " readonly"); │ │1874 + try appendFormAttribute(allocator, output, root_focus, id); │ │1875 + if (ti.options.name.len > 0) { │ │1876 + try output.appendSlice(allocator, " name=\""); │ │1877 + try appendEscapedHtml(allocator, output, ti.options.name); │ │1878 + try output.append(allocator, '"'); │ │1879 + } │ │1880 + if (child.focus.kind != .text_input_password) { │ │1881 + const value = try ti.text(allocator); │ │1882 + defer allocator.free(value); │ │1883 + try output.appendSlice(allocator, " value=\""); │ │1884 + try appendEscapedHtml(allocator, output, value); │ │1885 + try output.append(allocator, '"'); │ │1886 + } │ │1887 + var pos_buf: [96]u8 = undefined; │ │1888 + 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 })); │ │1889 + }, │ │1890 + .custom => |custom| { │ │1891 + if (std.mem.eql(u8, custom, "submit") or std.mem.startsWith(u8, custom, ui.submit_action_prefix)) { │ │1892 + if (formOwner(root_focus, id) == null) continue; │ │1893 + var id_buf: [128]u8 = undefined; │ │1894 + try output.appendSlice(allocator, try std.fmt.bufPrint(&id_buf, "<button type=\"submit\" data-key=\"control:{d}\" data-focus-id=\"{d}\"", .{ id, id })); │ │1895 + try appendFormAttribute(allocator, output, root_focus, id); │ │1896 + if (std.mem.startsWith(u8, custom, ui.submit_action_prefix)) { │ │1897 + try output.appendSlice(allocator, " formaction=\""); │ │1898 + try appendEscapedHtml(allocator, output, custom[ui.submit_action_prefix.len..]); │ │1899 + try output.append(allocator, '"'); │ │1900 } │ │1898 - // like the input above, only a page-constant initial value │ │1899 - // inside: the browser tracks the live value and the │ │1900 - // overlay diff must stay stable across keystrokes │ │1901 - try out.appendSlice(allocator, "\" style=\"left:"); │ │1901 var pos_buf: [128]u8 = undefined; │ │1903 - try out.appendSlice(allocator, try std.fmt.bufPrint(&pos_buf, "{d}ch;top:{d}em;width:{d}ch;height:{d}em", .{ inner_left, inner_top, inner_width, inner_height })); │ │1904 - // textarea discards the first newline immediately following │ │1905 - // its start tag, so add one of our own so text that begins │ │1906 - // with a newline doesn't lose it │ │1907 - try out.appendSlice(allocator, "\">\n"); │ │1908 - if (session.input_values.get(inner_id)) |value| try appendEscapedHtml(allocator, &out, value); │ │1909 - try out.appendSlice(allocator, "</textarea>"); │ │1910 - }, │ │1911 - .custom => |custom| { │ │1912 - if (std.mem.eql(u8, "submit", custom) or std.mem.startsWith(u8, custom, ui.submit_action_prefix)) { │ │1913 - try out.appendSlice(allocator, "<button type=\"submit\" data-focus-id=\""); │ │1914 - var id_buf: [32]u8 = undefined; │ │1915 - try out.appendSlice(allocator, try std.fmt.bufPrint(&id_buf, "{d}", .{inner_id})); │ │1916 - if (std.mem.startsWith(u8, custom, ui.submit_action_prefix)) { │ │1917 - try out.appendSlice(allocator, "\" formaction=\""); │ │1918 - try appendEscapedHtml(allocator, &out, custom[ui.submit_action_prefix.len..]); │ │1919 - } │ │1920 - try out.appendSlice(allocator, "\" style=\"left:"); │ │1921 - var pos_buf: [128]u8 = undefined; │ │1922 - try out.appendSlice(allocator, try std.fmt.bufPrint(&pos_buf, "{d}ch;top:{d}em;width:{d}ch;height:{d}em", .{ r.x, r.y, r.size.width, r.size.height })); │ │1923 - try out.appendSlice(allocator, "\"></button>"); │ │1924 - } │ │1925 - }, │ │1926 - else => {}, │ │1927 - } │ │1902 + 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 })); │ │1903 + } else if (std.mem.startsWith(u8, custom, ui.file_input_prefix)) { │ │1904 + try output.appendSlice(allocator, "<input type=\"file\" data-preserve-value data-action=\""); │ │1905 + try appendEscapedHtml(allocator, output, custom[ui.file_input_prefix.len..]); │ │1906 + var id_buf: [128]u8 = undefined; │ │1907 + 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 }));│ │1908 + } │ │1909 + }, │ │1910 + else => {}, │ │1911 } │ │1929 - │ │1930 - try out.appendSlice(allocator, "</form>"); │ │1912 } │ │1913 +} │ │1914 │ │1933 - // a file picker isn't part of a form: it posts its file straight to the url │ │1934 - // in its marker. it sits transparent over the button the tui drew, so │ │1935 - // clicking that button opens the browser's own dialog. │ │1936 - var file_iter = root_focus.children.iterator(); │ │1937 - while (file_iter.next()) |entry| { │ │1915 +// emit hidden form owners beside the rendered panels. their controls point │ │1916 +// back here with the standard HTML `form` attribute. │ │1917 +fn renderForms(allocator: std.mem.Allocator, output: *std.ArrayList(u8), root_focus: *Focus) !void { │ │1918 + // the focus child map preserves insertion order, which matches the order │ │1919 + // widgets were added in code │ │1920 + var iter = root_focus.children.iterator(); │ │1921 + while (iter.next()) |entry| { │ │1922 const child = entry.value_ptr.*; │ │1939 - const action_url = switch (child.focus.kind) { │ │1940 - .custom => |custom| if (std.mem.startsWith(u8, custom, ui.file_input_prefix)) │ │1941 - custom[ui.file_input_prefix.len..] │ │1942 - else │ │1943 - continue, │ │1944 - else => continue, │ │1945 - }; │ │1946 - const r = child.rect; │ │1923 + const action_url = formAction(child.focus) orelse continue; │ │1924 │ │1948 - try out.appendSlice(allocator, "<input type=\"file\" data-action=\""); │ │1949 - try appendEscapedHtml(allocator, &out, action_url); │ │1950 - try out.appendSlice(allocator, "\" data-focus-id=\""); │ │1951 - var id_buf: [32]u8 = undefined; │ │1952 - try out.appendSlice(allocator, try std.fmt.bufPrint(&id_buf, "{d}", .{entry.key_ptr.*})); │ │1953 - try out.appendSlice(allocator, "\" style=\"opacity:0;left:"); │ │1954 - var pos_buf: [128]u8 = undefined; │ │1955 - try out.appendSlice(allocator, try std.fmt.bufPrint(&pos_buf, "{d}ch;top:{d}em;width:{d}ch;height:{d}em", .{ r.x, r.y, r.size.width, r.size.height })); │ │1956 - try out.appendSlice(allocator, "\">"); │ │1925 + try output.appendSlice(allocator, "<form hidden data-key=\"form:"); │ │1926 + var id_buf: [96]u8 = undefined; │ │1927 + try output.appendSlice(allocator, try std.fmt.bufPrint(&id_buf, "{d}\" id=\"" ++ form_id_prefix ++ "{d}\" action=\"", .{ entry.key_ptr.*, entry.key_ptr.* })); │ │1928 + try appendEscapedHtml(allocator, output, action_url); │ │1929 + try output.appendSlice(allocator, "\" method=\"post\"></form>"); │ │1930 } │ │1958 - │ │1959 - return try out.toOwnedSlice(allocator); │ │1931 } │ │1932 │ │1933 fn colorEql(a: ?Grid.Color, b: ?Grid.Color) bool { │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯