╭─────────────╮ │scroll to top│ ╰─────────────╯ ╭ User.zig ─────────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
299 // each header tab maps 1:1 to a stack child by position 300 if (header.getSelectedIndex()) |index| 301 stack.getFocus().child_id = stack.children.keys()[index]; 302 try self.box.build(allocator, constraint, root_focus); 303 } 304 305 pub fn input(self: *View, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 306 const stack = &self.box.children.values()[stack_index].widget.stack; 307 if (self.getFocus().child_id) |child_id| { 308 if (self.box.children.getIndex(child_id)) |current_index| { 309 const child = &self.box.children.values()[current_index].widget; 310 var index = current_index; 311 312 const direction = inp.vertDirection(key); 313 314 switch (direction) { 315 .up => { 316 switch (child.*) { 317 .user_header => { 318 try child.input(allocator, key, root_focus); 319 }, 320 .stack => { 321 if (child.stack.getSelected()) |selected_widget| { 322 if (selected_widget.atTop(root_focus)) { 323 index = header_index; 324 } else { 325 try child.input(allocator, key, root_focus); 326 } 327 } 328 }, 329 else => {}, 330 } 331 }, 332 .down => { 333 switch (child.*) { 334 .user_header => { 335 // the empty message has nothing to focus 336 if (self.data.forksEmpty() and stack.getFocus().child_id == stack.children.keys()[forks_index]) return; 337 if (stack.getSelected()) |selected_widget| switch (selected_widget.*) { 338 .user_repos => |*v| return v.focusHeader(root_focus), 339 .user_notifications => |*v| if (v.focusHeader(root_focus)) return, 340 else => {}, 341 }; 342 index = stack_index; 343 }, 344 .stack => { 345 try child.input(allocator, key, root_focus); 346 }, 347 else => {}, 348 } 349 }, 350 .none => { 351 try child.input(allocator, key, root_focus); 352 }, 353 } 354 355 if (index != current_index) { 356 root_focus.setFocus(self.box.children.keys()[index]); 357 } 358 } 359 } 360 } 361 362 pub fn clearGrid(self: *View) void { 363 self.box.clearGrid(); 364 } 365 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_scroll = try ui.widget.HeaderScroll.init(allocator, session); 391 errdefer header_scroll.deinit(allocator); 392 const header = header_scroll.row(); 393 { 394 var search_box = try ui.widget.SearchBox.init(allocator, session, " search ", "search", data.repos_search); 395 errdefer search_box.deinit(allocator); 396 header.getFocus().child_id = search_box.getFocus().id; 397 try header.children.put(allocator, search_box.getFocus().id, .{ .widget = .{ .search_box = search_box }, .rect = null, .min_size = ui.widget.SearchBox.min_size }); 398 } 399 // the spacer keeps the box at its own width 400 { 401 var spacer = try ui.widget.Spacer.init(allocator); 402 errdefer spacer.deinit(allocator); 403 try header.children.put(allocator, spacer.getFocus().id, .{ .widget = .{ .spacer = spacer }, .rect = null, .min_size = null }); 404 } 405 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 } }); 406 } 407 408 // an empty first window has no rows, not even paging ones 409 if (data.reposEmpty()) { 410 var center = try ui.widget.initEmpty(allocator); 411 errdefer center.deinit(allocator); 412 try box.children.put(allocator, center.getFocus().id, .{ .widget = .{ .center = center }, .rect = null, .min_size = null }); 413 } else { 414 var list = try ui.widget.FlowBox.Scroll.init(allocator, .{}, !session.is_terminal); 415 errdefer list.deinit(allocator); 416 417 var arena = std.heap.ArenaAllocator.init(allocator); 418 defer arena.deinit(); 419 const aa = arena.allocator(); 420 421 const route = (reposRoute(data.user.name) orelse return error.RouteTooLong).withSearch(data.repos_search orelse "") orelse return error.RouteTooLong; 422 423 // a leading "previous" row off the first window, one row per repo, 424 // then a trailing "next" row when more remain. each window row 425 // navigates to the adjacent window of this user's repos. 426 var items: std.ArrayList(ui.widget.FlowBox.Item) = .empty; 427 if (data.repos_start > 0) { 428 var prev = route; 429 prev.user_repos.start = data.repos_start -| page_size; 430 try items.append(aa, .{ .text = "← previous", .link = try aa.print("a:{s}", .{try prev.toUrl(&arena)}) }); 431 } 432 for (data.repos) |repo| 433 // clicking a repo opens its page; the "a:" prefix makes the web 434 // renderer emit an <a href="/alice:foo"> anchor. 435 try items.append(aa, .{ 436 .text = if (repo.event.description.len == 0) repo.event.name else try aa.print("{s} - {s}", .{ repo.event.name, repo.event.description }), 437 .link = try aa.print("a:/{s}:{s}", .{ data.user.name, repo.event.name }), 438 .bottom_label = if (repo.event.read_access == .private) "(private)" else "", 439 }); 440 if (data.repos_next_start) |next_start| { 441 var next = route; 442 next.user_repos.start = next_start; 443 try items.append(aa, .{ .text = "next →", .link = try aa.print("a:{s}", .{try next.toUrl(&arena)}) }); 444 } 445 try list.setItems(allocator, items.items); 446 447 try box.children.put(allocator, list.getFocus().id, .{ .widget = .{ .flow_box_scroll = list }, .rect = null, .min_size = null }); 448 } 449 450 // search results start in the box so the term can be refined right away 451 box.getFocus().child_id = box.children.keys()[if (data.repos_search != null) header_index else list_index]; 452 return .{ .box = box, .data = data, .session = session }; 453 } 454 455 pub fn deinit(self: *ReposView, allocator: std.mem.Allocator) void { 456 self.box.deinit(allocator); 457 } 458 459 pub fn build(self: *ReposView, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 460 self.clearGrid(); 461 // clear the incoming min height so the list scrolls within what's left 462 try self.box.build(allocator, .{ 463 .min_size = .{ .width = null, .height = null }, 464 .max_size = constraint.max_size, 465 }, root_focus); 466 } 467 468 pub fn input(self: *ReposView, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 469 const direction = inp.vertDirection(key); 470 if (self.headerActive()) { 471 const search_box = self.searchBox(); 472 if (direction == .down) { 473 // the empty message has nothing to focus 474 if (!self.data.reposEmpty()) root_focus.setFocus(self.listScroll().getFocus().id); 475 return; 476 } 477 if (key == .enter) { 478 const text = try search_box.text(allocator); 479 defer allocator.free(text); 480 return self.submit(text); 481 } 482 return search_box.input(allocator, key, root_focus); 483 } 484 // up from the first row reaches the search box 485 if (direction == .up and self.listScroll().atTop()) return self.focusHeader(root_focus); 486 try self.listScroll().input(allocator, key, root_focus); 487 } 488 489 // the repos narrowed to `text` from the first window. an empty box on an 490 // unsearched page has nothing to clear. 491 fn submit(self: *ReposView, text: []const u8) !void { 492 if (text.len == 0 and self.data.repos_search == null) return; 493 const route = reposRoute(self.data.user.name) orelse return; 494 try self.session.navigate(route.withSearch(text) orelse return); 495 } 496 497 fn searchBox(self: *ReposView) *ui.widget.SearchBox { 498 return &self.box.children.values()[header_index].widget.header_scroll.row().children.values()[0].widget.search_box; 499 } 500 501 fn listScroll(self: *ReposView) *ui.widget.FlowBox.Scroll { 502 return &self.box.children.values()[list_index].widget.flow_box_scroll; 503 } 504 505 fn headerActive(self: *ReposView) bool { 506 return self.box.getFocus().child_id == self.box.children.keys()[header_index]; 507 } 508 509 pub fn clearGrid(self: *ReposView) void { 510 self.box.clearGrid(); 511 } 512 513 pub fn getGrid(self: ReposView) ?Grid { 514 return self.box.getGrid(); 515 } 516 517 pub fn getFocus(self: *ReposView) *Focus { 518 return self.box.getFocus(); 519 } 520 521 // up leaves the tab from the search box 522 pub fn atTop(self: *ReposView) bool { 523 return self.headerActive(); 524 } 525 526 pub fn focusHeader(self: *ReposView, root_focus: *Focus) void { 527 root_focus.setFocus(self.searchBox().getFocus().id); 528 } 529 }; 530 531 // the first window of `name`'s repos 532 fn reposRoute(name: []const u8) ?ui.RoutablePage { 533 return .{ .user_repos = .{ .name = ui.RoutablePage.Array(evt.User.name_max_len).from(name) orelse return null } }; 534 } 535