╭─────────────╮ │scroll to top│ ╰─────────────╯ ╭ Issues.zig ───────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
1005 fn tagFlow(self: *View, index: usize) ?*ui.TagFlow { 1006 const inner = self.detailInner(index); 1007 if (inner.children.count() == 0) return null; 1008 return switch (inner.children.values()[tags_child_index].widget) { 1009 .tag_flow => |*tf| tf, 1010 else => null, 1011 }; 1012 } 1013 1014 fn tagsFocused(self: *View, index: usize) bool { 1015 const inner = self.detailInner(index); 1016 const cid = inner.getFocus().child_id orelse return false; 1017 return inner.children.getIndex(cid) == tags_child_index and self.tagFlow(index) != null; 1018 } 1019 1020 fn focusTag(self: *View, index: usize, tf: *ui.TagFlow, root_focus: *Focus, item: usize) void { 1021 root_focus.setFocus(tf.text_boxes.items[item].getFocus().id); 1022 // keep the tag visible on the terminal: its rect offset by the flow's 1023 // position in the pane. 1024 if (self.session.is_terminal and item < tf.rects.items.len) { 1025 if (self.detailInner(index).children.values()[tags_child_index].rect) |flow_rect| { 1026 var rect = tf.rects.items[item]; 1027 rect.x += flow_rect.x; 1028 rect.y += flow_rect.y; 1029 self.detailScroll(index).scrollToRect(rect); 1030 } 1031 } 1032 } 1033 1034 fn focusTags(self: *View, index: usize, root_focus: *Focus) !void { 1035 const tf = self.tagFlow(index) orelse return; 1036 if (tf.text_boxes.items.len == 0) return; 1037 const cid = tf.focus.child_id orelse tf.text_boxes.items[0].getFocus().id; 1038 const item = tf.indexOfFocusId(cid) orelse 0; 1039 self.focusTag(index, tf, root_focus, item); 1040 } 1041 1042 fn focusDescription(self: *View, index: usize, root_focus: *Focus) !void { 1043 if (self.description_id[index]) |id| root_focus.setFocus(id); 1044 } 1045 1046 // enter the detail pane. an empty pane (no issues) can't be entered. 1047 fn focusDetail(self: *View, index: usize, root_focus: *Focus) !void { 1048 if (self.detailInner(index).children.count() == 0) return; 1049 root_focus.setFocus(self.detailOuter(index).getFocus().id); 1050 } 1051 1052 // return to the list. 1053 fn focusList(self: *View, index: usize, root_focus: *Focus) !void { 1054 root_focus.setFocus(self.listScroll(index).getFocus().id); 1055 } 1056 1057 // cross to the header tabs above the stack. 1058 fn focusHeader(self: *View, root_focus: *Focus) void { 1059 root_focus.setFocus(self.box.children.keys()[header_index]); 1060 } 1061 1062 pub fn clearGrid(self: *View) void { 1063 self.box.clearGrid(); 1064 } 1065 1066 pub fn getGrid(self: View) ?Grid { 1067 return self.box.getGrid(); 1068 } 1069 1070 pub fn getFocus(self: *View) *Focus { 1071 return self.box.getFocus(); 1072 } 1073 1074 // for the parent's "scroll up at the top jumps to the header" check: at the 1075 // top only while the header tabs hold focus, so up from the split 1076 // crosses into the tabs first. 1077 pub fn getSelectedIndex(self: *View) ?usize { 1078 return if (self.headerActive()) 0 else 1; 1079 } 1080 }; 1081 1082 // the "a:" navigation link for `status`'s issues page filtered to the 1083 // url-encoded `tag` and rooted at issue `id` within `identity` ("owner/name"). 1084 // a rooted url ignores `status` (it derives its view from the issue's own); 1085 // with an empty `id` this is the bare list link. 1086 fn issuesLink(page_arena: *std.heap.ArenaAllocator, identity: []const u8, status: evt.Issue.Status, tag: []const u8, id: []const u8) ![]const u8 { 1087 const route = ui.RoutablePage.repoIssuesRoute(identity, status, tag, id) orelse return error.RouteTooLong; 1088 const url = try route.toUrl(page_arena); 1089 return std.fmt.allocPrint(page_arena.allocator(), "a:{s}", .{url}); 1090 } 1091 1092 // the in-page "ai:" anchor for selecting issue `id` in `identity`'s list; the 1093 // href is only followed with js off. 1094 fn issueRowLink(page_arena: *std.heap.ArenaAllocator, identity: []const u8, id: []const u8) ![]const u8 { 1095 const route = ui.RoutablePage.repoIssuesRoute(identity, .open, "", id) orelse return error.RouteTooLong; 1096 const url = try route.toUrl(page_arena); 1097 return std.fmt.allocPrint(page_arena.allocator(), "ai:{s}", .{url}); 1098 } 1099 1100 // the "a:" link to `status`'s issues list filtered to `tag` (raw; encoded here). 1101 fn tagLink(page_arena: *std.heap.ArenaAllocator, identity: []const u8, status: evt.Issue.Status, tag: []const u8) ![]const u8 { 1102 const encoded = try ui.urlEncodeRef(page_arena.allocator(), tag); 1103 const route = ui.RoutablePage.repoIssuesRoute(identity, status, encoded, "") orelse return error.RouteTooLong; 1104 const url = try route.toUrl(page_arena); 1105 return std.fmt.allocPrint(page_arena.allocator(), "a:{s}", .{url}); 1106 } 1107 1108 // tabs switching between the issues page's views. 1109 pub const Header = struct { 1110 box: wgt.Box(ui.Widget), 1111 tab_ids: std.AutoArrayHashMapUnmanaged(usize, void), 1112 1113 pub fn init(allocator: std.mem.Allocator, session: *ui.Session, data: *const Self) !Header { 1114 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .horiz }); 1115 errdefer box.deinit(allocator); 1116 1117 var tab_ids: std.AutoArrayHashMapUnmanaged(usize, void) = .empty; 1118 errdefer tab_ids.deinit(allocator); 1119 1120 const aa = session.page_arena.allocator(); 1121 1122 // a list tab per status, labeled with its listing's issue count 1123 for ([_]evt.Issue.Status{ .open, .closed }) |status| { 1124 const route = ui.RoutablePage.repoIssuesRoute(data.identity, status, data.tag, "") orelse return error.RouteTooLong; 1125 const link = try std.fmt.allocPrint(aa, "ai:{s}", .{try route.toUrl(session.page_arena)}); 1126 const label = try std.fmt.allocPrint(aa, "{s} ({d})", .{ @tagName(status), data.window(status).count }); 1127 var text_box = try wgt.TextBox(ui.Widget).init(allocator, label, .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .none }); 1128 errdefer text_box.deinit(allocator); 1129 text_box.getFocus().focusable = true; 1130 text_box.getFocus().kind = .{ .custom = link }; 1131 try tab_ids.put(allocator, text_box.getFocus().id, {}); 1132 try box.children.put(allocator, text_box.getFocus().id, .{ 1133 .widget = .{ .text_box = text_box }, 1134 .rect = null, 1135 .min_size = .{ .width = label.len + 2, .height = null }, 1136 }); 1137 } 1138 1139 // tags tab, labeled with the active tag filter 1140 { 1141 const tags_route = ui.RoutablePage.repoIssuesTagsRoute(data.identity, data.tag) orelse return error.RouteTooLong; 1142 const tags_link = try std.fmt.allocPrint(aa, "ai:{s}", .{try tags_route.toUrl(session.page_arena)}); 1143 const label = if (data.tag.len == 0) "tags" else blk: { 1144 const decoded = std.Uri.percentDecodeInPlace(try aa.dupe(u8, data.tag)); 1145 break :blk try std.fmt.allocPrint(aa, "tags ({s})", .{decoded}); 1146 }; 1147 var text_box = try wgt.TextBox(ui.Widget).init(allocator, label, .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .none }); 1148 errdefer text_box.deinit(allocator); 1149 text_box.getFocus().focusable = true; 1150 text_box.getFocus().kind = .{ .custom = tags_link }; 1151 try tab_ids.put(allocator, text_box.getFocus().id, {}); 1152 try box.children.put(allocator, text_box.getFocus().id, .{ 1153 .widget = .{ .text_box = text_box }, 1154 .rect = null, 1155 .min_size = .{ .width = label.len + 2, .height = null }, 1156 }); 1157 } 1158 1159 // new-issue tab 1160 { 1161 const new_route = ui.RoutablePage.repoIssuesNewRoute(data.identity) orelse return error.RouteTooLong; 1162 const new_link = try std.fmt.allocPrint(aa, "ai:{s}", .{try new_route.toUrl(session.page_arena)}); 1163 var text_box = try wgt.TextBox(ui.Widget).init(allocator, "new", .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .none }); 1164 errdefer text_box.deinit(allocator); 1165 text_box.getFocus().focusable = true; 1166 text_box.getFocus().kind = .{ .custom = new_link }; 1167 try tab_ids.put(allocator, text_box.getFocus().id, {}); 1168 try box.children.put(allocator, text_box.getFocus().id, .{ 1169 .widget = .{ .text_box = text_box }, 1170 .rect = null, 1171 .min_size = .{ .width = "new".len + 2, .height = null }, 1172 }); 1173 } 1174 1175 var self = Header{ .box = box, .tab_ids = tab_ids }; 1176 // the tab matching the page's view is selected initially. 1177 self.getFocus().child_id = self.tab_ids.keys()[View.viewIndex(data.view)]; 1178 return self; 1179 } 1180 1181 pub fn deinit(self: *Header, allocator: std.mem.Allocator) void { 1182 self.box.deinit(allocator); 1183 self.tab_ids.deinit(allocator); 1184 } 1185 1186 pub fn build(self: *Header, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 1187 self.clearGrid(); 1188 // only the selected tab shows its border 1189 for (self.box.children.keys(), self.box.children.values()) |id, *child| { 1190 switch (child.widget) { 1191 .text_box => |*tb| tb.options.border_style = if (self.getFocus().child_id == id) .single else .hidden, 1192 else => {}, 1193 } 1194 } 1195 try self.box.build(allocator, constraint, root_focus); 1196 } 1197 1198 pub fn input(self: *Header, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 1199 _ = allocator; 1200 const current_tab = self.currentTabIndex() orelse return; 1201 if (inp.moveTab(key, current_tab, self.tab_ids.count())) |new_tab| { 1202 root_focus.setFocus(self.tab_ids.keys()[new_tab]); 1203 } 1204 } 1205 1206 pub fn clearGrid(self: *Header) void { 1207 self.box.clearGrid(); 1208 } 1209 1210 pub fn getGrid(self: Header) ?Grid { 1211 return self.box.getGrid(); 1212 } 1213 1214 pub fn getFocus(self: *Header) *Focus { 1215 return self.box.getFocus(); 1216 } 1217 1218 pub fn getSelectedIndex(self: Header) ?usize { 1219 return self.currentTabIndex(); 1220 } 1221 1222 fn currentTabIndex(self: Header) ?usize { 1223 const child_id = self.box.focus.child_id orelse return null; 1224 return self.tab_ids.getIndex(child_id); 1225 } 1226 }; 1227