╭─────────────╮ │scroll to top│ ╰─────────────╯ ╭ User.zig ─────────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
366 pub fn getGrid(self: View) ?Grid { 367 return self.box.getGrid(); 368 } 369 370 pub fn getFocus(self: *View) *Focus { 371 return self.box.getFocus(); 372 } 373 }; 374 375 pub const ReposView = struct { 376 // a vertical box: the search sub-header above the scrolling list. focus 377 // points at the header's box or the list's selected row. 378 box: wgt.Box(ui.Widget), 379 data: *const Self, 380 session: *ui.Session, 381 382 const header_index = 0; 383 const list_index = 1; 384 385 pub fn init(allocator: std.mem.Allocator, data: *const Self, session: *ui.Session) !ReposView { 386 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border = null, .direction = .vert }); 387 errdefer box.deinit(allocator); 388 389 { 390 var header = try wgt.Box(ui.Widget).init(allocator, .{ .border = null, .direction = .horiz }); 391 errdefer header.deinit(allocator); 392 { 393 var search_box = try ui.widget.SearchBox.init(allocator, session, " search ", "search", data.repos_search); 394 errdefer search_box.deinit(allocator); 395 header.getFocus().child_id = search_box.getFocus().id; 396 try header.children.put(allocator, search_box.getFocus().id, .{ .widget = .{ .search_box = search_box }, .rect = null, .min_size = ui.widget.SearchBox.min_size }); 397 } 398 // the spacer keeps the box at its own width 399 { 400 var spacer = try ui.widget.Spacer.init(allocator); 401 errdefer spacer.deinit(allocator); 402 try header.children.put(allocator, spacer.getFocus().id, .{ .widget = .{ .spacer = spacer }, .rect = null, .min_size = null }); 403 } 404 try box.children.put(allocator, header.getFocus().id, .{ .widget = .{ .box = header }, .rect = null, .min_size = .{ .width = null, .height = ui.widget.SearchBox.min_size.height } }); 405 } 406 407 // an empty first window has no rows, not even paging ones 408 if (data.reposEmpty()) { 409 var center = try ui.widget.initEmpty(allocator); 410 errdefer center.deinit(allocator); 411 try box.children.put(allocator, center.getFocus().id, .{ .widget = .{ .center = center }, .rect = null, .min_size = null }); 412 } else { 413 var list = try ui.widget.FlowBox.Scroll.init(allocator, .{}, !session.is_terminal); 414 errdefer list.deinit(allocator); 415 416 var arena = std.heap.ArenaAllocator.init(allocator); 417 defer arena.deinit(); 418 const aa = arena.allocator(); 419 420 const route = (reposRoute(data.user.name) orelse return error.RouteTooLong).withSearch(data.repos_search orelse "") orelse return error.RouteTooLong; 421 422 // a leading "previous" row off the first window, one row per repo, 423 // then a trailing "next" row when more remain. each window row 424 // navigates to the adjacent window of this user's repos. 425 var items: std.ArrayList(ui.widget.FlowBox.Item) = .empty; 426 if (data.repos_start > 0) { 427 var prev = route; 428 prev.user_repos.start = data.repos_start -| page_size; 429 try items.append(aa, .{ .text = "← previous", .link = try aa.print("a:{s}", .{try prev.toUrl(&arena)}) }); 430 } 431 for (data.repos) |repo| 432 // clicking a repo opens its page; the "a:" prefix makes the web 433 // renderer emit an <a href="/alice:foo"> anchor. 434 try items.append(aa, .{ 435 .text = if (repo.event.description.len == 0) repo.event.name else try aa.print("{s} - {s}", .{ repo.event.name, repo.event.description }), 436 .link = try aa.print("a:/{s}:{s}", .{ data.user.name, repo.event.name }), 437 .bottom_label = if (repo.event.read_access == .private) "(private)" else "", 438 }); 439 if (data.repos_next_start) |next_start| { 440 var next = route; 441 next.user_repos.start = next_start; 442 try items.append(aa, .{ .text = "next →", .link = try aa.print("a:{s}", .{try next.toUrl(&arena)}) }); 443 } 444 try list.setItems(allocator, items.items); 445 446 try box.children.put(allocator, list.getFocus().id, .{ .widget = .{ .flow_box_scroll = list }, .rect = null, .min_size = null }); 447 } 448 449 // search results start in the box so the term can be refined right away 450 box.getFocus().child_id = box.children.keys()[if (data.repos_search != null) header_index else list_index]; 451 return .{ .box = box, .data = data, .session = session }; 452 } 453 454 pub fn deinit(self: *ReposView, allocator: std.mem.Allocator) void { 455 self.box.deinit(allocator); 456 } 457 458 pub fn build(self: *ReposView, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 459 self.clearGrid(); 460 // clear the incoming min height so the list scrolls within what's left 461 try self.box.build(allocator, .{ 462 .min_size = .{ .width = null, .height = null }, 463 .max_size = constraint.max_size, 464 }, root_focus); 465 } 466 467 pub fn input(self: *ReposView, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 468 const direction = inp.vertDirection(key); 469 if (self.headerActive()) { 470 const search_box = self.searchBox(); 471 if (direction == .down) { 472 // the empty message has nothing to focus 473 if (!self.data.reposEmpty()) root_focus.setFocus(self.listScroll().getFocus().id); 474 return; 475 } 476 if (key == .enter) { 477 const text = try search_box.text(allocator); 478 defer allocator.free(text); 479 return self.submit(text); 480 } 481 return search_box.input(allocator, key, root_focus); 482 } 483 // up from the first row reaches the search box 484 if (direction == .up and self.listScroll().atTop()) return self.focusHeader(root_focus); 485 try self.listScroll().input(allocator, key, root_focus); 486 } 487 488 // the repos narrowed to `text` from the first window. an empty box on an 489 // unsearched page has nothing to clear. 490 fn submit(self: *ReposView, text: []const u8) !void { 491 if (text.len == 0 and self.data.repos_search == null) return; 492 const route = reposRoute(self.data.user.name) orelse return; 493 try self.session.navigate(route.withSearch(text) orelse return); 494 } 495 496 fn searchBox(self: *ReposView) *ui.widget.SearchBox { 497 return &self.box.children.values()[header_index].widget.box.children.values()[0].widget.search_box; 498 } 499 500 fn listScroll(self: *ReposView) *ui.widget.FlowBox.Scroll { 501 return &self.box.children.values()[list_index].widget.flow_box_scroll; 502 } 503 504 fn headerActive(self: *ReposView) bool { 505 return self.box.getFocus().child_id == self.box.children.keys()[header_index]; 506 } 507 508 pub fn clearGrid(self: *ReposView) void { 509 self.box.clearGrid(); 510 } 511 512 pub fn getGrid(self: ReposView) ?Grid { 513 return self.box.getGrid(); 514 } 515 516 pub fn getFocus(self: *ReposView) *Focus { 517 return self.box.getFocus(); 518 } 519 520 // up leaves the tab from the search box 521 pub fn atTop(self: *ReposView) bool { 522 return self.headerActive(); 523 } 524 525 pub fn focusHeader(self: *ReposView, root_focus: *Focus) void { 526 root_focus.setFocus(self.searchBox().getFocus().id); 527 } 528 }; 529 530 // the first window of `name`'s repos 531 fn reposRoute(name: []const u8) ?ui.RoutablePage { 532 return .{ .user_repos = .{ .name = ui.RoutablePage.Array(evt.User.name_max_len).from(name) orelse return null } }; 533 } 534