╭ changed ──╮ │src/web.zig│ ╰───────────╯ ╭───────────╮ │← all files│ ╰───────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │445 err.flush() catch {}; │ │446 } │ │447 │ │448 -// emits the TUI grid cells as static HTML │ │448 +// emits the TUI grid cells as static HTML. each web-native Scroll becomes its │ │449 +// own absolutely-positioned, natively-scrollable <div> holding its full content, │ │450 +// rendered recursively so nested scrolls work. │ │451 pub fn generateHtml(allocator: std.mem.Allocator, root: *ui.Widget) ![]const u8 { │ │452 const grid = root.getGrid() orelse return error.MissingGrid; │ │451 - const root_focus = root.getFocus(); │ │453 + │ │454 + var out: std.ArrayList(u8) = .empty; │ │455 + errdefer out.deinit(allocator); │ │456 + │ │457 + try renderPanel(allocator, &out, root.getFocus(), grid); │ │458 + return try out.toOwnedSlice(allocator); │ │459 +} │ │460 + │ │461 +// render one panel — a focus subtree and its content grid — as HTML, recursing │ │462 +// once per web-native Scroll inside it. the root panel is the whole page; each │ │463 +// Scroll becomes a nested, natively-scrollable div holding its full content. │ │464 +// emits the panel's grid cells as text, leaving holes where child scrolls sit, │ │465 +// then the child scroll divs over those holes. │ │466 +fn renderPanel(allocator: std.mem.Allocator, output: *std.ArrayList(u8), focus: *Focus, grid: Grid) !void {│ │467 + // `direct` is this panel's scroll children, each drawn as an overlaid div. │ │468 + // `excluded` is every focus id that belongs to a scroll (the scroll nodes │ │469 + // plus their descendants, which the focus tree flattens into this panel's │ │470 + // children): the cell hit-test below skips them so this panel doesn't claim │ │471 + // cells that are really rendered inside a child scroll's own panel. │ │472 + // (scrolls aren't nested in practice, so every scroll child is direct here.) │ │473 + var direct: std.ArrayList(usize) = .empty; │ │474 + defer direct.deinit(allocator); │ │475 + var excluded: std.AutoHashMapUnmanaged(usize, void) = .empty; │ │476 + defer excluded.deinit(allocator); │ │477 + { │ │478 + var it = focus.children.iterator(); │ │479 + while (it.next()) |e| { │ │480 + if (e.value_ptr.focus.scroll == null) continue; │ │481 + try direct.append(allocator, e.key_ptr.*); │ │482 + try excluded.put(allocator, e.key_ptr.*, {}); │ │483 + var mit = e.value_ptr.focus.children.iterator(); │ │484 + while (mit.next()) |m| try excluded.put(allocator, m.key_ptr.*, {}); │ │485 + } │ │486 + } │ │487 │ │453 - const Tag = union(enum) { │ │488 + // the HTML element a run of cells is wrapped in, chosen from the cell's │ │489 + // focusable kind │ │490 + const CellTag = union(enum) { │ │491 span, // clickable focusable cell │ │492 a: []const u8, // clickable focusable link │ │493 plain, // non-focusable, colored │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────╮ │537 } │ │538 }; │ │539 │ │503 - var out: std.ArrayList(u8) = .empty; │ │504 - errdefer out.deinit(allocator); │ │505 - │ │540 + // emit this panel's grid as rows of text, coalescing adjacent cells that │ │541 + // share a focus id and colors into one tag (a clickable span, a link, or a│ │542 + // plain colored span). cells covered by a child scroll are left blank. │ │543 for (0..grid.size.height) |y| { │ │544 var cur_id: ?usize = null; │ │545 var cur_fg: ?Grid.Color = null; │ ╰────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │546 var cur_bg: ?Grid.Color = null; │ │510 - var open_tag: ?Tag = null; │ │547 + var open_tag: ?CellTag = null; │ │548 var first = true; │ │549 for (0..grid.size.width) |x| { │ │550 + // a cell covered by a child scroll's viewport is drawn by that │ │551 + // scroll's own div; leave a blank here so the layout stays aligned. │ │552 + const covered_by_scroll = blk: { │ │553 + for (direct.items) |id| { │ │554 + const r = (focus.children.get(id) orelse continue).rect; │ │555 + 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;│ │556 + } │ │557 + break :blk false; │ │558 + }; │ │559 + if (covered_by_scroll) { │ │560 + if (open_tag) |t| try output.appendSlice(allocator, t.closeTag()); │ │561 + open_tag = null; │ │562 + cur_id = null; │ │563 + first = true; │ │564 + try output.append(allocator, ' '); │ │565 + continue; │ │566 + } │ │567 + │ │568 const cell = grid.cells.items[try grid.cells.at(.{ y, x })]; │ │514 - const cell_id = cellFocusId(root_focus, x, y); │ │569 + // the focusable cell at (x, y) among this panel's own focusables (skipping any │ │570 + // that belong to a child scroll, whose cells are drawn in their own panel). │ │571 + const cell_id = blk: { │ │572 + var iter = focus.children.iterator(); │ │573 + while (iter.next()) |entry| { │ │574 + const child = entry.value_ptr.*; │ │575 + if (!child.focus.focusable) continue; │ │576 + if (excluded.contains(entry.key_ptr.*)) continue; │ │577 + const r = child.rect; │ │578 + if (x >= r.x and y >= r.y and x < r.x + r.size.width and y < r.y + r.size.height) { │ │579 + break :blk entry.key_ptr.*; │ │580 + } │ │581 + } │ │582 + break :blk null; │ │583 + }; │ │584 const fg = cell.style.fg; │ │585 const bg = cell.style.bg; │ │586 │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────╮ │587 if (first or cell_id != cur_id or !colorEql(fg, cur_fg) or !colorEql(bg, cur_bg)) {│ │519 - if (open_tag) |t| try out.appendSlice(allocator, t.closeTag()); │ │588 + if (open_tag) |t| try output.appendSlice(allocator, t.closeTag()); │ │589 │ │521 - var new_tag: ?Tag = null; │ │590 + var new_tag: ?CellTag = null; │ │591 if (cell_id) |id| { │ │523 - if (root_focus.children.get(id)) |child| { │ │592 + if (focus.children.get(id)) |child| { │ │593 new_tag = switch (child.focus.kind) { │ │525 - .custom => |custom| Tag.init(custom), │ │594 + .custom => |custom| CellTag.init(custom), │ │595 else => .span, │ │596 }; │ │597 } │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │600 │ │601 if (new_tag) |t| { │ │602 var style_buf: [64]u8 = undefined; │ │534 - try t.writeOpenTag(allocator, &out, cell_id orelse 0, styleAttr(&style_buf, fg, bg)); │ │603 + try t.writeOpenTag(allocator, output, cell_id orelse 0, styleAttr(&style_buf, fg, bg));│ │604 } │ │605 │ │606 open_tag = new_tag; │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │610 first = false; │ │611 } │ │612 │ │544 - try appendEscapedHtml(allocator, &out, cell.rune orelse " "); │ │613 + try appendEscapedHtml(allocator, output, cell.rune orelse " "); │ │614 } │ │546 - if (open_tag) |t| try out.appendSlice(allocator, t.closeTag()); │ │547 - try out.append(allocator, '\n'); │ │615 + if (open_tag) |t| try output.appendSlice(allocator, t.closeTag()); │ │616 + try output.append(allocator, '\n'); │ │617 } │ │618 │ │550 - return try out.toOwnedSlice(allocator); │ │619 + // each child scroll becomes a natively-scrollable div positioned over its │ │620 + // viewport, holding its full content rendered recursively. │ │621 + for (direct.items) |id| { │ │622 + const child = focus.children.get(id) orelse continue; │ │623 + const info = child.focus.scroll orelse continue; │ │624 + const r = child.rect; │ │625 + // overflow only on the axes the widget scrolls; `auto` shows a bar only │ │626 + // when that axis actually overflows. the other axis is clipped (hidden), │ │627 + // matching how the terminal Scroll handles its non-scrolling axis. │ │628 + const overflow = switch (info.direction) { │ │629 + .vert => "overflow-x:hidden;overflow-y:auto", │ │630 + .horiz => "overflow-x:auto;overflow-y:hidden", │ │631 + .both => "overflow:auto", │ │632 + }; │ │633 + // the id carries a content version so JS preserves the native scroll │ │634 + // position across re-renders of the same content but resets it (to the │ │635 + // top) when the content is replaced — e.g. selecting a different commit. │ │636 + var buf: [160]u8 = undefined; │ │637 + try output.appendSlice(allocator, try std.fmt.bufPrint(&buf, "<div class=\"scroll\" data-scroll-id=\"{d}-{d}\" style=\"left:{d}ch;top:{d}em;width:{d}ch;height:{d}em;{s}\">", .{ id, child.focus.version, r.x, r.y, r.size.width, r.size.height, overflow }));│ │638 + try renderPanel(allocator, output, child.focus, info.content); │ │639 + try output.appendSlice(allocator, "</div>"); │ │640 + } │ │641 } │ │642 │ │643 // emits the form overlay — one <form> per "form:<url>" focus subtree in the │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │757 return buf[0..i]; │ │758 } │ │759 │ │670 -fn cellFocusId(focus: *Focus, x: usize, y: usize) ?usize { │ │671 - var iter = focus.children.iterator(); │ │672 - while (iter.next()) |entry| { │ │673 - const child = entry.value_ptr.*; │ │674 - if (!child.focus.focusable) continue; │ │675 - const r = child.rect; │ │676 - if (x >= r.x and y >= r.y and x < r.x + r.size.width and y < r.y + r.size.height) { │ │677 - return entry.key_ptr.*; │ │678 - } │ │679 - } │ │680 - return null; │ │681 -} │ │682 - │ │760 fn appendEscapedHtml(allocator: std.mem.Allocator, out: *std.ArrayList(u8), input: []const u8) !void {│ │761 for (input) |ch| { │ │762 switch (ch) { │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────╯