╭─────────────╮ │scroll to top│ ╰─────────────╯ ╭ Issues.zig ───────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
383 fn populateDetail(self: *View, allocator: std.mem.Allocator, sel: usize) !void { 384 const entry = self.data.issues[sel]; 385 const inner = self.detailInner(); 386 387 for (inner.children.values()) |*child| child.widget.deinit(allocator); 388 inner.children.clearAndFree(allocator); 389 inner.getFocus().child_id = null; 390 391 // the issue's tags, each linking to the list filtered to that tag. 392 { 393 var items: std.ArrayList(ui.TagFlow.Item) = .empty; 394 defer items.deinit(allocator); 395 var tag_iter = evt.Issue.tagIterator(entry.issue.tags); 396 while (tag_iter.next()) |tag| { 397 if (tag.len == 0) continue; 398 try items.append(allocator, .{ .text = tag, .link = try tagLink(self.session.page_arena, self.data.identity, tag) }); 399 } 400 if (items.items.len > 0) { 401 var tf = try ui.TagFlow.init(allocator); 402 errdefer tf.deinit(allocator); 403 try tf.setItems(allocator, items.items); 404 try inner.children.put(allocator, tf.getFocus().id, .{ .widget = .{ .tag_flow = tf }, .rect = null, .min_size = null }); 405 } 406 } 407 408 // the description as a focusable word-wrapped text box. its hidden 409 // border reserves the space the border occupies when focused, so 410 // focusing doesn't shift layout. 411 { 412 var tb = try wgt.TextBox(ui.Widget).init(allocator, entry.issue.description, .{ .border_style = .hidden, .rounded_corners = true, .wrap_kind = .word }); 413 errdefer tb.deinit(allocator); 414 tb.getFocus().focusable = true; 415 try inner.children.put(allocator, tb.getFocus().id, .{ .widget = .{ .text_box = tb }, .rect = null, .min_size = null }); 416 } 417 418 // point the pane at its first row so focus recovery can land here. 419 inner.getFocus().child_id = inner.children.keys()[0]; 420 421 // reset the scroll to the top for the newly-shown issue: directly on the 422 // terminal (the wasm offset), and via a version bump on the web (so the 423 // renderer's scroll id changes and JS drops the preserved position). 424 const sc = self.detailScroll(); 425 sc.x = 0; 426 sc.y = 0; 427 sc.getFocus().version +%= 1; 428 } 429 430 pub fn input(self: *View, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 431 _ = allocator; 432 if (self.detailActive()) { 433 try self.detailInput(key, root_focus); 434 } else { 435 try self.listInput(key, root_focus); 436 } 437 } 438 439 fn listInput(self: *View, key: Key, root_focus: *Focus) !void { 440 // up/down (and the scroll wheel) move the selection a row; page up/down 441 // jump a fixed amount. right/Enter cross into the detail pane. 442 if (inp.rowDelta(key, @intCast(self.listBox().children.count()))) |delta| { 443 ui.moveRowFocus(self.listBox(), self.listScroll(), root_focus, delta); 444 return; 445 } 446 switch (key) { 447 .enter, .arrow_right => try self.focusDetail(root_focus), 448 else => {}, 449 } 450 } 451 452 fn detailInput(self: *View, key: Key, root_focus: *Focus) !void { 453 if (self.tagsFocused()) { 454 try self.tagsInput(key, root_focus); 455 } else { 456 try self.descriptionInput(key, root_focus); 457 } 458 } 459 460 fn descriptionInput(self: *View, key: Key, root_focus: *Focus) !void { 461 const sc = self.detailScroll(); 462 switch (key) { 463 .arrow_left => return self.focusList(root_focus), 464 // once the scroll can't move further, cross into the tags. 465 .arrow_up => { 466 const before = sc.y; 467 sc.y -= 1; 468 sc.clampToContent(); 469 if (sc.y == before) try self.focusTags(root_focus); 470 return; 471 }, 472 .arrow_down => sc.y += 1, 473 .page_up => sc.y -= 10, 474 .page_down => sc.y += 10, 475 .home => sc.y = 0, 476 .end => sc.y = std.math.maxInt(isize), 477 .mouse => |mouse| switch (mouse.action) { 478 .scroll => |dir| sc.y += if (dir == .up) @as(isize, -1) else 1, 479 else => {}, 480 }, 481 else => return, 482 } 483 sc.clampToContent(); 484 } 485 486 // arrow keys move the tag selection; at the flow's edges focus crosses to 487 // the neighboring widgets. 488 fn tagsInput(self: *View, key: Key, root_focus: *Focus) !void { 489 const tf = self.tagFlow() orelse return; 490 const cid = tf.focus.child_id orelse return; 491 const cur = tf.indexOfFocusId(cid) orelse return; 492 const count = tf.text_boxes.items.len; 493 const sc = self.detailScroll(); 494 switch (key) { 495 .arrow_left => if (cur > 0) self.focusTag(tf, root_focus, cur - 1) else try self.focusList(root_focus), 496 .arrow_right => if (cur + 1 < count) self.focusTag(tf, root_focus, cur + 1), 497 .arrow_up => if (tf.rowStep(cur, false)) |i| self.focusTag(tf, root_focus, i), 498 .arrow_down => if (tf.rowStep(cur, true)) |i| self.focusTag(tf, root_focus, i) else try self.focusDescription(root_focus), 499 .home => self.focusTag(tf, root_focus, 0), 500 .end => self.focusTag(tf, root_focus, count - 1), 501 .mouse => |mouse| switch (mouse.action) { 502 .scroll => |dir| { 503 sc.y += if (dir == .up) @as(isize, -1) else 1; 504 sc.clampToContent(); 505 }, 506 else => {}, 507 }, 508 else => {}, 509 } 510 } 511 512 const tags_child_index: usize = 0; 513 514 fn tagFlow(self: *View) ?*ui.TagFlow { 515 const inner = self.detailInner(); 516 if (inner.children.count() == 0) return null; 517 return switch (inner.children.values()[tags_child_index].widget) { 518 .tag_flow => |*tf| tf, 519 else => null, 520 }; 521 } 522 523 fn tagsFocused(self: *View) bool { 524 const inner = self.detailInner(); 525 const cid = inner.getFocus().child_id orelse return false; 526 return inner.children.getIndex(cid) == tags_child_index and self.tagFlow() != null; 527 } 528 529 fn focusTag(self: *View, tf: *ui.TagFlow, root_focus: *Focus, index: usize) void { 530 root_focus.setFocus(tf.text_boxes.items[index].getFocus().id); 531 // keep the tag visible on the terminal: its rect offset by the flow's 532 // position in the pane. 533 if (self.session.is_terminal and index < tf.rects.items.len) { 534 if (self.detailInner().children.values()[tags_child_index].rect) |flow_rect| { 535 var rect = tf.rects.items[index]; 536 rect.x += flow_rect.x; 537 rect.y += flow_rect.y; 538 self.detailScroll().scrollToRect(rect); 539 } 540 } 541 } 542 543 fn focusTags(self: *View, root_focus: *Focus) !void { 544 const tf = self.tagFlow() orelse return; 545 if (tf.text_boxes.items.len == 0) return; 546 const cid = tf.focus.child_id orelse tf.text_boxes.items[0].getFocus().id; 547 const index = tf.indexOfFocusId(cid) orelse 0; 548 self.focusTag(tf, root_focus, index); 549 } 550 551 fn focusDescription(self: *View, root_focus: *Focus) !void { 552 const inner = self.detailInner(); 553 const index: usize = if (self.tagFlow() != null) 1 else 0; 554 if (inner.children.count() <= index) return; 555 root_focus.setFocus(inner.children.keys()[index]); 556 } 557 558 // enter the detail pane. an empty pane (no issues) can't be entered. 559 fn focusDetail(self: *View, root_focus: *Focus) !void { 560 if (self.detailInner().children.count() == 0) return; 561 root_focus.setFocus(self.detailOuter().getFocus().id); 562 } 563 564 // return to the list. 565 fn focusList(self: *View, root_focus: *Focus) !void { 566 root_focus.setFocus(self.listScroll().getFocus().id); 567 } 568 569 pub fn clearGrid(self: *View) void { 570 self.box.clearGrid(); 571 } 572 573 pub fn getGrid(self: View) ?Grid { 574 return self.box.getGrid(); 575 } 576 577 pub fn getFocus(self: *View) *Focus { 578 return self.box.getFocus(); 579 } 580 581 // for the parent's "scroll up at the top jumps to the header" check. when 582 // the list holds focus, report its selected row directly. 583 pub fn getSelectedIndex(self: *View) ?usize { 584 if (self.detailActive()) { 585 return if (self.detailScroll().y == 0 and self.paneTopFocused()) 0 else 1; 586 } 587 const lb = self.listBox(); 588 const cid = lb.getFocus().child_id orelse return null; 589 return lb.children.getIndex(cid); 590 } 591 592 // whether the focused element is the detail pane's topmost one, so up has 593 // nowhere further to go within the pane. 594 fn paneTopFocused(self: *View) bool { 595 const tf = self.tagFlow() orelse return true; 596 if (!self.tagsFocused()) return false; 597 const cid = tf.focus.child_id orelse return true; 598 const cur = tf.indexOfFocusId(cid) orelse return true; 599 return tf.rowStep(cur, false) == null; 600 } 601 }; 602 603 // the "a:" navigation link for the issues page filtered to the url-encoded 604 // `tag` and rooted at issue `id` within `identity` ("owner/name"). 605 fn issuesLink(page_arena: *std.heap.ArenaAllocator, identity: []const u8, tag: []const u8, id: []const u8) ![]const u8 { 606 const route = ui.RoutablePage.repoIssuesRoute(identity, tag, id) orelse return error.RouteTooLong; 607 const url = try route.urlAlloc(page_arena); 608 return std.fmt.allocPrint(page_arena.allocator(), "a:{s}", .{url}); 609 } 610 611 // the in-page "ai:" anchor for selecting issue `id` in `identity`'s list; the 612 // href is only followed with js off. 613 fn issueRowLink(page_arena: *std.heap.ArenaAllocator, identity: []const u8, id: []const u8) ![]const u8 { 614 const route = ui.RoutablePage.repoIssuesRoute(identity, "", id) orelse return error.RouteTooLong; 615 const url = try route.urlAlloc(page_arena); 616 return std.fmt.allocPrint(page_arena.allocator(), "ai:{s}", .{url}); 617 } 618 619 // the "a:" link to the issues list filtered to `tag` (raw; encoded here). 620 fn tagLink(page_arena: *std.heap.ArenaAllocator, identity: []const u8, tag: []const u8) ![]const u8 { 621 const encoded = try ui.urlEncodeRef(page_arena.allocator(), tag); 622 const route = ui.RoutablePage.repoIssuesRoute(identity, encoded, "") orelse return error.RouteTooLong; 623 const url = try route.urlAlloc(page_arena); 624 return std.fmt.allocPrint(page_arena.allocator(), "a:{s}", .{url}); 625 } 626