╭ changed ──╮ │src/web.zig│ ╰───────────╯ ╭───────────╮ │← all files│ ╰───────────╯ ╭──────────╮ │← previous│ ╰──────────╯ ╭───────────────────────────────────────────╮ │203 io: std.Io, │ │207 - http_server: *std.http.Server, │ │204 request: *std.http.Server.Request,│ │205 allocator: std.mem.Allocator, │ │206 admin_repo_path: []const u8, │ ╰───────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │229 switch (result) { │ │230 .success => |user_id| { │ │231 const hex = std.fmt.bytesToHex(user_id, .lower); │ │236 - try http_server.out.print( │ │237 - "HTTP/1.1 303 See Other\r\nLocation: /\r\nSet-Cookie: " ++ cookie_name ++ "={s}; Path=/; HttpOnly; SameSite=Strict\r\nContent-Length: 0\r\n\r\n", │ │238 - .{hex}, │ │239 - ); │ │232 + var cookie_buf: [256]u8 = undefined; │ │233 + const cookie = try std.fmt.bufPrint(&cookie_buf, cookie_name ++ "={s}; Path=/; HttpOnly; SameSite=Strict", .{hex}); │ │234 + try request.respond("", .{ │ │235 + .status = .see_other, │ │236 + .extra_headers = &.{ │ │237 + .{ .name = "location", .value = "/" }, │ │238 + .{ .name = "set-cookie", .value = cookie }, │ │239 + }, │ │240 + }); │ │241 }, │ │242 .unknown_user => { │ │242 - try http_server.out.print( │ │243 - "HTTP/1.1 303 See Other\r\nLocation: /\r\nSet-Cookie: " ++ login_failure_cookie ++ "=unknown_user; Path=/; HttpOnly; SameSite=Strict\r\nContent-Length: 0\r\n\r\n", │ │244 - .{}, │ │245 - ); │ │243 + try request.respond("", .{ │ │244 + .status = .see_other, │ │245 + .extra_headers = &.{ │ │246 + .{ .name = "location", .value = "/" }, │ │247 + .{ .name = "set-cookie", .value = login_failure_cookie ++ "=unknown_user; Path=/; HttpOnly; SameSite=Strict" }, │ │248 + }, │ │249 + }); │ │250 }, │ │251 .wrong_password => { │ │248 - try http_server.out.print( │ │249 - "HTTP/1.1 303 See Other\r\nLocation: /\r\nSet-Cookie: " ++ login_failure_cookie ++ "=wrong_password; Path=/; HttpOnly; SameSite=Strict\r\nContent-Length: 0\r\n\r\n",│ │250 - .{}, │ │251 - ); │ │252 + try request.respond("", .{ │ │253 + .status = .see_other, │ │254 + .extra_headers = &.{ │ │255 + .{ .name = "location", .value = "/" }, │ │256 + .{ .name = "set-cookie", .value = login_failure_cookie ++ "=wrong_password; Path=/; HttpOnly; SameSite=Strict" }, │ │257 + }, │ │258 + }); │ │259 }, │ │260 } │ │261 } │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │262 │ │256 -fn handleLogout(http_server: *std.http.Server, request: *std.http.Server.Request) !void { │ │257 - // we don't need the body, but the http server wants the request consumed │ │258 - // before the next one (and before we write the response). │ │259 - var sink_buf: [64]u8 = undefined; │ │260 - _ = request.readerExpectNone(&sink_buf).discardRemaining() catch {}; │ │261 - │ │262 - try http_server.out.print( │ │263 - "HTTP/1.1 303 See Other\r\nLocation: /\r\nSet-Cookie: " ++ cookie_name ++ "=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0\r\nContent-Length: 0\r\n\r\n",│ │264 - .{}, │ │265 - ); │ │263 +fn handleLogout(request: *std.http.Server.Request) !void { │ │264 + // request.respond's discardBody reads any unused body for us and lands │ │265 + // the connection back in the .ready state for the next request on the │ │266 + // same connection (keep_alive defaults to true). │ │267 + try request.respond("", .{ │ │268 + .status = .see_other, │ │269 + .extra_headers = &.{ │ │270 + .{ .name = "location", .value = "/" }, │ │271 + .{ .name = "set-cookie", .value = cookie_name ++ "=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0" }, │ │272 + }, │ │273 + }); │ │274 } │ │275 │ │276 fn renderIndexHtml( │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────╮ │307 │ │308 const content = try generateHtml(allocator, &root, &session); │ │309 defer allocator.free(content); │ │310 + const overlay = try generateOverlay(allocator, &root, &session); │ │311 + defer allocator.free(overlay); │ │312 │ │313 // serialize the snapshot so the wasm side can parse it back without │ │314 // making a second request. base64-encoded so the json can be embedded│ ╰───────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │328 var cursor: usize = 0; │ │329 for (&[_]struct { needle: []const u8, replacement: []const u8 }{ │ │330 .{ .needle = "{{{ HAXY_HTML }}}", .replacement = content }, │ │331 + .{ .needle = "{{{ HAXY_OVERLAY }}}", .replacement = overlay }, │ │332 .{ .needle = "{{{ HAXY_JSON }}}", .replacement = json_b64 }, │ │333 }) |sub| { │ │334 const idx = std.mem.indexOfPos(u8, template, cursor, sub.needle) orelse return error.MissingTemplateToken;│ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────╮ │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; │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯