╭──────────╮ │← previous│ ╰──────────╯ ╭ changed ──────╮ │src/ui/User.zig│ ╰───────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │321 } │ │322 │ │323 pub fn getFocus(self: *View) *Focus { │ │324 + return self.box.getFocus(); │ │325 + } │ │326 +}; │ │327 + │ │328 +pub const ReposView = struct { │ │329 + // a vertical box: the search sub-header above the scrolling list. focus │ │330 + // points at the header's box or the list's selected row. │ │331 + box: wgt.Box(ui.Widget), │ │332 + data: *const Self, │ │333 + session: *ui.Session, │ │334 + │ │335 + const header_index = 0; │ │336 + const list_index = 1; │ │337 + │ │338 + pub fn init(allocator: std.mem.Allocator, data: *const Self, session: *ui.Session) !ReposView { │ │339 + var box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); │ │340 + errdefer box.deinit(allocator); │ │341 + │ │342 + { │ │343 + var header = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .horiz }); │ │344 + errdefer header.deinit(allocator); │ │345 + { │ │346 + var search_box = try ui.widget.SearchBox.init(allocator, session, " search ", "search", data.repos_search); │ │347 + errdefer search_box.deinit(allocator); │ │348 + header.getFocus().child_id = search_box.getFocus().id; │ │349 + try header.children.put(allocator, search_box.getFocus().id, .{ .widget = .{ .search_box = search_box }, .rect = null, .min_size = ui.widget.SearchBox.min_size }); │ │350 + } │ │351 + // the spacer keeps the box at its own width │ │352 + { │ │353 + var spacer = try ui.widget.Spacer.init(allocator); │ │354 + errdefer spacer.deinit(allocator); │ │355 + try header.children.put(allocator, spacer.getFocus().id, .{ .widget = .{ .spacer = spacer }, .rect = null, .min_size = null }); │ │356 + } │ │357 + try box.children.put(allocator, header.getFocus().id, .{ .widget = .{ .box = header }, .rect = null, .min_size = .{ .width = null, .height = ui.widget.SearchBox.min_size.height } });│ │358 + } │ │359 + │ │360 + { │ │361 + var list = try ui.widget.FlowBox.Scroll.init(allocator, .{}, !session.is_terminal); │ │362 + errdefer list.deinit(allocator); │ │363 + │ │364 + var arena = std.heap.ArenaAllocator.init(allocator); │ │365 + defer arena.deinit(); │ │366 + const aa = arena.allocator(); │ │367 + │ │368 + const route = (reposRoute(data.user.name) orelse return error.RouteTooLong).withSearch(data.repos_search orelse "") orelse return error.RouteTooLong; │ │369 + │ │370 + // a leading "previous" row off the first window, one row per repo, │ │371 + // then a trailing "next" row when more remain. each window row │ │372 + // navigates to the adjacent window of this user's repos. │ │373 + var items: std.ArrayList(ui.widget.FlowBox.Item) = .empty; │ │374 + if (data.repos_start > 0) { │ │375 + var prev = route; │ │376 + prev.user_repos.start = data.repos_start -| page_size; │ │377 + try items.append(aa, .{ .text = "← previous", .link = try std.fmt.allocPrint(aa, "a:{s}", .{try prev.toUrl(&arena)}) }); │ │378 + } │ │379 + for (data.repos) |repo| │ │380 + // clicking a repo opens its page; the "a:" prefix makes the web │ │381 + // renderer emit an <a href="/repo/alice/foo"> anchor. │ │382 + try items.append(aa, .{ │ │383 + .text = try std.fmt.allocPrint(aa, "{s} - {s}", .{ repo.event.name, repo.event.description }), │ │384 + .link = try std.fmt.allocPrint(aa, "a:/repo/{s}/{s}", .{ data.user.name, repo.event.name }), │ │385 + }); │ │386 + if (data.repos_next_start) |next_start| { │ │387 + var next = route; │ │388 + next.user_repos.start = next_start; │ │389 + try items.append(aa, .{ .text = "next →", .link = try std.fmt.allocPrint(aa, "a:{s}", .{try next.toUrl(&arena)}) }); │ │390 + } │ │391 + try list.setItems(allocator, items.items); │ │392 + │ │393 + try box.children.put(allocator, list.getFocus().id, .{ .widget = .{ .flow_box_scroll = list }, .rect = null, .min_size = null }); │ │394 + } │ │395 + │ │396 + // search results start in the box so the term can be refined right away │ │397 + box.getFocus().child_id = box.children.keys()[if (data.repos_search != null) header_index else list_index]; │ │398 + return .{ .box = box, .data = data, .session = session }; │ │399 + } │ │400 + │ │401 + pub fn deinit(self: *ReposView, allocator: std.mem.Allocator) void { │ │402 + self.box.deinit(allocator); │ │403 + } │ │404 + │ │405 + pub fn build(self: *ReposView, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { │ │406 + self.clearGrid(); │ │407 + // clear the incoming min height so the list scrolls within what's left │ │408 + try self.box.build(allocator, .{ │ │409 + .min_size = .{ .width = null, .height = null }, │ │410 + .max_size = constraint.max_size, │ │411 + }, root_focus); │ │412 + } │ │413 + │ │414 + pub fn input(self: *ReposView, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { │ │415 + const direction = inp.vertDirection(key); │ │416 + if (self.headerActive()) { │ │417 + const search_box = self.searchBox(); │ │418 + if (direction == .down) return root_focus.setFocus(self.listScroll().getFocus().id); │ │419 + if (key == .enter) { │ │420 + const text = try search_box.text(allocator); │ │421 + defer allocator.free(text); │ │422 + return self.submit(text); │ │423 + } │ │424 + return search_box.input(allocator, key, root_focus); │ │425 + } │ │426 + // up from the first row reaches the search box │ │427 + if (direction == .up and self.listScroll().atTop()) return self.focusHeader(root_focus); │ │428 + try self.listScroll().input(allocator, key, root_focus); │ │429 + } │ │430 + │ │431 + // the repos narrowed to `text` from the first window. an empty box on an │ │432 + // unsearched page has nothing to clear. │ │433 + fn submit(self: *ReposView, text: []const u8) !void { │ │434 + if (text.len == 0 and self.data.repos_search == null) return; │ │435 + const route = reposRoute(self.data.user.name) orelse return; │ │436 + try self.session.navigate(route.withSearch(text) orelse return); │ │437 + } │ │438 + │ │439 + fn searchBox(self: *ReposView) *ui.widget.SearchBox { │ │440 + return &self.box.children.values()[header_index].widget.box.children.values()[0].widget.search_box; │ │441 + } │ │442 + │ │443 + fn listScroll(self: *ReposView) *ui.widget.FlowBox.Scroll { │ │444 + return &self.box.children.values()[list_index].widget.flow_box_scroll; │ │445 + } │ │446 + │ │447 + fn headerActive(self: *ReposView) bool { │ │448 + return self.box.getFocus().child_id == self.box.children.keys()[header_index]; │ │449 + } │ │450 + │ │451 + pub fn clearGrid(self: *ReposView) void { │ │452 + self.box.clearGrid(); │ │453 + } │ │454 + │ │455 + pub fn getGrid(self: ReposView) ?Grid { │ │456 + return self.box.getGrid(); │ │457 + } │ │458 + │ │459 + pub fn getFocus(self: *ReposView) *Focus { │ │460 return self.box.getFocus(); │ │461 + } │ │462 + │ │463 + // up leaves the tab from the search box │ │464 + pub fn atTop(self: *ReposView) bool { │ │465 + return self.headerActive(); │ │466 + } │ │467 + │ │468 + pub fn focusHeader(self: *ReposView, root_focus: *Focus) void { │ │469 + root_focus.setFocus(self.searchBox().getFocus().id); │ │470 } │ │471 }; │ │472 │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │473 +// the first window of `name`'s repos │ │474 +fn reposRoute(name: []const u8) ?ui.RoutablePage { │ │475 + return .{ .user_repos = .{ .name = ui.RoutablePage.Array(evt.User.name_max_len).from(name) orelse return null } };│ │476 +} │ │477 + │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ────────╮ │src/ui/widget.zig│ ╰─────────────────╯ ╭─────────────────────────────────────────╮ │37 sub_title: ui.SubTitle.View, │ │38 home_header: ui.Home.Header.View,│ │39 user_header: ui.User.Header.View,│ │40 + user_repos: ui.User.ReposView, │ │41 repo_header: ui.Repo.Header.View,│ │42 fork_header: ui.Fork.Header.View,│ │43 search_box: SearchBox, │ ╰─────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │112 // whether moving up should return to the page header │ │113 pub fn atTop(self: *Widget, root_focus: *Focus) bool { │ │114 return switch (self.*) { │ │114 - inline .home_about, .home_users, .flow_box_scroll, .home_settings, .auth_login, .auth_logout, .quit, .repo_files, .repo_commits, .diff_view, .repo_refs, .repo_issues, .repo_patches, .repo_discussions, .repo_events, .repo_undo => |*view| view.atTop(), │ │115 + inline .home_about, .home_users, .user_repos, .flow_box_scroll, .home_settings, .auth_login, .auth_logout, .quit, .repo_files, .repo_commits, .diff_view, .repo_refs, .repo_issues, .repo_patches, .repo_discussions, .repo_events, .repo_undo => |*view| view.atTop(),│ │116 inline .home_auth, .repo_patch_detail => |*view| view.atTop(root_focus), │ │117 else => false, │ │118 }; │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────╮ │446 return self.scroll.getFocus(); │ │447 } │ │448 │ │449 + pub fn isEmpty(self: Scroll) bool { │ │450 + return self.scroll.child.flow_box.text_boxes.items.len == 0;│ │451 + } │ │452 + │ │453 pub fn atTop(self: Scroll) bool { │ │454 const in = self.scroll.child.flow_box; │ │455 const child_id = in.focus.child_id orelse return false; │ ╰─────────────────────────────────────────────────────────────────────────────╯