╭ changed ────╮ │build.zig.zon│ ╰─────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ 3 .version = "0.0.0", │ │ 4 .dependencies = .{ │ │ 5 .xit = .{ │ │ 6 - .url = "git+https://github.com/xit-vcs/xit#a0e9b464f44745a8dc00c5ca478761be2abb1474",│ │ 7 - .hash = "xit-0.0.0-Ub3nDNLvTwEH4cEytrKMWIy4fNls7NvsS0X5RKcQqnmL", │ │ 6 + .url = "git+https://github.com/xit-vcs/xit#9de918726a6e50c8fa1ec5fedcac079d3587b014",│ │ 7 + .hash = "xit-0.0.0-Ub3nDNLvTwG5iWmglRVQ-ICg4jTr-MCya5Tw_iaIW7q3", │ │ 8 }, │ │ 9 }, │ │10 .paths = .{ │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ───────────╮ │src/embed/index.html│ ╰────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────╮ │ 7 html { │ │ 8 background-color: rgba(0, 50, 70, 1.0); │ │ 9 color: rgba(200, 200, 200, 1.0); │ │10 - /* always reserve the vertical scrollbar's space so the usable width│ │11 - is constant whether or not it's showing */ │ │12 - scrollbar-gutter: stable; │ │10 + } │ │11 + │ │12 + /* no body margin because it could push content past the viewport and │ │13 + add a window scrollbar */ │ │14 + body { │ │15 + margin: 0; │ │16 } │ │17 │ │18 @font-face { │ ╰────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────╮ │29 │ │30 #grid { │ │31 white-space: pre; │ │32 + /* positioning context for the top-level .scroll divs */ │ │33 + position: relative; │ │34 } │ │35 │ │36 + /* each web-native Scroll widget: a box positioned over its viewport │ │37 + (left/top/width/height and per-axis overflow come inline, the latter│ │38 + from the widget's scroll direction) */ │ │39 + .scroll { │ │40 + position: absolute; │ │41 + /* scrollbars are hidden; panes scroll via wheel/trackpad and via │ │42 + scrollIntoView on keyboard focus, so a visible bar would only │ │43 + cover content. (Firefox / WebKit respectively.) */ │ │44 + scrollbar-width: none; │ │45 + } │ │46 + .scroll::-webkit-scrollbar { │ │47 + display: none; │ │48 + } │ │49 + │ │50 /* render each colored cell-run as an inline-block to prevent │ │51 horizontal seams between rows of the block art. */ │ │52 #grid span { │ ╰─────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ──────────╮ │src/embed/script.js│ ╰───────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────╮ │57 const html = readWasmString(ptr, len); │ │58 if (html === currentHtml) return; │ │59 currentHtml = html; │ │60 - // the grid only holds non-focusable TUI cell spans now; the │ │61 - // form (inputs + submit button) lives in #overlay and is │ │62 - // never touched by this wipe, so we don't need to save and │ │63 - // restore focus across the swap. │ │60 + // each web-native Scroll renders as a .scroll div; replacing │ │61 + // innerHTML recreates them and would reset their scroll position │ │62 + // every tick, so snapshot scrollTop/Left by their stable id and │ │63 + // restore it after the swap. (mouse-wheel scrolling happens with no │ │64 + // tick, so it isn't affected.) │ │65 + const positions = {}; │ │66 + for (const el of grid.querySelectorAll(".scroll[data-scroll-id]")) { │ │67 + positions[el.dataset.scrollId] = { top: el.scrollTop, left: el.scrollLeft };│ │68 + } │ │69 grid.innerHTML = html; │ │70 + for (const el of grid.querySelectorAll(".scroll[data-scroll-id]")) { │ │71 + const p = positions[el.dataset.scrollId]; │ │72 + if (p) { el.scrollTop = p.top; el.scrollLeft = p.left; } │ │73 + } │ │74 }, │ │75 _replaceState: function (ptr, len) { │ │76 const url = readWasmString(ptr, len); │ ╰────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │103 // can't render it client-side without server data). │ │104 window.location.assign(readWasmString(ptr, len)); │ │105 }, │ │ 97 - _scrollToRect: function (x, y, w, h) { │ │ 98 - // the rect arrives in grid cells; map it to document coordinates │ │ 99 - // via the measured cell size and the grid origin, then scroll the │ │100 - // minimum needed to bring the widget fully into view │ │101 - if (!cellHeight) measureCell(); │ │102 - const gridTop = grid.getBoundingClientRect().top + window.scrollY; │ │103 - const top = gridTop + y * cellHeight; │ │104 - const bottom = top + h * cellHeight; │ │105 - const viewTop = window.scrollY; │ │106 - const viewBottom = viewTop + document.documentElement.clientHeight; │ │107 - if (top < viewTop) { │ │108 - window.scrollTo({ top }); │ │109 - } else if (bottom > viewBottom) { │ │110 - window.scrollTo({ top: bottom - document.documentElement.clientHeight });│ │106 + _scrollToFocus: function (id) { │ │107 + // bring the focused widget into view within its own .scroll div. it's │ │108 + // several spans (one per row) sharing this id, in top-to-bottom order, │ │109 + // so the first gives its top/left edge and the last its bottom. │ │110 + const els = grid.querySelectorAll(`[data-focus-id="${id}"]`); │ │111 + if (els.length === 0) return; │ │112 + const container = els[0].closest(".scroll"); │ │113 + if (!container) return; │ │114 + │ │115 + const c = container.getBoundingClientRect(); │ │116 + const first = els[0].getBoundingClientRect(); │ │117 + const last = els[els.length - 1].getBoundingClientRect(); │ │118 + const top = first.top - c.top + container.scrollTop; │ │119 + const bottom = last.bottom - c.top + container.scrollTop; │ │120 + const left = first.left - c.left + container.scrollLeft; │ │121 + │ │122 + // vertical is the priority axis: fit the widget, preferring its top │ │123 + // when it's taller than the viewport. │ │124 + if (top < container.scrollTop || bottom - top > container.clientHeight) { │ │125 + container.scrollTop = top; │ │126 + } else if (bottom > container.scrollTop + container.clientHeight) { │ │127 + container.scrollTop = bottom - container.clientHeight; │ │128 } │ │129 + // horizontal: only reveal the row's start, never chase the right edge, │ │130 + // so moving down through varying-width rows doesn't yank the view │ │131 + // sideways. (a hidden-overflow x axis stays at 0, so this no-ops.) │ │132 + if (left < container.scrollLeft) container.scrollLeft = left; │ │133 }, │ │134 }, │ │135 }; │ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ────────╮ │src/main_wasm.zig│ ╰─────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────╮ │54 session.applyPending(); │ │55 │ │56 try root_ptr.build(allocator, .{ │ │57 - // min_height lets the TUI fill the viewport when its content is │ │58 - // short; max height stays null so taller content extends downward │ │59 - // and the browser scrolls. │ │57 + // bind the UI to the browser viewport (cols x rows) like the terminal: │ │58 + // min == max height fills it exactly, and each Scroll clips to its │ │59 + // viewport while handing its full content to a native-scrollable element.│ │60 .min_size = .{ .width = null, .height = min_height }, │ │61 - .max_size = .{ .width = max_width, .height = null }, │ │61 + .max_size = .{ .width = max_width, .height = min_height }, │ │62 }, root_ptr.getFocus()); │ │63 │ │64 // mirror the page Header settled on into the browser URL. these are │ ╰──────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ 99 else => {}, │ │100 } │ │101 │ │102 - // keep the focused widget within the browser viewport. only fire on │ │103 - // an actual focus change so we don't fight the user's manual │ │104 - // scrolling on unrelated ticks. │ │102 + // keep the focused widget visible within its own scrollable element. │ │103 + // only fire on an actual focus change so we don't fight the user's │ │104 + // manual scrolling on unrelated ticks. │ │105 if (gid != last_scrolled_focus_id) { │ │106 last_scrolled_focus_id = gid; │ │107 - const r = child.rect; │ │108 - _scrollToRect(@intCast(r.x), @intCast(r.y), @intCast(r.size.width), @intCast(r.size.height));│ │107 + _scrollToFocus(@intCast(gid)); │ │108 } │ │109 } │ │110 } │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────╮ │177 extern fn _replaceState(arg: [*]const u8, len: u32) void; │ │178 extern fn _focusInput(focus_id: u32) void; │ │179 extern fn _navigate(arg: [*]const u8, len: u32) void; │ │181 -extern fn _scrollToRect(x: u32, y: u32, width: u32, height: u32) void; │ │180 +extern fn _scrollToFocus(focus_id: u32) void; │ │181 │ │182 /// js calls this first to get a wasm pointer it can write the page json into.│ │183 export fn _alloc(len: u32) ?[*]u8 { │ ╰───────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ─╮ │src/ui.zig│ ╰──────────╯ ╭────────────────────────────────────────────────────────────────────────────────╮ │503 } │ │504 } │ │505 │ │506 - // request a forward navigation to `route`; Nav.sync builds it next │ │507 - fn navigate(self: *Session, route: RoutablePage) !void { │ │506 + // request a forward navigation to `route`; the host consumes next_page│ │507 + // (Nav.sync on the terminal, the wasm tick on the web). │ │508 + pub fn navigate(self: *Session, route: RoutablePage) !void { │ │509 self.next_page = route; │ │510 } │ │511 }; │ ╰────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │1043 pub const Scroll = struct { │ │1044 scroll: wgt.Scroll(Widget), │ │1045 │ │1045 - pub fn init(allocator: std.mem.Allocator, options: FlowBox.Options) !Scroll { │ │1046 + pub fn init(allocator: std.mem.Allocator, options: FlowBox.Options, web_native: bool) !Scroll { │ │1047 var layout_inner = try FlowBox.init(allocator, options); │ │1048 errdefer layout_inner.deinit(allocator); │ │1048 - var scroll = try wgt.Scroll(Widget).init(allocator, .{ .flow_box = layout_inner }, .{ .direction = .vert }); │ │1049 + var scroll = try wgt.Scroll(Widget).init(allocator, .{ .flow_box = layout_inner }, .{ .direction = .vert, .web_native = web_native });│ │1050 errdefer scroll.deinit(allocator); │ │1051 return .{ .scroll = scroll }; │ │1052 } │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯