╭──────────╮ │← previous│ ╰──────────╯ ╭ changed ──────────────╮ │src/ui/Repo/Commits.zig│ ╰───────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────╮ │584 } │ │585 } │ │586 │ │580 - // move focus one hunk in `dir` (+1 down, -1 up). if the adjacent hunk is │ │581 - // already visible, just move focus to it — don't scroll. only when it isn't │ │582 - // visible yet do we scroll one line toward it (and focus it once it appears),│ │583 - // so scrolling never skips past an on-screen hunk. when nothing fits to │ │584 - // scroll (e.g. the web, where the browser scrolls the page), the clamp keeps │ │585 - // the offset put and focus simply steps to the adjacent hunk. │ │587 + // move focus one hunk in `dir` (+1 down, -1 up). on the web the browser owns │ │588 + // scrolling, so just step focus to the adjacent hunk and let scrollToFocus │ │589 + // bring it into view. on the terminal, if the adjacent hunk is already │ │590 + // visible just move focus; otherwise scroll one line toward it (focusing it │ │591 + // once it appears) so scrolling never skips past an on-screen hunk. │ │592 fn moveDiff(self: *View, root_focus: *Focus, dir: isize) !void { │ │593 const inner = self.diffInner(); │ │594 const keys = inner.children.keys(); │ ╰───────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────╮ │597 const target = @as(isize, @intCast(cur)) + dir; │ │598 const in_range = target >= 0 and target < @as(isize, @intCast(keys.len));│ │599 │ │600 + if (!self.session.is_terminal) { │ │601 + if (in_range) try root_focus.setFocus(keys[@intCast(target)]); │ │602 + return; │ │603 + } │ │604 + │ │605 if (in_range and self.hunkVisible(@intCast(target))) { │ │606 try root_focus.setFocus(keys[@intCast(target)]); │ │607 return; │ ╰──────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │615 } │ │616 │ │617 // page a fixed number of lines, then focus the leading visible hunk │ │607 - // (bottom-most when paging down, top-most when paging up). │ │618 + // (bottom-most when paging down, top-most when paging up). on the web the │ │619 + // browser scrolls, so jump focus by a page's worth of hunks instead. │ │620 fn pageDiff(self: *View, root_focus: *Focus, delta: isize) !void { │ │621 + if (!self.session.is_terminal) { │ │622 + const inner = self.diffInner(); │ │623 + const keys = inner.children.keys(); │ │624 + if (keys.len == 0) return; │ │625 + const cur: isize = if (root_focus.grandchild_id) |g| @intCast(inner.children.getIndex(g) orelse 0) else 0;│ │626 + const target: usize = @intCast(std.math.clamp(cur + delta, 0, @as(isize, @intCast(keys.len - 1)))); │ │627 + try root_focus.setFocus(keys[target]); │ │628 + return; │ │629 + } │ │630 const sc = self.diffScroll(); │ │631 sc.y += delta; │ │632 self.clampDiffScroll(); │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ────────────╮ │src/ui/Repo/Files.zig│ ╰─────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │113 } │ │114 if (box.children.count() > 0) box.getFocus().child_id = box.children.keys()[0]; │ │115 │ │116 - var scroll = try wgt.Scroll(ui.Widget).init(allocator, .{ .box = box }, .{ .direction = .vert }); │ │116 + var scroll = try wgt.Scroll(ui.Widget).init(allocator, .{ .box = box }, .{ .direction = .vert, .web_native = !session.is_terminal });│ │117 errdefer scroll.deinit(allocator); │ │118 return .{ .scroll = scroll, .data = data }; │ │119 } │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ──────╮ │src/ui/User.zig│ ╰───────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────╮ │107 │ │108 // repos list — the default tab │ │109 { │ │110 - var list = try ui.FlowBox.Scroll.init(allocator, .{}); │ │110 + var list = try ui.FlowBox.Scroll.init(allocator, .{}, !session.is_terminal);│ │111 errdefer list.deinit(allocator); │ │112 │ │113 var arena = std.heap.ArenaAllocator.init(allocator); │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ──╮ │src/web.zig│ ╰───────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │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; │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯