╭ changed ────╮ │build.zig.zon│ ╰─────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ 3 .version = "0.0.0", │ │ 4 .dependencies = .{ │ │ 5 .xit = .{ │ │ 6 - .url = "git+https://github.com/xit-vcs/xit#0b6eca3512b68ecf5288736f2ee78cbc120b9d76",│ │ 7 - .hash = "xit-0.0.0-Ub3nDEPFTwFvMuZzHUUDGMW32_P4fOFo90QpiwBNnUAe", │ │ 6 + .url = "git+https://github.com/xit-vcs/xit#dc2b2ce4b8878d826ce7f1fa35751fe98daf514e",│ │ 7 + .hash = "xit-0.0.0-Ub3nDITiTwFOTcJ89MVK3ocxrMVDtCXKPbTR4KKiwOB-", │ │ 8 }, │ │ 9 }, │ │10 .paths = .{ │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ────────╮ │src/main_wasm.zig│ ╰─────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────╮ │71 // leaves the live <form> alone — crucial since wiping it mid-click │ │72 // would detach the submit button before the browser can dispatch the │ │73 // form submission. │ │74 - const overlay = try web.generateOverlay(allocator, root_ptr, &session);│ │74 + const overlay = try web.generateOverlay(allocator, root_ptr); │ │75 defer allocator.free(overlay); │ │76 setOverlay(overlay); │ │77 │ ╰───────────────────────────────────────────────────────────────────────────────╯ ╭ changed ─────────────────╮ │src/ui/Home/Auth/Login.zig│ ╰──────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │44 pub fn init(allocator: std.mem.Allocator, data: *const Self, session: *ui.Session, users_tab_id: usize) !View { │ │45 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .rounded_corners = true, .direction = .vert });│ │46 errdefer box.deinit(allocator); │ │47 + // marks this subtree as an HTML form scope for the web overlay │ │48 + box.getFocus().kind = .{ .custom = "form:/login" }; │ │49 │ │50 var nav_ids: [3]usize = undefined; │ │51 │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ──────────────────╮ │src/ui/Home/Auth/Logout.zig│ ╰───────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │29 pub fn init(allocator: std.mem.Allocator, data: *const Self, session: *ui.Session, users_tab_id: usize) !View { │ │30 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .rounded_corners = true, .direction = .vert });│ │31 errdefer box.deinit(allocator); │ │32 + // marks this subtree as an HTML form scope for the web overlay │ │33 + box.getFocus().kind = .{ .custom = "form:/logout" }; │ │34 │ │35 { │ │36 var prompt = wgt.Text(ui.Widget).init(allocator, "are you sure?"); │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ──╮ │src/web.zig│ ╰───────────╯ ╭─────────────────────────────────────────────────────────────────────────────╮ │313 │ │314 const content = try generateHtml(allocator, &root, &session); │ │315 defer allocator.free(content); │ │316 - const overlay = try generateOverlay(allocator, &root, &session); │ │316 + const overlay = try generateOverlay(allocator, &root); │ │317 defer allocator.free(overlay); │ │318 │ │319 // serialize the snapshot so the wasm side can parse it back without│ ╰─────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │398 return try out.toOwnedSlice(allocator); │ │399 } │ │400 │ │401 -// emits the form overlay — a <form> wrapping the text inputs and submit │ │402 -// button, positioned absolutely over the matching grid cells │ │403 -pub fn generateOverlay(allocator: std.mem.Allocator, root: *ui.Widget, session: *const ui.Session) ![]const u8 {│ │401 +// emits the form overlay — one <form> per "form:<url>" focus subtree in the │ │402 +// widget tree, each wrapping the text inputs and submit button inside it, │ │403 +// positioned absolutely over the matching grid cells. │ │404 +pub fn generateOverlay(allocator: std.mem.Allocator, root: *ui.Widget) ![]const u8 { │ │405 const root_focus = root.getFocus(); │ │406 │ │407 var out: std.ArrayList(u8) = .empty; │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────╮ │408 errdefer out.deinit(allocator); │ │409 │ │409 - const InputEntry = struct { │ │410 - focus_id: usize, │ │411 - x: usize, │ │412 - y: usize, │ │413 - width: usize, │ │414 - is_password: bool, │ │415 - ti: *wgt.TextInput(ui.Widget), │ │416 - }; │ │417 - const SubmitButton = struct { │ │418 - focus_id: usize, │ │419 - x: usize, │ │420 - y: usize, │ │421 - width: usize, │ │422 - height: usize, │ │423 - }; │ │424 - var inputs: std.ArrayList(InputEntry) = .empty; │ │425 - defer inputs.deinit(allocator); │ │426 - var submit: ?SubmitButton = null; │ │410 + const form_prefix = "form:"; │ │411 │ │412 + // Focus.children is an AutoArrayHashMap that preserves insertion order,│ │413 + // which matches the order widgets were added in code │ │414 var iter = root_focus.children.iterator(); │ │415 while (iter.next()) |entry| { │ │416 const child = entry.value_ptr.*; │ ╰─────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │431 - switch (child.focus.kind) { │ │432 - .text_input, .text_input_password => { │ │433 - try inputs.append(allocator, .{ │ │434 - .focus_id = entry.key_ptr.*, │ │435 - .x = child.rect.x, │ │436 - .y = child.rect.y, │ │437 - .width = child.rect.size.width, │ │438 - .is_password = child.focus.kind == .text_input_password, │ │439 - .ti = @fieldParentPtr("focus", child.focus), │ │440 - }); │ │441 - }, │ │442 - .custom => |custom| { │ │443 - if (std.mem.eql(u8, "submit", custom)) { │ │444 - submit = .{ │ │445 - .focus_id = entry.key_ptr.*, │ │446 - .x = child.rect.x, │ │447 - .y = child.rect.y, │ │448 - .width = child.rect.size.width, │ │449 - .height = child.rect.size.height, │ │450 - }; │ │451 - } │ │452 - }, │ │453 - else => {}, │ │454 - } │ │455 - } │ │417 + const action_url = switch (child.focus.kind) { │ │418 + .custom => |custom| if (std.mem.startsWith(u8, custom, form_prefix)) │ │419 + custom[form_prefix.len..] │ │420 + else │ │421 + continue, │ │422 + else => continue, │ │423 + }; │ │424 │ │457 - if (submit == null and inputs.items.len == 0) return try out.toOwnedSlice(allocator); │ │425 + try out.appendSlice(allocator, "<form action=\""); │ │426 + try appendEscapedHtml(allocator, &out, action_url); │ │427 + try out.appendSlice(allocator, "\" method=\"post\">"); │ │428 │ │459 - // top-to-bottom, left-to-right order so the natural tab cycle matches │ │460 - // visual layout (username -> password -> submit button). │ │461 - std.mem.sort(InputEntry, inputs.items, {}, struct { │ │462 - fn lt(_: void, a: InputEntry, b: InputEntry) bool { │ │463 - if (a.y != b.y) return a.y < b.y; │ │464 - return a.x < b.x; │ │465 - } │ │466 - }.lt); │ │429 + // form.focus.children was populated by addChild, which flattens the │ │430 + // whole subtree into one map — so descendants (inputs + the submit │ │431 + // button) all appear here. positions on form.focus are relative to │ │432 + // the form, so we look up absolute rects via root_focus for layout. │ │433 + var inner_iter = child.focus.children.iterator(); │ │434 + while (inner_iter.next()) |inner_entry| { │ │435 + const inner_id = inner_entry.key_ptr.*; │ │436 + const root_child = root_focus.children.get(inner_id) orelse continue; │ │437 + const r = root_child.rect; │ │438 + switch (root_child.focus.kind) { │ │439 + .text_input, .text_input_password => { │ │440 + const ti: *wgt.TextInput(ui.Widget) = @fieldParentPtr("focus", root_child.focus); │ │441 + const inner_left = r.x + 1; │ │442 + const inner_top = r.y + 1; │ │443 + const inner_width = if (r.size.width > 2) r.size.width - 2 else 0; │ │444 │ │468 - // only the auth page hosts a form right now; on any other page we │ │469 - // emit nothing. on auth, the URL depends on whether there's already │ │470 - // an active session. │ │471 - const action_url: []const u8 = switch (session.data.current_page) { │ │472 - .home_auth => if (session.data.user_id != null) "/logout" else "/login", │ │473 - .home_users, .home_repos => return try out.toOwnedSlice(allocator), │ │474 - }; │ │475 - │ │476 - try out.appendSlice(allocator, "<form action=\""); │ │477 - try out.appendSlice(allocator, action_url); │ │478 - try out.appendSlice(allocator, "\" method=\"post\">"); │ │479 - │ │480 - for (inputs.items) |entry| { │ │481 - const inner_left = entry.x + 1; │ │482 - const inner_top = entry.y + 1; │ │483 - const inner_width = if (entry.width > 2) entry.width - 2 else 0; │ │484 - │ │485 - try out.appendSlice(allocator, "<input type=\""); │ │486 - try out.appendSlice(allocator, if (entry.is_password) "password" else "text"); │ │487 - try out.appendSlice(allocator, "\" data-focus-id=\""); │ │488 - var id_buf: [32]u8 = undefined; │ │489 - try out.appendSlice(allocator, try std.fmt.bufPrint(&id_buf, "{d}", .{entry.focus_id})); │ │490 - if (entry.ti.options.name.len > 0) { │ │491 - try out.appendSlice(allocator, "\" name=\""); │ │492 - try appendEscapedHtml(allocator, &out, entry.ti.options.name); │ │445 + try out.appendSlice(allocator, "<input type=\""); │ │446 + try out.appendSlice(allocator, if (root_child.focus.kind == .text_input_password) "password" else "text"); │ │447 + try out.appendSlice(allocator, "\" data-focus-id=\""); │ │448 + var id_buf: [32]u8 = undefined; │ │449 + try out.appendSlice(allocator, try std.fmt.bufPrint(&id_buf, "{d}", .{inner_id})); │ │450 + if (ti.options.name.len > 0) { │ │451 + try out.appendSlice(allocator, "\" name=\""); │ │452 + try appendEscapedHtml(allocator, &out, ti.options.name); │ │453 + } │ │454 + // intentionally no `value` attribute: the browser tracks user input │ │455 + // natively, and including the wasm-side value here would make the │ │456 + // overlay HTML differ on every keystroke — that would trip the diff │ │457 + // in _setOverlay and rebuild the <input>, eating the user's caret. │ │458 + try out.appendSlice(allocator, "\" style=\"left:"); │ │459 + var pos_buf: [64]u8 = undefined; │ │460 + 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 })); │ │461 + try out.appendSlice(allocator, "\">"); │ │462 + }, │ │463 + .custom => |custom| { │ │464 + if (std.mem.eql(u8, "submit", custom)) { │ │465 + // inside the dashed border the TUI cells draw around the button. │ │466 + const inner_left = r.x + 1; │ │467 + const inner_top = r.y + 1; │ │468 + const inner_width = if (r.size.width > 2) r.size.width - 2 else 0; │ │469 + const inner_height = if (r.size.height > 2) r.size.height - 2 else 0; │ │470 + try out.appendSlice(allocator, "<button type=\"submit\" data-focus-id=\""); │ │471 + var id_buf: [32]u8 = undefined; │ │472 + try out.appendSlice(allocator, try std.fmt.bufPrint(&id_buf, "{d}", .{inner_id})); │ │473 + try out.appendSlice(allocator, "\" style=\"left:"); │ │474 + var pos_buf: [128]u8 = undefined; │ │475 + 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 }));│ │476 + try out.appendSlice(allocator, "\"></button>"); │ │477 + } │ │478 + }, │ │479 + else => {}, │ │480 + } │ │481 } │ │494 - // intentionally no `value` attribute: the browser tracks user input │ │495 - // natively, and including the wasm-side value here would make the │ │496 - // overlay HTML differ on every keystroke — that would trip the diff │ │497 - // in _setOverlay and rebuild the <input>, eating the user's caret. │ │498 - try out.appendSlice(allocator, "\" style=\"left:"); │ │499 - var pos_buf: [64]u8 = undefined; │ │500 - 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 })); │ │501 - try out.appendSlice(allocator, "\">"); │ │502 - } │ │482 │ │504 - if (submit) |btn| { │ │505 - // inside the dashed border the TUI cells draw around the button. │ │506 - const inner_left = btn.x + 1; │ │507 - const inner_top = btn.y + 1; │ │508 - const inner_width = if (btn.width > 2) btn.width - 2 else 0; │ │509 - const inner_height = if (btn.height > 2) btn.height - 2 else 0; │ │510 - try out.appendSlice(allocator, "<button type=\"submit\" data-focus-id=\""); │ │511 - var id_buf: [32]u8 = undefined; │ │512 - try out.appendSlice(allocator, try std.fmt.bufPrint(&id_buf, "{d}", .{btn.focus_id})); │ │513 - try out.appendSlice(allocator, "\" style=\"left:"); │ │514 - var pos_buf: [128]u8 = undefined; │ │515 - 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 })); │ │516 - try out.appendSlice(allocator, "\"></button>"); │ │483 + try out.appendSlice(allocator, "</form>"); │ │484 } │ │485 │ │519 - try out.appendSlice(allocator, "</form>"); │ │520 - │ │486 return try out.toOwnedSlice(allocator); │ │487 } │ │488 │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯