╭──────────╮ │← previous│ ╰──────────╯ ╭ changed ──╮ │src/web.zig│ ╰───────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────╮ │355 return null; │ │356 } │ │357 │ │347 -fn writeSimpleResponse( │ │348 - http_server: *std.http.Server, │ │349 - code: u16, │ │350 - message: []const u8, │ │351 - content_type: []const u8, │ │352 - body: []const u8, │ │353 -) !void { │ │354 - try http_server.out.print( │ │355 - "HTTP/1.1 {d} {s}\r\nContent-Type: {s}\r\nContent-Length: {d}\r\n\r\n{s}",│ │356 - .{ code, message, content_type, body.len, body }, │ │357 - ); │ │358 -} │ │359 - │ │360 -fn writeStaticResponse( │ │361 - http_server: *std.http.Server, │ │362 - code: u16, │ │363 - message: []const u8, │ │364 - content_type: []const u8, │ │365 - body: []const u8, │ │366 - head_only: bool, │ │367 -) !void { │ │368 - try http_server.out.print( │ │369 - "HTTP/1.1 {d} {s}\r\nContent-Type: {s}\r\nContent-Length: {d}\r\n\r\n", │ │370 - .{ code, message, content_type, body.len }, │ │371 - ); │ │372 - if (!head_only) { │ │373 - try http_server.out.writeAll(body); │ │374 - } │ │375 -} │ │376 - │ │358 fn logError(err: *std.Io.Writer, comptime fmt: []const u8, args: anytype) void { │ │359 err.print(fmt, args) catch return; │ │360 err.flush() catch {}; │ ╰───────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │361 } │ │362 │ │363 +// emits the TUI grid cells (one <span> run per focusable widget) │ │364 pub fn generateHtml(allocator: std.mem.Allocator, root: *ui.Widget, session: *const ui.Session) ![]const u8 {│ │365 + _ = session; │ │366 const grid = root.getGrid() orelse return error.MissingGrid; │ │367 const root_focus = root.getFocus(); │ │368 │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │369 var out: std.ArrayList(u8) = .empty; │ │370 errdefer out.deinit(allocator); │ │371 │ │389 - // determines the form route for any submit_button on this page. only one │ │390 - // is visible at a time: login when logged out, logout when logged in. │ │391 - const submit_url: []const u8 = if (session.data.user_id != null) "/logout" else "/login"; │ │372 + for (0..grid.size.height) |y| { │ │373 + var current_id: ?usize = null; │ │374 + for (0..grid.size.width) |x| { │ │375 + const cell_id = cellFocusId(root_focus, x, y); │ │376 + if (cell_id != current_id) { │ │377 + if (current_id != null) try out.appendSlice(allocator, "</span>"); │ │378 + if (cell_id) |id| { │ │379 + var buf: [128]u8 = undefined; │ │380 + const tag = try std.fmt.bufPrint(&buf, "<span class=\"clickable\" data-focus-id=\"{d}\">", .{id});│ │381 + try out.appendSlice(allocator, tag); │ │382 + } │ │383 + current_id = cell_id; │ │384 + } │ │385 + const rune = grid.cells.items[try grid.cells.at(.{ y, x })].rune orelse " "; │ │386 + try appendEscapedHtml(allocator, &out, rune); │ │387 + } │ │388 + if (current_id != null) try out.appendSlice(allocator, "</span>"); │ │389 + try out.append(allocator, '\n'); │ │390 + } │ │391 │ │393 - // collect the text inputs in deterministic top-to-bottom, left-to-right │ │394 - // order. root_focus.children is a hash map so iterating it directly would │ │395 - // give arbitrary tab order between username / password. │ │392 + return try out.toOwnedSlice(allocator); │ │393 +} │ │394 + │ │395 +// emits the form overlay — a <form> wrapping the text inputs and submit │ │396 +// button, positioned absolutely over the matching grid cells │ │397 +pub fn generateOverlay(allocator: std.mem.Allocator, root: *ui.Widget, session: *const ui.Session) ![]const u8 { │ │398 + const root_focus = root.getFocus(); │ │399 + │ │400 + var out: std.ArrayList(u8) = .empty; │ │401 + errdefer out.deinit(allocator); │ │402 + │ │403 const InputEntry = struct { │ │404 focus_id: usize, │ │405 x: usize, │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │408 is_password: bool, │ │409 ti: *wgt.TextInput(ui.Widget), │ │410 }; │ │411 + const SubmitButton = struct { │ │412 + focus_id: usize, │ │413 + x: usize, │ │414 + y: usize, │ │415 + width: usize, │ │416 + height: usize, │ │417 + }; │ │418 var inputs: std.ArrayList(InputEntry) = .empty; │ │419 defer inputs.deinit(allocator); │ │406 - var focus_iter = root_focus.children.iterator(); │ │407 - while (focus_iter.next()) |entry| { │ │420 + var submit: ?SubmitButton = null; │ │421 + │ │422 + var iter = root_focus.children.iterator(); │ │423 + while (iter.next()) |entry| { │ │424 const child = entry.value_ptr.*; │ │409 - const is_password = switch (child.focus.kind) { │ │410 - .text_input => false, │ │411 - .text_input_password => true, │ │412 - else => continue, │ │413 - }; │ │414 - try inputs.append(allocator, .{ │ │415 - .focus_id = entry.key_ptr.*, │ │416 - .x = child.rect.x, │ │417 - .y = child.rect.y, │ │418 - .width = child.rect.size.width, │ │419 - .is_password = is_password, │ │420 - .ti = @fieldParentPtr("focus", child.focus), │ │421 - }); │ │425 + switch (child.focus.kind) { │ │426 + .text_input, .text_input_password => { │ │427 + try inputs.append(allocator, .{ │ │428 + .focus_id = entry.key_ptr.*, │ │429 + .x = child.rect.x, │ │430 + .y = child.rect.y, │ │431 + .width = child.rect.size.width, │ │432 + .is_password = child.focus.kind == .text_input_password, │ │433 + .ti = @fieldParentPtr("focus", child.focus), │ │434 + }); │ │435 + }, │ │436 + .submit_button => { │ │437 + submit = .{ │ │438 + .focus_id = entry.key_ptr.*, │ │439 + .x = child.rect.x, │ │440 + .y = child.rect.y, │ │441 + .width = child.rect.size.width, │ │442 + .height = child.rect.size.height, │ │443 + }; │ │444 + }, │ │445 + else => {}, │ │446 + } │ │447 } │ │448 + │ │449 + if (submit == null and inputs.items.len == 0) return try out.toOwnedSlice(allocator);│ │450 + │ │451 + // top-to-bottom, left-to-right order so the natural tab cycle matches │ │452 + // visual layout (username -> password -> submit button). │ │453 std.mem.sort(InputEntry, inputs.items, {}, struct { │ │454 fn lt(_: void, a: InputEntry, b: InputEntry) bool { │ │455 if (a.y != b.y) return a.y < b.y; │ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │457 } │ │458 }.lt); │ │459 │ │430 - // emit the overlay inputs FIRST so they precede any submit-button span │ │431 - // in DOM order; with the submit button carrying tabindex="0", that puts │ │432 - // it at the end of the natural tab order — username -> password -> button. │ │460 + const action_url: []const u8 = if (session.data.user_id != null) "/logout" else "/login";│ │461 + │ │462 + try out.appendSlice(allocator, "<form action=\""); │ │463 + try out.appendSlice(allocator, action_url); │ │464 + try out.appendSlice(allocator, "\" method=\"post\">"); │ │465 + │ │466 for (inputs.items) |entry| { │ │467 const inner_left = entry.x + 1; │ │468 const inner_top = entry.y + 1; │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │476 if (entry.ti.options.name.len > 0) { │ │477 try out.appendSlice(allocator, "\" name=\""); │ │478 try appendEscapedHtml(allocator, &out, entry.ti.options.name); │ │446 - } │ │447 - // mark the wasm-focused element so JS can mirror it onto DOM focus │ │448 - // — otherwise moving focus via arrow keys leaves the TUI border │ │449 - // showing focus but the underlying <input> unfocused. │ │450 - if (entry.focus_id == root_focus.grandchild_id) { │ │451 - try out.appendSlice(allocator, "\" data-focused=\"true"); │ │452 - } │ │453 - try out.appendSlice(allocator, "\" value=\""); │ │454 - var value_buf: std.ArrayList(u8) = .empty; │ │455 - defer value_buf.deinit(allocator); │ │456 - for (entry.ti.content.items) |cp| { │ │457 - try value_buf.appendSlice(allocator, cp); │ │479 } │ │459 - try appendEscapedHtml(allocator, &out, value_buf.items); │ │480 + // intentionally no `value` attribute: the browser tracks user input │ │481 + // natively, and including the wasm-side value here would make the │ │482 + // overlay HTML differ on every keystroke — that would trip the diff │ │483 + // in _setOverlay and rebuild the <input>, eating the user's caret. │ │484 try out.appendSlice(allocator, "\" style=\"left:"); │ │485 var pos_buf: [64]u8 = undefined; │ │486 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 }));│ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │487 try out.appendSlice(allocator, "\">"); │ │488 } │ │489 │ │466 - for (0..grid.size.height) |y| { │ │467 - // wrap runs of cells belonging to a focusable widget in a span tagged │ │468 - // with that widget's focus id. CSS paints them with a pointer cursor; │ │469 - // JS reads the id on click and dispatches focus directly. │ │470 - var current_id: ?usize = null; │ │471 - for (0..grid.size.width) |x| { │ │472 - const cell_id = cellFocusId(root_focus, x, y); │ │473 - if (cell_id != current_id) { │ │474 - if (current_id != null) try out.appendSlice(allocator, "</span>"); │ │475 - if (cell_id) |id| { │ │476 - const kind = if (root_focus.children.get(id)) |entry| entry.focus.kind else .container; │ │477 - const focused_attr: []const u8 = if (id == root_focus.grandchild_id) " data-focused=\"true\"" else ""; │ │478 - var buf: [256]u8 = undefined; │ │479 - // tabindex="0" on submit buttons puts them in the browser's │ │480 - // natural Tab order alongside the inputs above. │ │481 - const tag = if (kind == .submit_button) │ │482 - try std.fmt.bufPrint(&buf, "<span class=\"clickable\" data-focus-id=\"{d}\" data-action=\"submit\" data-url=\"{s}\" tabindex=\"0\"{s}>", .{ id, submit_url, focused_attr })│ │483 - else │ │484 - try std.fmt.bufPrint(&buf, "<span class=\"clickable\" data-focus-id=\"{d}\"{s}>", .{ id, focused_attr }); │ │485 - try out.appendSlice(allocator, tag); │ │486 - } │ │487 - current_id = cell_id; │ │488 - } │ │489 - const rune = grid.cells.items[try grid.cells.at(.{ y, x })].rune orelse " "; │ │490 - try appendEscapedHtml(allocator, &out, rune); │ │491 - } │ │492 - if (current_id != null) try out.appendSlice(allocator, "</span>"); │ │493 - try out.append(allocator, '\n'); │ │490 + if (submit) |btn| { │ │491 + // inside the dashed border the TUI cells draw around the button. │ │492 + const inner_left = btn.x + 1; │ │493 + const inner_top = btn.y + 1; │ │494 + const inner_width = if (btn.width > 2) btn.width - 2 else 0; │ │495 + const inner_height = if (btn.height > 2) btn.height - 2 else 0; │ │496 + try out.appendSlice(allocator, "<button type=\"submit\" data-focus-id=\""); │ │497 + var id_buf: [32]u8 = undefined; │ │498 + try out.appendSlice(allocator, try std.fmt.bufPrint(&id_buf, "{d}", .{btn.focus_id})); │ │499 + try out.appendSlice(allocator, "\" style=\"left:"); │ │500 + var pos_buf: [128]u8 = undefined; │ │501 + 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 })); │ │502 + try out.appendSlice(allocator, "\"></button>"); │ │503 } │ │504 │ │505 + try out.appendSlice(allocator, "</form>"); │ │506 + │ │507 return try out.toOwnedSlice(allocator); │ │508 } │ │509 │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯