╭─────────────╮ │scroll to top│ ╰─────────────╯ ╭ Issues.zig ───────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
658 }; 659 660 // select the description by default 661 inner.getFocus().child_id = self.description_id[index]; 662 663 // reset the scroll to the top for the newly-shown issue: directly on the 664 // terminal (the wasm offset), and via a version bump on the web (so the 665 // renderer's scroll id changes and JS drops the preserved position). 666 const sc = self.detailScroll(index); 667 sc.x = 0; 668 sc.y = 0; 669 sc.getFocus().version +%= 1; 670 } 671 672 pub fn input(self: *View, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 673 if (self.headerActive()) { 674 // down from the tabs re-enters the stack if the selected view has 675 // something to focus; other keys move the tabs. 676 if (inp.vertDirection(key) == .down) { 677 const enterable = if (self.viewStack().getSelected()) |selected| switch (selected.*) { 678 .tag_flow => |*tf| tf.text_boxes.items.len > 0, 679 else => true, 680 } else false; 681 if (enterable) root_focus.setFocus(self.box.children.keys()[stack_index]); 682 } else { 683 try self.header().input(allocator, key, root_focus); 684 } 685 return; 686 } 687 if (self.tagsViewActive()) { 688 self.tagsViewInput(key, root_focus); 689 return; 690 } 691 const i = self.selectedSplitIndex() orelse return; 692 if (self.detailActive(i)) { 693 try self.detailInput(i, key, root_focus); 694 } else { 695 try self.listInput(i, key, root_focus); 696 } 697 } 698 699 // arrow keys move the tag selection; up from the top row crosses to the 700 // header tabs. 701 fn tagsViewInput(self: *View, key: Key, root_focus: *Focus) void { 702 const tf = self.tagsView(); 703 const cid = tf.focus.child_id orelse return; 704 const cur = tf.indexOfFocusId(cid) orelse return; 705 const count = tf.text_boxes.items.len; 706 switch (key) { 707 .arrow_left => if (cur > 0) root_focus.setFocus(tf.text_boxes.items[cur - 1].getFocus().id), 708 .arrow_right => if (cur + 1 < count) root_focus.setFocus(tf.text_boxes.items[cur + 1].getFocus().id), 709 .arrow_up => if (tf.rowStep(cur, false)) |i| root_focus.setFocus(tf.text_boxes.items[i].getFocus().id) else self.focusHeader(root_focus), 710 .arrow_down => if (tf.rowStep(cur, true)) |i| root_focus.setFocus(tf.text_boxes.items[i].getFocus().id), 711 .home => root_focus.setFocus(tf.text_boxes.items[0].getFocus().id), 712 .end => root_focus.setFocus(tf.text_boxes.items[count - 1].getFocus().id), 713 else => {}, 714 } 715 } 716 717 fn listInput(self: *View, index: usize, key: Key, root_focus: *Focus) !void { 718 // up/down (and the scroll wheel) move the selection a row; page up/down 719 // jump a fixed amount. right/Enter cross into the detail pane. up from 720 // the top row crosses into the header tabs. 721 if (inp.rowDelta(key, @intCast(self.listBox(index).children.count()))) |delta| { 722 const lb = self.listBox(index); 723 const at_top = if (lb.getFocus().child_id) |cid| lb.children.getIndex(cid) == 0 else true; 724 if (delta < 0 and at_top) return self.focusHeader(root_focus); 725 ui.moveRowFocus(lb, self.listScroll(index), root_focus, delta); 726 return; 727 } 728 switch (key) { 729 .enter, .arrow_right => try self.focusDetail(index, root_focus), 730 else => {}, 731 } 732 } 733 734 fn detailInput(self: *View, index: usize, key: Key, root_focus: *Focus) !void { 735 if (self.tagsFocused(index)) { 736 try self.tagsInput(index, key, root_focus); 737 } else { 738 try self.descriptionInput(index, key, root_focus); 739 } 740 } 741 742 fn descriptionInput(self: *View, index: usize, key: Key, root_focus: *Focus) !void { 743 const sc = self.detailScroll(index); 744 switch (key) { 745 .arrow_left => return self.focusList(index, root_focus), 746 // once the scroll can't move further, cross into the tags (or the 747 // header tabs when the issue has none). 748 .arrow_up => { 749 const before = sc.y; 750 sc.y -= 1; 751 sc.clampToContent(); 752 if (sc.y == before) { 753 if (self.tagFlow(index) != null) try self.focusTags(index, root_focus) else self.focusHeader(root_focus); 754 } 755 return; 756 }, 757 .arrow_down => sc.y += 1, 758 .page_up => sc.y -= 10, 759 .page_down => sc.y += 10, 760 .home => sc.y = 0, 761 .end => sc.y = std.math.maxInt(isize), 762 .mouse => |mouse| switch (mouse.action) { 763 .scroll => |dir| sc.y += if (dir == .up) @as(isize, -1) else 1, 764 else => {}, 765 }, 766 else => return, 767 } 768 sc.clampToContent(); 769 } 770 771 // arrow keys move the tag selection; at the flow's edges focus crosses to 772 // the neighboring widgets. 773 fn tagsInput(self: *View, index: usize, key: Key, root_focus: *Focus) !void { 774 const tf = self.tagFlow(index) orelse return; 775 const cid = tf.focus.child_id orelse return; 776 const cur = tf.indexOfFocusId(cid) orelse return; 777 const count = tf.text_boxes.items.len; 778 const sc = self.detailScroll(index); 779 switch (key) { 780 .arrow_left => if (cur > 0) self.focusTag(index, tf, root_focus, cur - 1) else try self.focusList(index, root_focus), 781 .arrow_right => if (cur + 1 < count) self.focusTag(index, tf, root_focus, cur + 1), 782 .arrow_up => if (tf.rowStep(cur, false)) |i| self.focusTag(index, tf, root_focus, i) else self.focusHeader(root_focus), 783 .arrow_down => if (tf.rowStep(cur, true)) |i| self.focusTag(index, tf, root_focus, i) else try self.focusDescription(index, root_focus), 784 .home => self.focusTag(index, tf, root_focus, 0), 785 .end => self.focusTag(index, tf, root_focus, count - 1), 786 .mouse => |mouse| switch (mouse.action) { 787 .scroll => |dir| { 788 sc.y += if (dir == .up) @as(isize, -1) else 1; 789 sc.clampToContent(); 790 }, 791 else => {}, 792 }, 793 else => {}, 794 } 795 } 796 797 const tags_child_index: usize = 0; 798 799 fn tagFlow(self: *View, index: usize) ?*ui.TagFlow { 800 const inner = self.detailInner(index); 801 if (inner.children.count() == 0) return null; 802 return switch (inner.children.values()[tags_child_index].widget) { 803 .tag_flow => |*tf| tf, 804 else => null, 805 }; 806 } 807 808 fn tagsFocused(self: *View, index: usize) bool { 809 const inner = self.detailInner(index); 810 const cid = inner.getFocus().child_id orelse return false; 811 return inner.children.getIndex(cid) == tags_child_index and self.tagFlow(index) != null; 812 } 813 814 fn focusTag(self: *View, index: usize, tf: *ui.TagFlow, root_focus: *Focus, item: usize) void { 815 root_focus.setFocus(tf.text_boxes.items[item].getFocus().id); 816 // keep the tag visible on the terminal: its rect offset by the flow's 817 // position in the pane. 818 if (self.session.is_terminal and item < tf.rects.items.len) { 819 if (self.detailInner(index).children.values()[tags_child_index].rect) |flow_rect| { 820 var rect = tf.rects.items[item]; 821 rect.x += flow_rect.x; 822 rect.y += flow_rect.y; 823 self.detailScroll(index).scrollToRect(rect); 824 } 825 } 826 } 827 828 fn focusTags(self: *View, index: usize, root_focus: *Focus) !void { 829 const tf = self.tagFlow(index) orelse return; 830 if (tf.text_boxes.items.len == 0) return; 831 const cid = tf.focus.child_id orelse tf.text_boxes.items[0].getFocus().id; 832 const item = tf.indexOfFocusId(cid) orelse 0; 833 self.focusTag(index, tf, root_focus, item); 834 } 835 836 fn focusDescription(self: *View, index: usize, root_focus: *Focus) !void { 837 if (self.description_id[index]) |id| root_focus.setFocus(id); 838 } 839 840 // enter the detail pane. an empty pane (no issues) can't be entered. 841 fn focusDetail(self: *View, index: usize, root_focus: *Focus) !void { 842 if (self.detailInner(index).children.count() == 0) return; 843 root_focus.setFocus(self.detailOuter(index).getFocus().id); 844 } 845 846 // return to the list. 847 fn focusList(self: *View, index: usize, root_focus: *Focus) !void { 848 root_focus.setFocus(self.listScroll(index).getFocus().id); 849 } 850 851 // cross to the header tabs above the stack. 852 fn focusHeader(self: *View, root_focus: *Focus) void { 853 root_focus.setFocus(self.box.children.keys()[header_index]); 854 } 855 856 pub fn clearGrid(self: *View) void { 857 self.box.clearGrid(); 858 } 859 860 pub fn getGrid(self: View) ?Grid { 861 return self.box.getGrid(); 862 } 863 864 pub fn getFocus(self: *View) *Focus { 865 return self.box.getFocus(); 866 } 867 868 // for the parent's "scroll up at the top jumps to the header" check: at the 869 // top only while the header tabs hold focus, so up from the split 870 // crosses into the tabs first. 871 pub fn getSelectedIndex(self: *View) ?usize { 872 return if (self.headerActive()) 0 else 1; 873 } 874 }; 875 876 // the "a:" navigation link for the issues page filtered to the url-encoded 877 // `tag` and rooted at issue `id` within `identity` ("owner/name"). a rooted 878 // url derives its view from the issue's status; the bare list is the open view. 879 fn issuesLink(page_arena: *std.heap.ArenaAllocator, identity: []const u8, tag: []const u8, id: []const u8) ![]const u8 { 880 const route = ui.RoutablePage.repoIssuesRoute(identity, .open, tag, id) orelse return error.RouteTooLong; 881 const url = try route.urlAlloc(page_arena); 882 return std.fmt.allocPrint(page_arena.allocator(), "a:{s}", .{url}); 883 } 884 885 // the in-page "ai:" anchor for selecting issue `id` in `identity`'s list; the 886 // href is only followed with js off. 887 fn issueRowLink(page_arena: *std.heap.ArenaAllocator, identity: []const u8, id: []const u8) ![]const u8 { 888 const route = ui.RoutablePage.repoIssuesRoute(identity, .open, "", id) orelse return error.RouteTooLong; 889 const url = try route.urlAlloc(page_arena); 890 return std.fmt.allocPrint(page_arena.allocator(), "ai:{s}", .{url}); 891 } 892 893 // the "a:" link to `status`'s issues list filtered to `tag` (raw; encoded here). 894 fn tagLink(page_arena: *std.heap.ArenaAllocator, identity: []const u8, status: evt.Issue.Status, tag: []const u8) ![]const u8 { 895 const encoded = try ui.urlEncodeRef(page_arena.allocator(), tag); 896 const route = ui.RoutablePage.repoIssuesRoute(identity, status, encoded, "") orelse return error.RouteTooLong; 897 const url = try route.urlAlloc(page_arena); 898 return std.fmt.allocPrint(page_arena.allocator(), "a:{s}", .{url}); 899 } 900 901 // tabs switching between the issues page's views. 902 pub const Header = struct { 903 box: wgt.Box(ui.Widget), 904 tab_ids: std.AutoArrayHashMapUnmanaged(usize, void), 905 906 pub fn init(allocator: std.mem.Allocator, session: *ui.Session, data: *const Self) !Header { 907 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = .hidden, .rounded_corners = true, .direction = .horiz }); 908 errdefer box.deinit(allocator); 909 910 var tab_ids: std.AutoArrayHashMapUnmanaged(usize, void) = .empty; 911 errdefer tab_ids.deinit(allocator); 912 913 const aa = session.page_arena.allocator(); 914 915 // a list tab per status, labeled with its listing's issue count 916 for ([_]evt.Issue.Status{ .open, .closed }) |status| { 917 const route = ui.RoutablePage.repoIssuesRoute(data.identity, status, data.tag, "") orelse return error.RouteTooLong; 918 const link = try std.fmt.allocPrint(aa, "ai:{s}", .{try route.urlAlloc(session.page_arena)}); 919 const label = try std.fmt.allocPrint(aa, "{s} ({d})", .{ @tagName(status), data.window(status).count }); 920 var text_box = try wgt.TextBox(ui.Widget).init(allocator, label, .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .none }); 921 errdefer text_box.deinit(allocator); 922 text_box.getFocus().focusable = true; 923 text_box.getFocus().kind = .{ .custom = link }; 924 try tab_ids.put(allocator, text_box.getFocus().id, {}); 925 try box.children.put(allocator, text_box.getFocus().id, .{ 926 .widget = .{ .text_box = text_box }, 927 .rect = null, 928 .min_size = .{ .width = label.len + 2, .height = null }, 929 }); 930 } 931 932 // tags tab, labeled with the active tag filter 933 { 934 const tags_route = ui.RoutablePage.repoIssuesTagsRoute(data.identity, data.tag) orelse return error.RouteTooLong; 935 const tags_link = try std.fmt.allocPrint(aa, "ai:{s}", .{try tags_route.urlAlloc(session.page_arena)}); 936 const label = if (data.tag.len == 0) "tags" else blk: { 937 const decoded = std.Uri.percentDecodeInPlace(try aa.dupe(u8, data.tag)); 938 break :blk try std.fmt.allocPrint(aa, "tags ({s})", .{decoded}); 939 }; 940 var text_box = try wgt.TextBox(ui.Widget).init(allocator, label, .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .none }); 941 errdefer text_box.deinit(allocator); 942 text_box.getFocus().focusable = true; 943 text_box.getFocus().kind = .{ .custom = tags_link }; 944 try tab_ids.put(allocator, text_box.getFocus().id, {}); 945 try box.children.put(allocator, text_box.getFocus().id, .{ 946 .widget = .{ .text_box = text_box }, 947 .rect = null, 948 .min_size = .{ .width = label.len + 2, .height = null }, 949 }); 950 } 951 952 var self = Header{ .box = box, .tab_ids = tab_ids }; 953 // the tab matching the page's view is selected initially. 954 self.getFocus().child_id = self.tab_ids.keys()[View.viewIndex(data.view)]; 955 return self; 956 } 957 958 pub fn deinit(self: *Header, allocator: std.mem.Allocator) void { 959 self.box.deinit(allocator); 960 self.tab_ids.deinit(allocator); 961 } 962 963 pub fn build(self: *Header, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 964 self.clearGrid(); 965 // only the selected tab shows its border 966 for (self.box.children.keys(), self.box.children.values()) |id, *child| { 967 switch (child.widget) { 968 .text_box => |*tb| tb.options.border_style = if (self.getFocus().child_id == id) .single else .hidden, 969 else => {}, 970 } 971 } 972 try self.box.build(allocator, constraint, root_focus); 973 } 974 975 pub fn input(self: *Header, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 976 _ = allocator; 977 const current_tab = self.currentTabIndex() orelse return; 978 if (inp.moveTab(key, current_tab, self.tab_ids.count())) |new_tab| { 979 root_focus.setFocus(self.tab_ids.keys()[new_tab]); 980 } 981 } 982 983 pub fn clearGrid(self: *Header) void { 984 self.box.clearGrid(); 985 } 986 987 pub fn getGrid(self: Header) ?Grid { 988 return self.box.getGrid(); 989 } 990 991 pub fn getFocus(self: *Header) *Focus { 992 return self.box.getFocus(); 993 } 994 995 pub fn getSelectedIndex(self: Header) ?usize { 996 return self.currentTabIndex(); 997 } 998 999 fn currentTabIndex(self: Header) ?usize { 1000 const child_id = self.box.focus.child_id orelse return null; 1001 return self.tab_ids.getIndex(child_id); 1002 } 1003 }; 1004