╭──────────╮ │← previous│ ╰──────────╯ ╭ changed ────────────╮ │src/ui/Repo/Files.zig│ ╰─────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │483 } │ │484 } │ │485 │ │486 + // same for the "next" link above the content. │ │487 + const nav = self.navBox(); │ │488 + for (nav.children.keys(), nav.children.values()) |id, *child| { │ │489 + switch (child.widget) { │ │490 + .text_box => |*tb| tb.options.border_style = if (nav.getFocus().child_id == id) .single else .hidden,│ │491 + else => {}, │ │492 + } │ │493 + } │ │494 + │ │495 // cap the list at list_max_width only while the detail pane fits beside │ │496 // it. the box drops the detail pane when the width can't hold both │ │497 // minimums, so when it's that narrow we lift the cap and let the list │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────╮ │515 } │ │516 │ │517 fn populateDetail(self: *View, allocator: std.mem.Allocator) !void { │ │518 + const nav = self.navBox(); │ │519 const inner = self.detailInner(); │ │520 │ │521 + for (nav.children.values()) |*child| child.widget.deinit(allocator); │ │522 + nav.children.clearAndFree(allocator); │ │523 + nav.getFocus().child_id = null; │ │524 for (inner.children.values()) |*child| child.widget.deinit(allocator);│ │525 inner.children.clearAndFree(allocator); │ │526 inner.getFocus().child_id = null; │ ╰───────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │529 // the pane empty. │ │530 if (self.selectedEntry()) |entry| { │ │531 if (!entry.is_dir) { │ │532 + // the "next" window link sits above the scroll, so it stays put │ │533 + // while the content scrolls. │ │534 + if (entry.has_more) try self.addNavLink(allocator, nav, "next lines →", entry, entry.window_start + file_page);│ │535 if (entry.is_binary) { │ │536 try self.addContentBox(allocator, inner, "(binary file)"); │ │537 } else { │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │459 - const text = try std.mem.join(self.session.page_arena.allocator(), "\n", entry.lines); │ │538 + const text = try numberedContent(self.session.page_arena.allocator(), entry.lines, entry.window_start);│ │539 try self.addContentBox(allocator, inner, text); │ │540 } │ │541 } │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │542 } │ │464 - // point the pane at its content box so focus recovery can land here. │ │543 + // point each box at its first child so focus can descend into it. │ │544 + if (nav.children.count() > 0) nav.getFocus().child_id = nav.children.keys()[0]; │ │545 if (inner.children.count() > 0) inner.getFocus().child_id = inner.children.keys()[0];│ │546 │ │547 // reset the scroll to the top for the newly-shown file: directly on the │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │553 sc.getFocus().version +%= 1; │ │554 } │ │555 │ │556 + // a focusable "next" link above the content. it's an `a:` link to the │ │557 + // selected file at `target_after`, so activating it (the host follows the │ │558 + // link) reloads the page on the next window. │ │559 + fn addNavLink(self: *View, allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), label: []const u8, entry: Entry, target_after: usize) !void { │ │560 + const page_arena = self.session.page_arena; │ │561 + const path = try childDir(page_arena.allocator(), self.data.dir, entry.name); │ │562 + const route = ui.RoutablePage.repoFilesRoute(self.data.identity, self.data.ref_or_oid, self.data.ref_or_oid_value, path, target_after) orelse return error.RouteTooLong;│ │563 + const link = try std.fmt.allocPrint(page_arena.allocator(), "a:{s}", .{try route.urlAlloc(page_arena)}); │ │564 + var tb = try wgt.TextBox(ui.Widget).init(allocator, label, .{ .border_style = .hidden, .rounded_corners = true, .wrap_kind = .none }); │ │565 + errdefer tb.deinit(allocator); │ │566 + tb.getFocus().focusable = true; │ │567 + tb.getFocus().kind = .{ .custom = link }; │ │568 + try box.children.put(allocator, tb.getFocus().id, .{ .widget = .{ .text_box = tb }, .rect = null, .min_size = null }); │ │569 + } │ │570 + │ │571 pub fn input(self: *View, allocator: std.mem.Allocator, key: inp.Key, root_focus: *Focus) !void { │ │572 _ = allocator; │ │573 if (self.detailActive()) { │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────╮ │596 }, │ │597 else => {}, │ │598 } │ │599 + } │ │600 + │ │601 + // whether focus is on the content box (vs. a "previous"/"next" nav link).│ │602 + fn focusOnContent(self: *View, root_focus: *Focus) bool { │ │603 + const g = root_focus.grandchild_id orelse return false; │ │604 + return self.detailInner().children.contains(g); │ │605 } │ │606 │ │607 fn detailInput(self: *View, key: inp.Key, root_focus: *Focus) !void { │ ╰───────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────╮ │608 const sc = self.detailScroll(); │ │508 - // on the web each Scroll is a native scrollable element, so vertical │ │509 - // scrolling is handled by the browser; the terminal scrolls the content │ │510 - // by moving the offset. left scrolls horizontally, then returns to the │ │511 - // list once flush left. │ │609 + const on_content = self.focusOnContent(root_focus); │ │610 + // the "next" link sits above the content scroll. on the link, left returns│ │611 + // to the file list and down drops into the content. in the content, the │ │612 + // terminal scrolls by offset and reaching the top then pressing up crosses│ │613 + // back up to the link; on the web vertical scrolling is the browser's job │ │614 + // so up just crosses to the link. │ │615 switch (key) { │ │616 .arrow_left => { │ │514 - if (sc.x > 0) { │ │515 - sc.x -= 1; │ │516 - self.clampDetailScroll(); │ │517 - } else try self.focusList(root_focus); │ │617 + if (on_content) { │ │618 + if (sc.x > 0) { │ │619 + sc.x -= 1; │ │620 + self.clampDetailScroll(); │ │621 + } else try self.focusList(root_focus); │ │622 + } else if (!self.moveNav(root_focus, -1)) { │ │623 + try self.focusList(root_focus); │ │624 + } │ │625 }, │ │626 .arrow_right => { │ │520 - sc.x += 1; │ │521 - self.clampDetailScroll(); │ │627 + if (on_content) { │ │628 + sc.x += 1; │ │629 + self.clampDetailScroll(); │ │630 + } else _ = self.moveNav(root_focus, 1); │ │631 }, │ │523 - .arrow_up => if (self.session.is_terminal) { │ │524 - sc.y -= 1; │ │525 - self.clampDetailScroll(); │ │632 + .arrow_up => { │ │633 + if (on_content) { │ │634 + if (self.session.is_terminal and sc.y > 0) { │ │635 + sc.y -= 1; │ │636 + self.clampDetailScroll(); │ │637 + } else self.focusNav(root_focus); // cross up to the links │ │638 + } │ │639 }, │ │527 - .arrow_down => if (self.session.is_terminal) { │ │528 - sc.y += 1; │ │529 - self.clampDetailScroll(); │ │640 + .arrow_down => { │ │641 + if (on_content) { │ │642 + if (self.session.is_terminal) { │ │643 + sc.y += 1; │ │644 + self.clampDetailScroll(); │ │645 + } │ │646 + } else self.focusContent(root_focus); // links -> content │ │647 }, │ │531 - .page_up => if (self.session.is_terminal) { │ │648 + .page_up => if (on_content and self.session.is_terminal) { │ │649 sc.y -= 10; │ │650 self.clampDetailScroll(); │ │651 }, │ ╰────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────╮ │535 - .page_down => if (self.session.is_terminal) { │ │652 + .page_down => if (on_content and self.session.is_terminal) {│ │653 sc.y += 10; │ │654 self.clampDetailScroll(); │ │655 }, │ ╰─────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────╮ │539 - .home => if (self.session.is_terminal) { │ │656 + .home => if (on_content and self.session.is_terminal) {│ │657 sc.y = 0; │ │658 self.clampDetailScroll(); │ │659 }, │ ╰────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯