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