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