╭─────────────╮ │scroll to top│ ╰─────────────╯ ╭ Threads.zig ──────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
1148 { 1149 var tb = try wgt.TextBox(ui.Widget).init(allocator, entry.record.event.title, .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .word, .label = " title " }); 1150 errdefer tb.deinit(allocator); 1151 tb.getFocus().focusable = true; 1152 try inner.children.put(allocator, tb.getFocus().id, .{ .widget = .{ .text_box = tb }, .rect = null, .min_size = null }); 1153 } 1154 1155 self.author_id[index] = blk: { 1156 var tb = try ui.authorBox(allocator, self.session.page_arena, entry.author); 1157 errdefer tb.deinit(allocator); 1158 try inner.children.put(allocator, tb.getFocus().id, .{ .widget = .{ .text_box = tb }, .rect = null, .min_size = null }); 1159 break :blk tb.getFocus().id; 1160 }; 1161 1162 // the issue's tags, each linking to this status's list filtered to 1163 // that tag. 1164 { 1165 var items: std.ArrayList(ui.TagFlow.Item) = .empty; 1166 defer items.deinit(allocator); 1167 var tag_iter = Event.tagIterator(entry.record.event.tags); 1168 while (tag_iter.next()) |tag| { 1169 if (tag.len == 0) continue; 1170 try items.append(allocator, .{ .text = tag, .link = try tagLink(self.session.page_arena, self.data.identity, entryStatus(entry), tag) }); 1171 } 1172 if (items.items.len > 0) { 1173 var tf = try ui.TagFlow.init(allocator); 1174 errdefer tf.deinit(allocator); 1175 try tf.setItems(allocator, items.items); 1176 try inner.children.put(allocator, tf.getFocus().id, .{ .widget = .{ .tag_flow = tf }, .rect = null, .min_size = null }); 1177 } 1178 } 1179 } 1180 1181 // the description as a focusable word-wrapped text box. past the limit 1182 // it's cut at the last whole line and links to the /description page, 1183 // which shows the whole thing. 1184 self.description_id[index] = blk: { 1185 const whole = entry.record.event.description; 1186 const cut_short = !description_page and whole.len > Data.max_description_size; 1187 const shown = if (cut_short) 1188 std.mem.trimEnd(u8, whole[0 .. std.mem.lastIndexOfScalar(u8, whole[0..Data.max_description_size], '\n') orelse 0], " \t\r\n") 1189 else if (whole.len == 0) 1190 "(no description)" 1191 else 1192 whole; 1193 var tb = try wgt.TextBox(ui.Widget).init(allocator, shown, .{ 1194 .border_style = .single, 1195 .rounded_corners = true, 1196 .wrap_kind = .word, 1197 .label = " description ", 1198 .bottom_label = if (cut_short) " click or press enter to see more " else "", 1199 }); 1200 errdefer tb.deinit(allocator); 1201 tb.getFocus().focusable = true; 1202 if (cut_short) tb.getFocus().kind = .{ .custom = try descriptionLink(self.session.page_arena, self.data.identity, entry.id) }; 1203 try inner.children.put(allocator, tb.getFocus().id, .{ .widget = .{ .text_box = tb }, .rect = null, .min_size = null }); 1204 break :blk tb.getFocus().id; 1205 }; 1206 1207 const parent_route = commentsRoute(self.data.identity, entry.id, 0) orelse return error.RouteTooLong; 1208 try Attachment.appendRows(allocator, inner, self.session, self.data.identity, try parent_route.toUrl(self.session.page_arena), entry.attachments); 1209 1210 if (!description_page) { 1211 var reply = try Comment.linkBox(allocator, self.session, "new comment", newCommentRoute(self.data.identity, entry.id, "") orelse return error.RouteTooLong); 1212 errdefer reply.deinit(allocator); 1213 try inner.children.put(allocator, reply.getFocus().id, .{ .widget = .{ .text_box = reply }, .rect = null, .min_size = null }); 1214 1215 try Comment.appendCount(allocator, inner, entry.comments.count, "comment", "comments", self.session.page_arena.allocator()); 1216 for (entry.comments.comments) |comment| try Comment.appendComment(allocator, inner, self.session, self.data.identity, kind, comment); 1217 try Comment.appendWindowNav(allocator, inner, self.session, self.data.identity, kind, entry.id, null, entry.comments); 1218 1219 var spacer = try ui.Spacer.init(allocator); 1220 errdefer spacer.deinit(allocator); 1221 try inner.children.put(allocator, spacer.getFocus().id, .{ .widget = .{ .spacer = spacer }, .rect = null, .min_size = null }); 1222 } 1223 1224 // select the title by default 1225 inner.getFocus().child_id = inner.children.keys()[title_child_index]; 1226 1227 // reset the scroll to the top for the newly-shown issue: directly on the 1228 // terminal (the wasm offset), and via a version bump on the web (so the 1229 // renderer's scroll id changes and JS drops the preserved position). 1230 const sc = self.detailScroll(index); 1231 sc.x = 0; 1232 sc.y = 0; 1233 sc.getFocus().version +%= 1; 1234 } 1235 1236 pub fn input(self: *This, allocator: std.mem.Allocator, raw_key: Key, root_focus: *Focus) !void { 1237 // wheel input moves focus like the arrows 1238 const key: Key = if (raw_key == .mouse and raw_key.mouse.action == .scroll) 1239 (if (raw_key.mouse.action.scroll == .up) .arrow_up else .arrow_down) 1240 else 1241 raw_key; 1242 1243 if (self.headerActive()) { 1244 // down from the tabs re-enters the stack if the selected view has 1245 // something to focus; other keys move the tabs. 1246 if (inp.vertDirection(key) == .down) { 1247 const enterable = if (self.viewStack().getSelected()) |selected| switch (selected.*) { 1248 .tag_flow => |*tf| tf.text_boxes.items.len > 0, 1249 // nothing focusable in the unauthorized view 1250 .unauthorized => false, 1251 else => true, 1252 } else false; 1253 if (enterable) root_focus.setFocus(self.box.children.keys()[stack_index]); 1254 } else { 1255 try self.header().input(allocator, key, root_focus); 1256 } 1257 return; 1258 } 1259 if (self.tagsViewActive()) { 1260 self.tagsViewInput(key, root_focus); 1261 return; 1262 } 1263 if (self.formViewActive()) { 1264 if (self.data.view == .remove) { 1265 try self.removeFormInput(allocator, key, root_focus); 1266 } else if (has_conflicts and self.data.view == .resolve) { 1267 try self.resolveInput(allocator, key, root_focus); 1268 } else if (self.data.view == .new_comment or self.data.view == .edit_comment) { 1269 try self.commentFormInput(allocator, key, root_focus); 1270 } else { 1271 try self.formInput(allocator, key, root_focus); 1272 } 1273 return; 1274 } 1275 const i = self.selectedSplitIndex() orelse return; 1276 if (self.detailActive(i)) { 1277 try self.detailInput(allocator, i, key, root_focus); 1278 } else { 1279 try self.listInput(i, key, root_focus); 1280 } 1281 } 1282 1283 // arrow keys move the tag selection; up from the top row crosses to the 1284 // header tabs. 1285 fn tagsViewInput(self: *This, key: Key, root_focus: *Focus) void { 1286 const tf = self.tagsView(); 1287 const cid = tf.focus.child_id orelse return; 1288 const cur = tf.indexOfFocusId(cid) orelse return; 1289 const count = tf.text_boxes.items.len; 1290 switch (key) { 1291 .arrow_left => if (cur > 0) root_focus.setFocus(tf.text_boxes.items[cur - 1].getFocus().id), 1292 .arrow_right => if (cur + 1 < count) root_focus.setFocus(tf.text_boxes.items[cur + 1].getFocus().id), 1293 .arrow_up => if (tf.rowStep(cur, false)) |i| root_focus.setFocus(tf.text_boxes.items[i].getFocus().id) else self.focusHeader(root_focus), 1294 .arrow_down => if (tf.rowStep(cur, true)) |i| root_focus.setFocus(tf.text_boxes.items[i].getFocus().id), 1295 .home => root_focus.setFocus(tf.text_boxes.items[0].getFocus().id), 1296 .end => root_focus.setFocus(tf.text_boxes.items[count - 1].getFocus().id), 1297 else => {}, 1298 } 1299 } 1300 1301 fn listInput(self: *This, index: usize, key: Key, root_focus: *Focus) !void { 1302 // up/down (and the scroll wheel) move the selection a row; page up/down 1303 // jump a fixed amount. right/Enter cross into the detail pane. up from 1304 // the top row crosses into the header tabs. 1305 if (inp.rowDelta(key, @intCast(self.listBox(index).children.count()))) |delta| { 1306 const lb = self.listBox(index); 1307 const at_top = if (lb.getFocus().child_id) |cid| lb.children.getIndex(cid) == 0 else true; 1308 if (delta < 0 and at_top) return self.focusHeader(root_focus); 1309 ui.moveRowFocus(lb, self.listScroll(index), root_focus, delta); 1310 return; 1311 } 1312 switch (key) { 1313 .enter, .arrow_right => try self.focusDetail(index, root_focus), 1314 else => {}, 1315 } 1316 } 1317 1318 fn detailInput(self: *This, allocator: std.mem.Allocator, index: usize, key: Key, root_focus: *Focus) !void { 1319 switch (key) { 1320 .page_up => { 1321 self.pageDetail(index, root_focus, -10); 1322 return; 1323 }, 1324 .page_down => { 1325 self.pageDetail(index, root_focus, 10); 1326 return; 1327 }, 1328 .home => { 1329 self.jumpDetail(index, root_focus, false); 1330 return; 1331 }, 1332 .end => { 1333 self.jumpDetail(index, root_focus, true); 1334 return; 1335 }, 1336 else => {}, 1337 } 1338 if (self.data.comment_page != null and std.mem.eql(u8, self.window(index).items[self.detailed_index[index] orelse return].id, self.data.selected_id)) { 1339 try self.commentInput(allocator, index, key, root_focus); 1340 } else if (self.toolRowFocused(index)) { 1341 try self.toolRowInput(allocator, index, key, root_focus); 1342 } else if (self.titleFocused(index)) { 1343 try self.titleInput(index, key, root_focus); 1344 } else if (self.authorFocused(index)) { 1345 try self.authorInput(index, key, root_focus); 1346 } else if (self.tagsFocused(index)) { 1347 try self.tagsInput(index, key, root_focus); 1348 } else if (self.descriptionFocused(index)) { 1349 try self.descriptionInput(index, key, root_focus); 1350 } else if (try self.attachmentInput(allocator, index, key, root_focus)) {} else { 1351 try self.commentInput(allocator, index, key, root_focus); 1352 } 1353 } 1354 1355 fn attachmentInput(self: *This, allocator: std.mem.Allocator, index: usize, key: Key, root_focus: *Focus) !bool { 1356 const child_index = self.focusedDetailChild(index, root_focus) orelse return false; 1357 const child = &self.detailInner(index).children.values()[child_index].widget; 1358 const row = switch (child.*) { 1359 .box => |*box| box, 1360 else => return false, 1361 }; 1362 const attachment_id = Attachment.removeId(row) orelse return false; 1363 if (row.children.count() < 2) return false; 1364 const remove_id = row.children.keys()[1]; 1365 switch (key) { 1366 .arrow_left => if (!self.moveCommentHorizontal(index, root_focus, false)) try self.focusList(index, root_focus), 1367 .arrow_right => _ = self.moveCommentHorizontal(index, root_focus, true), 1368 .arrow_up => _ = self.moveDetailVertical(index, root_focus, false), 1369 .arrow_down => _ = self.moveDetailVertical(index, root_focus, true), 1370 .enter => if (row.getFocus().child_id == remove_id) try self.removeEvent(allocator, .attach, attachment_id), 1371 .mouse => |mouse| if (inp.leftClickOn(root_focus, remove_id, mouse)) try self.removeEvent(allocator, .attach, attachment_id), 1372 else => {}, 1373 } 1374 return true; 1375 } 1376 1377 // the tool row: enter or a click on the open/close button flips the 1378 // issue's status; the edit and resolve buttons are a: links the host 1379 // follows. arrows cross between the buttons and to the neighboring 1380 // widgets. 1381 fn toolRowInput(self: *This, allocator: std.mem.Allocator, index: usize, key: Key, root_focus: *Focus) !void { 1382 const row = self.toolRow(index); 1383 const cur = if (row.getFocus().child_id) |cid| row.children.getIndex(cid) orelse return else return; 1384 const on_status = if (self.status_button_id[index]) |id| row.getFocus().child_id == id else false; 1385 switch (key) { 1386 .arrow_left => if (cur > first_in_row_index) root_focus.setFocus(row.children.keys()[cur - 1]) else try self.focusList(index, root_focus), 1387 .arrow_right => if (cur + 1 < row.children.count()) root_focus.setFocus(row.children.keys()[cur + 1]), 1388 .arrow_up => self.focusHeader(root_focus), 1389 .arrow_down => { 1390 self.focusDetailEdge(index, root_focus, false); 1391 self.detailScroll(index).y = 0; 1392 }, 1393 .enter => if (on_status) try self.toggleStatus(allocator, index), 1394 .mouse => |mouse| if (self.status_button_id[index]) |id| { 1395 if (inp.leftClickOn(root_focus, id, mouse)) try self.toggleStatus(allocator, index); 1396 }, 1397 else => {}, 1398 } 1399 } 1400 1401 fn titleInput(self: *This, index: usize, key: Key, root_focus: *Focus) !void { 1402 switch (key) { 1403 .arrow_left => try self.focusList(index, root_focus), 1404 .arrow_up => self.focusToolRow(index, root_focus), 1405 .arrow_down => _ = self.moveDetailVertical(index, root_focus, true), 1406 else => {}, 1407 } 1408 } 1409 1410 // the author box's a: link (when it has one) is followed by the host on 1411 // enter / a click; arrows cross to the neighboring widgets. 1412 fn authorInput(self: *This, index: usize, key: Key, root_focus: *Focus) !void { 1413 switch (key) { 1414 .arrow_left => try self.focusList(index, root_focus), 1415 .arrow_up => _ = self.moveDetailVertical(index, root_focus, false), 1416 .arrow_down => _ = self.moveDetailVertical(index, root_focus, true), 1417 else => {}, 1418 } 1419 } 1420 1421 fn descriptionInput(self: *This, index: usize, key: Key, root_focus: *Focus) !void { 1422 const sc = self.detailScroll(index); 1423 switch (key) { 1424 .arrow_left => return self.focusList(index, root_focus), 1425 // the terminal moves through the description one line at a time; 1426 // the web lets focus changes drive its native scroll. 1427 .arrow_up => { 1428 if (!self.session.is_terminal) { 1429 _ = self.moveDetailVertical(index, root_focus, false); 1430 return; 1431 } 1432 if (self.focusAdjacentVisible(index, root_focus, false)) return; 1433 const before = sc.y; 1434 sc.y -= 1; 1435 sc.clampToContent(); 1436 if (sc.y == before) _ = self.moveDetailVertical(index, root_focus, false); 1437 return; 1438 }, 1439 .arrow_down => { 1440 if (!self.session.is_terminal) { 1441 _ = self.moveDetailVertical(index, root_focus, true); 1442 return; 1443 } 1444 if (self.focusAdjacentVisible(index, root_focus, true)) return; 1445 const before = sc.y; 1446 sc.y += 1; 1447 sc.clampToContent(); 1448 if (sc.y == before) _ = self.moveDetailVertical(index, root_focus, true); 1449 return; 1450 }, 1451 else => return, 1452 } 1453 sc.clampToContent(); 1454 } 1455 1456 fn commentInput(self: *This, allocator: std.mem.Allocator, index: usize, key: Key, root_focus: *Focus) !void { 1457 _ = allocator; 1458 switch (key) { 1459 .arrow_left => if (!self.moveCommentHorizontal(index, root_focus, false)) return self.focusList(index, root_focus), 1460 .arrow_right => _ = self.moveCommentHorizontal(index, root_focus, true), 1461 .arrow_up => _ = self.moveDetailVertical(index, root_focus, false), 1462 .arrow_down => _ = self.moveDetailVertical(index, root_focus, true), 1463 else => return, 1464 } 1465 } 1466 1467 // page the detail scroll, then focus the first or last visible widget. 1468 fn pageDetail(self: *This, index: usize, root_focus: *Focus, delta: isize) void { 1469 if (!self.session.is_terminal) return; 1470 const sc = self.detailScroll(index); 1471 sc.y += delta; 1472 sc.clampToContent(); 1473 self.focusVisible(index, root_focus, delta > 0); 1474 } 1475 1476 // jump to the detail's first or last focusable widget. the terminal pins 1477 // its synthetic scroll too; the browser scrolls to the new focus itself. 1478 fn jumpDetail(self: *This, index: usize, root_focus: *Focus, to_end: bool) void { 1479 if (self.session.is_terminal) { 1480 const sc = self.detailScroll(index); 1481 sc.y = if (to_end) std.math.maxInt(isize) else 0; 1482 sc.clampToContent(); 1483 self.focusVisible(index, root_focus, to_end); 1484 } else { 1485 self.focusDetailEdge(index, root_focus, to_end); 1486 } 1487 } 1488 1489 fn focusDetailEdge(self: *This, index: usize, root_focus: *Focus, last: bool) void { 1490 const focus = self.detailInner(index).getFocus(); 1491 var chosen: ?usize = null; 1492 for (focus.children.keys(), focus.children.values()) |id, child| { 1493 if (!child.focus.focusable) continue; 1494 chosen = id; 1495 if (!last) break; 1496 } 1497 if (chosen) |id| root_focus.setFocus(id); 1498 } 1499 1500 fn detailRectVisible(self: *This, index: usize, rect: layout.IRect) bool { 1501 const sc = self.detailScroll(index); 1502 const viewport = sc.grid orelse return false; 1503 const top = sc.y; 1504 const bottom = top + @as(isize, @intCast(viewport.size.height - sc.bar_h)); 1505 return rect.y + @as(isize, @intCast(rect.size.height)) > top and rect.y < bottom; 1506 } 1507 1508 // focus the top- or bottom-most focusable widget intersecting the viewport. 1509 fn focusVisible(self: *This, index: usize, root_focus: *Focus, last: bool) void { 1510 const inner = self.detailInner(index); 1511 var offset: usize = 0; 1512 while (offset < inner.children.count()) : (offset += 1) { 1513 const child_index = if (last) inner.children.count() - 1 - offset else offset; 1514 if (self.focusVisibleChild(index, child_index, root_focus, last)) return; 1515 } 1516 } 1517 1518 fn focusVisibleChild(self: *This, index: usize, child_index: usize, root_focus: *Focus, last: bool) bool { 1519 const child = &self.detailInner(index).children.values()[child_index]; 1520 const child_rect = child.rect orelse return false; 1521 var chosen: ?usize = null; 1522 if (child.widget.getFocus().focusable and self.detailRectVisible(index, child_rect)) { 1523 const id = self.detailInner(index).children.keys()[child_index]; 1524 chosen = id; 1525 if (!last) { 1526 root_focus.setFocus(id); 1527 return true; 1528 } 1529 } 1530 1531 for (child.widget.getFocus().children.keys(), child.widget.getFocus().children.values()) |id, focus_child| { 1532 if (!focus_child.focus.focusable) continue; 1533 const rect: layout.IRect = .{ 1534 .x = child_rect.x + @as(isize, @intCast(focus_child.rect.x)), 1535 .y = child_rect.y + @as(isize, @intCast(focus_child.rect.y)), 1536 .size = focus_child.rect.size, 1537 }; 1538 if (!self.detailRectVisible(index, rect)) continue; 1539 chosen = id; 1540 if (!last) break; 1541 } 1542 if (chosen) |id| { 1543 root_focus.setFocus(id); 1544 return true; 1545 } 1546 return false; 1547 } 1548 1549 fn focusAdjacentVisible(self: *This, index: usize, root_focus: *Focus, down: bool) bool { 1550 const inner = self.detailInner(index); 1551 var next = self.focusedDetailChild(index, root_focus) orelse return false; 1552 while (true) { 1553 if (down) { 1554 next += 1; 1555 if (next >= inner.children.count()) return false; 1556 } else { 1557 if (next == 0) return false; 1558 next -= 1; 1559 } 1560 if (self.focusVisibleChild(index, next, root_focus, !down)) { 1561 self.focusDetailChild(index, next, !down, root_focus); 1562 return true; 1563 } 1564 } 1565 } 1566 1567 fn focusedDetailChild(self: *This, index: usize, root_focus: *Focus) ?usize { 1568 const inner = self.detailInner(index); 1569 var id = root_focus.grandchild_id orelse return null; 1570 while (inner.getFocus().children.get(id)) |child| { 1571 if (child.parent_id == inner.getFocus().id) return inner.children.getIndex(id); 1572 id = child.parent_id; 1573 } 1574 return null; 1575 } 1576 1577 fn detailChildFocusable(self: *This, index: usize, child_index: usize) bool { 1578 const child = &self.detailInner(index).children.values()[child_index].widget; 1579 const focus = child.getFocus(); 1580 if (focus.focusable) return true; 1581 for (focus.children.values()) |item| { 1582 if (item.focus.focusable) return true; 1583 } 1584 return false; 1585 } 1586 1587 fn focusDetailChild(self: *This, index: usize, child_index: usize, last: bool, root_focus: *Focus) void { 1588 const inner = self.detailInner(index); 1589 const child = &inner.children.values()[child_index]; 1590 var target = inner.children.keys()[child_index]; 1591 var rect = child.rect orelse return; 1592 switch (child.widget) { 1593 .repo_comment => |*comment| { 1594 if (comment.rowRect(last)) |row_rect| { 1595 rect.x += row_rect.x; 1596 rect.y += row_rect.y; 1597 rect.size = row_rect.size; 1598 } 1599 if (last) comment.focusBody(root_focus) else comment.focusMetadata(root_focus); 1600 }, 1601 .box => |*box| if (box.children.count() > 0) { 1602 target = box.children.keys()[if (last) box.children.count() - 1 else 0]; 1603 root_focus.setFocus(target); 1604 }, 1605 else => root_focus.setFocus(target), 1606 } 1607 const sc = self.detailScroll(index); 1608 if (sc.grid) |viewport| { 1609 const viewport_height = viewport.size.height - sc.bar_h; 1610 if (rect.size.height > viewport_height) { 1611 const bottom_aligned = rect.y + @as(isize, @intCast(rect.size.height - viewport_height)); 1612 sc.y = std.math.clamp(sc.y, rect.y, bottom_aligned); 1613 sc.clampToContent(); 1614 return; 1615 } 1616 } 1617 sc.scrollToRect(rect); 1618 } 1619 1620 fn moveDetailVertical(self: *This, index: usize, root_focus: *Focus, down: bool) bool { 1621 const inner = self.detailInner(index); 1622 const current = self.focusedDetailChild(index, root_focus) orelse return false; 1623 if (inner.children.values()[current].widget == .repo_comment) { 1624 const comment = &inner.children.values()[current].widget.repo_comment; 1625 if (down and !comment.bodyFocused()) { 1626 self.focusDetailChild(index, current, true, root_focus); 1627 return true; 1628 } 1629 if (!down and comment.bodyFocused()) { 1630 self.focusDetailChild(index, current, false, root_focus); 1631 return true; 1632 } 1633 } 1634 1635 var next = current; 1636 while (true) { 1637 if (down) { 1638 next += 1; 1639 if (next >= inner.children.count()) return false; 1640 } else { 1641 if (next == 0) return false; 1642 next -= 1; 1643 } 1644 if (!self.detailChildFocusable(index, next)) continue; 1645 self.focusDetailChild(index, next, !down, root_focus); 1646 return true; 1647 } 1648 } 1649 1650 fn moveCommentHorizontal(self: *This, index: usize, root_focus: *Focus, right: bool) bool { 1651 const inner = self.detailInner(index); 1652 const current = self.focusedDetailChild(index, root_focus) orelse return false; 1653 const child = &inner.children.values()[current]; 1654 if (child.widget == .repo_comment) { 1655 const moved = child.widget.repo_comment.moveHorizontal(root_focus, right); 1656 if (moved) if (child.rect) |rect| self.detailScroll(index).scrollToRect(rect); 1657 return moved; 1658 } 1659 const row = switch (child.widget) { 1660 .box => |*box| box, 1661 else => return false, 1662 }; 1663 const selected = row.getFocus().child_id orelse return false; 1664 const selected_index = row.children.getIndex(selected) orelse return false; 1665 const target = if (right) selected_index + 1 else selected_index -| 1; 1666 if (target == selected_index or target >= row.children.count()) return false; 1667 root_focus.setFocus(row.children.keys()[target]); 1668 if (child.rect) |rect| self.detailScroll(index).scrollToRect(rect); 1669 return true; 1670 } 1671 1672 // up/down (and tab/shift+tab) move between a form's fields; up from the 1673 // title crosses into the header tabs. the multiline description keeps 1674 // enter and any up/down that has a row to move to. 1675 fn formInput(self: *This, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 1676 const form = self.threadForm() orelse return; 1677 const cid = form.getFocus().child_id orelse return; 1678 const cur = form.children.getIndex(cid) orelse return; 1679 1680 if (cur == description_field_index) { 1681 const child = &form.children.values()[cur]; 1682 if (try multilineKeepsKey(&child.widget.text_input, allocator, key)) 1683 return child.widget.input(allocator, key, root_focus); 1684 } 1685 1686 switch (key) { 1687 .arrow_up, .back_tab => if (cur > 0) 1688 root_focus.setFocus(form.children.keys()[cur - 1]) 1689 else 1690 self.focusHeader(root_focus), 1691 .arrow_down, .tab => if (cur < submit_field_index) { 1692 root_focus.setFocus(form.children.keys()[cur + 1]); 1693 }, 1694 .enter => if (cur == submit_field_index) try self.submitForm(allocator), 1695 .mouse => |mouse| if (cur == submit_field_index) { 1696 const submit = &form.children.values()[cur].widget.submit_button; 1697 if (inp.leftClickOn(root_focus, submit.buttonId(), mouse)) try self.submitForm(allocator); 1698 }, 1699 else => try form.children.values()[cur].widget.input(allocator, key, root_focus), 1700 } 1701 } 1702 1703 fn commentFormInput(self: *This, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 1704 const form = self.threadForm() orelse return; 1705 const cid = form.getFocus().child_id orelse return; 1706 const cur = form.children.getIndex(cid) orelse return; 1707 const child = &form.children.values()[cur]; 1708 1709 if (cur == comment_body_field_index and try multilineKeepsKey(&child.widget.text_input, allocator, key)) 1710 return child.widget.input(allocator, key, root_focus); 1711 1712 switch (key) { 1713 .arrow_up, .back_tab => if (cur > comment_author_field_index) 1714 root_focus.setFocus(form.children.keys()[cur - 1]) 1715 else 1716 self.focusHeader(root_focus), 1717 .arrow_down, .tab => if (cur < comment_submit_field_index) 1718 root_focus.setFocus(form.children.keys()[cur + 1]), 1719 .enter => if (cur == comment_submit_field_index) try self.submitComment(allocator), 1720 .mouse => |mouse| if (cur == comment_submit_field_index) { 1721 const submit = &child.widget.submit_button; 1722 if (inp.leftClickOn(root_focus, submit.buttonId(), mouse)) try self.submitComment(allocator); 1723 }, 1724 else => try child.widget.input(allocator, key, root_focus), 1725 } 1726 } 1727 1728 fn removeFormInput(self: *This, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 1729 const form = self.removeForm() orelse return; 1730 const button_id = form.children.keys()[1]; 1731 switch (key) { 1732 .arrow_up, .back_tab => self.focusHeader(root_focus), 1733 .enter => try self.submitRemove(allocator), 1734 .mouse => |mouse| if (inp.leftClickOn(root_focus, button_id, mouse)) try self.submitRemove(allocator), 1735 else => {}, 1736 } 1737 } 1738 1739 // the multiline hunk inputs keep enter and any up/down that has a row to 1740 // move to; enter on a "use this" link is a navigation the host follows 1741 // before this runs. 1742 fn resolveInput(self: *This, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 1743 const form = self.threadForm() orelse return; 1744 const cid = form.getFocus().child_id orelse return; 1745 const cur = form.children.getIndex(cid) orelse return; 1746 const child = &form.children.values()[cur]; 1747 1748 if (child.widget == .text_input) { 1749 const ti = &child.widget.text_input; 1750 if (ti.options.multiline and try multilineKeepsKey(ti, allocator, key)) 1751 return child.widget.input(allocator, key, root_focus); 1752 } 1753 1754 const on_submit = child.widget == .submit_button; 1755 1756 switch (key) { 1757 .arrow_up, .back_tab => if (resolveStep(form, cur, false)) |i| self.focusResolveChild(form, i, root_focus) else self.focusHeader(root_focus), 1758 .arrow_down, .tab => if (resolveStep(form, cur, true)) |i| self.focusResolveChild(form, i, root_focus), 1759 .arrow_left, .arrow_right => switch (child.widget) { 1760 .box => |*row| { 1761 const row_cid = row.getFocus().child_id orelse return; 1762 const row_index = row.children.getIndex(row_cid) orelse return; 1763 if (key == .arrow_left) { 1764 if (row_index > 0) root_focus.setFocus(row.children.keys()[row_index - 1]); 1765 } else if (row_index + 1 < row.children.count()) { 1766 root_focus.setFocus(row.children.keys()[row_index + 1]); 1767 } 1768 }, 1769 else => try child.widget.input(allocator, key, root_focus), 1770 }, 1771 .enter => if (on_submit) try self.submitResolution(allocator), 1772 .mouse => |mouse| if (on_submit) { 1773 if (inp.leftClickOn(root_focus, child.widget.submit_button.buttonId(), mouse)) try self.submitResolution(allocator); 1774 }, 1775 else => try child.widget.input(allocator, key, root_focus), 1776 } 1777 } 1778 1779 // a multiline input keeps enter and any up/down that has a row to move to 1780 fn multilineKeepsKey(text_input: *wgt.TextInput(ui.Widget), allocator: std.mem.Allocator, key: Key) !bool { 1781 return switch (key) { 1782 .enter => true, 1783 .arrow_up => !try text_input.cursorOnFirstRow(allocator), 1784 .arrow_down => !try text_input.cursorOnLastRow(allocator), 1785 else => false, 1786 }; 1787 } 1788 1789 // the neighboring focusable resolve-form child, skipping the spacer and 1790 // the blank gap rows 1791 fn resolveStep(form: *wgt.Box(ui.Widget), cur: usize, down: bool) ?usize { 1792 var i = cur; 1793 while (true) { 1794 if (down) { 1795 i += 1; 1796 if (i >= form.children.count()) return null; 1797 } else { 1798 if (i == 0) return null; 1799 i -= 1; 1800 } 1801 switch (form.children.values()[i].widget) { 1802 .spacer, .text => continue, 1803 else => return i, 1804 } 1805 } 1806 } 1807 1808 // focus the resolve-form child at `i` (a version row focuses its selected 1809 // child) and keep it visible on the terminal 1810 fn focusResolveChild(self: *This, form: *wgt.Box(ui.Widget), i: usize, root_focus: *Focus) void { 1811 const child = &form.children.values()[i]; 1812 switch (child.widget) { 1813 .box => |*row| root_focus.setFocus(row.getFocus().child_id orelse form.children.keys()[i]), 1814 else => root_focus.setFocus(form.children.keys()[i]), 1815 } 1816 if (self.session.is_terminal) { 1817 if (self.resolveScroll()) |sc| { 1818 if (child.rect) |rect| sc.scrollToRect(rect); 1819 } 1820 } 1821 } 1822 1823 // re-emit the conflicted issue's event with the resolved content; any 1824 // event on the issue settles its conflict entry. this is the terminal 1825 // path; the web posts the form to the resolve route. 1826 fn submitResolution(self: *This, allocator: std.mem.Allocator) !void { 1827 if (comptime wasm) return; 1828 const io = self.session.io orelse return; 1829 const src = self.data.repo_source orelse return; 1830 const entry = self.data.selectedThread() orelse return; 1831 const author = (try self.session.eventAuthor()) orelse return; 1832 const form = self.threadForm() orelse return; 1833 1834 // gather the inputs by name; the d<n> hunk inputs appear in chunk order 1835 var title: ?[]u8 = null; 1836 var tags: ?[]u8 = null; 1837 var hunks: std.ArrayList([]const u8) = .empty; 1838 defer { 1839 if (title) |t| allocator.free(t); 1840 if (tags) |t| allocator.free(t); 1841 for (hunks.items) |h| allocator.free(h); 1842 hunks.deinit(allocator); 1843 } 1844 for (form.children.values()) |*child| switch (child.widget) { 1845 .text_input => |*ti| { 1846 const text = try ti.text(allocator); 1847 errdefer allocator.free(text); 1848 if (std.mem.eql(u8, ti.options.name, "title")) { 1849 title = text; 1850 } else if (std.mem.eql(u8, ti.options.name, "tags")) { 1851 tags = text; 1852 } else { 1853 try hunks.append(allocator, text); 1854 } 1855 }, 1856 else => {}, 1857 }; 1858 1859 var id_bytes: [evt.event_id_size]u8 = undefined; 1860 _ = try std.fmt.hexToBytes(&id_bytes, entry.id[0 .. evt.event_id_size * 2]); 1861 1862 switch (src.repo_kind) { 1863 inline else => |repo_kind| { 1864 var any_repo = try rp.AnyRepo(repo_kind, .{}).open(io, allocator, src.localInitOpts()); 1865 defer any_repo.deinit(io, allocator); 1866 switch (any_repo) { 1867 inline else => |*repo| Event.update(repo_kind, repo.self_repo_opts, io, allocator, repo, &id_bytes, .{ .resolve = .{ 1868 .title = title, 1869 .tags = tags, 1870 .hunks = hunks.items, 1871 } }, author) catch |err| switch (err) { 1872 // leave the form up for correction 1873 error.InvalidFields => return, 1874 else => |e| return e, 1875 }, 1876 } 1877 }, 1878 } 1879 1880 const route = listRoute(self.data.identity, entryStatus(entry.*), "", entry.id) orelse return; 1881 try self.session.navigate(route); 1882 } 1883 1884 // commit a new or edited comment and navigate to its permalink. this is the 1885 // terminal path; the web posts the form to its comment route. 1886 fn submitComment(self: *This, allocator: std.mem.Allocator) !void { 1887 if (comptime wasm) return; 1888 const io = self.session.io orelse return; 1889 const src = self.data.repo_source orelse return; 1890 const author = (try self.session.eventAuthor()) orelse return; 1891 const form = self.threadForm() orelse return; 1892 const body_input = &form.children.values()[comment_body_field_index].widget.text_input; 1893 const body = try body_input.text(allocator); 1894 defer allocator.free(body); 1895 if (!evt.Comment.fieldsValid(body)) return; 1896 1897 var thread_id: [evt.event_id_size * 2]u8 = undefined; 1898 @memcpy(&thread_id, self.data.selected_id); 1899 const parent_id = if (self.data.comment_id.len == 0) thread_id else blk: { 1900 var parent: [evt.event_id_size * 2]u8 = undefined; 1901 @memcpy(&parent, self.data.comment_id); 1902 break :blk parent; 1903 }; 1904 var event_id_hex: [evt.event_id_size * 2]u8 = undefined; 1905 const editing = self.data.view == .edit_comment; 1906 switch (src.repo_kind) { 1907 inline else => |repo_kind| { 1908 var any_repo = try rp.AnyRepo(repo_kind, .{}).open(io, allocator, src.localInitOpts()); 1909 defer any_repo.deinit(io, allocator); 1910 switch (any_repo) { 1911 inline else => |*repo| if (editing) { 1912 var comment_id: [evt.event_id_size]u8 = undefined; 1913 _ = std.fmt.hexToBytes(&comment_id, self.data.comment_id) catch return; 1914 try evt.Comment.update(repo_kind, repo.self_repo_opts, io, allocator, repo, &thread_id, &comment_id, body, author); 1915 event_id_hex = std.fmt.bytesToHex(comment_id, .lower); 1916 } else { 1917 event_id_hex = try evt.Comment.create(repo_kind, repo.self_repo_opts, io, allocator, repo, &thread_id, &parent_id, body, author); 1918 }, 1919 } 1920 }, 1921 } 1922 1923 body_input.clear(allocator); 1924 const route = commentRoute(self.data.identity, &thread_id, &event_id_hex, 0) orelse return; 1925 try self.session.navigate(route); 1926 } 1927 1928 // the terminal submit paths: the new form commits a new-issue event, the 1929 // edit form re-emits the selected issue's event with the form's content. 1930 // the web posts the forms to their routes instead. 1931 fn submitForm(self: *This, allocator: std.mem.Allocator) !void { 1932 if (self.data.view == .edit) { 1933 try self.submitEditedThread(allocator); 1934 } else { 1935 try self.submitNewThread(allocator); 1936 } 1937 } 1938 1939 // commit the new issue to the repo's events branch and navigate to it. 1940 // this is the terminal path; the web posts the form to the issue route, 1941 // so the wasm side never runs (or compiles) the repo access below. 1942 fn submitNewThread(self: *This, allocator: std.mem.Allocator) !void { 1943 if (comptime wasm) return; 1944 const io = self.session.io orelse return; 1945 const src = self.data.repo_source orelse return; 1946 const author = (try self.session.eventAuthor()) orelse return; 1947 1948 const form = self.threadForm() orelse return; 1949 const title_input = &form.children.values()[title_field_index].widget.text_input; 1950 const tags_input = &form.children.values()[tags_field_index].widget.text_input; 1951 const description_input = &form.children.values()[description_field_index].widget.text_input; 1952 1953 const title = try title_input.text(allocator); 1954 defer allocator.free(title); 1955 const tags = try tags_input.text(allocator); 1956 defer allocator.free(tags); 1957 const description = try description_input.text(allocator); 1958 defer allocator.free(description); 1959 1960 if (!Event.fieldsValid(title, tags)) return; 1961 1962 var id_bytes: [evt.event_id_size]u8 = undefined; 1963 io.random(&id_bytes); 1964 const event_id_hex = std.fmt.bytesToHex(id_bytes, .lower); 1965 1966 const event = evt.EventWithId{ 1967 .id = event_id_hex, 1968 .timestamp = @intCast(std.Io.Timestamp.now(io, .real).toSeconds()), 1969 .author = author, 1970 .event = @unionInit(evt.Event, @tagName(kind), Event{ 1971 .title = title, 1972 .description = description, 1973 .tags = tags, 1974 }), 1975 }; 1976 1977 switch (src.repo_kind) { 1978 inline else => |repo_kind| { 1979 var any_repo = try rp.AnyRepo(repo_kind, .{}).open(io, allocator, src.localInitOpts()); 1980 defer any_repo.deinit(io, allocator); 1981 switch (any_repo) { 1982 inline else => |*repo| try evt.consume(repo_kind, repo.self_repo_opts, io, allocator, repo, evt.events_ref, &.{event}), 1983 } 1984 }, 1985 } 1986 1987 // wipe the form so a return visit starts fresh 1988 title_input.clear(allocator); 1989 tags_input.clear(allocator); 1990 description_input.clear(allocator); 1991 1992 const route = listRoute(self.data.identity, @enumFromInt(0), "", &event_id_hex) orelse return; 1993 try self.session.navigate(route); 1994 } 1995 1996 // re-emit the selected issue's event with the form's content (status 1997 // preserved), then reload the issue's page. this is the terminal path; 1998 // the web posts the form to the edit route. 1999 fn submitEditedThread(self: *This, allocator: std.mem.Allocator) !void { 2000 if (comptime wasm) return; 2001 const io = self.session.io orelse return; 2002 const src = self.data.repo_source orelse return; 2003 const entry = self.data.selectedThread() orelse return; 2004 const author = (try self.session.eventAuthor()) orelse return; 2005 2006 const form = self.threadForm() orelse return; 2007 const title_input = &form.children.values()[title_field_index].widget.text_input; 2008 const tags_input = &form.children.values()[tags_field_index].widget.text_input; 2009 const description_input = &form.children.values()[description_field_index].widget.text_input; 2010 2011 const title = try title_input.text(allocator); 2012 defer allocator.free(title); 2013 const tags = try tags_input.text(allocator); 2014 defer allocator.free(tags); 2015 const description = try description_input.text(allocator); 2016 defer allocator.free(description); 2017 2018 if (!Event.fieldsValid(title, tags)) return; 2019 2020 var id_bytes: [evt.event_id_size]u8 = undefined; 2021 _ = try std.fmt.hexToBytes(&id_bytes, entry.id[0 .. evt.event_id_size * 2]); 2022 2023 switch (src.repo_kind) { 2024 inline else => |repo_kind| { 2025 var any_repo = try rp.AnyRepo(repo_kind, .{}).open(io, allocator, src.localInitOpts()); 2026 defer any_repo.deinit(io, allocator); 2027 switch (any_repo) { 2028 inline else => |*repo| if (has_status) 2029 try Event.update(repo_kind, repo.self_repo_opts, io, allocator, repo, &id_bytes, .{ .fields = .{ 2030 .title = title, 2031 .tags = tags, 2032 .description = description, 2033 } }, author) 2034 else 2035 try Event.update(repo_kind, repo.self_repo_opts, io, allocator, repo, &id_bytes, title, tags, description, author), 2036 } 2037 }, 2038 } 2039 2040 const route = listRoute(self.data.identity, entryStatus(entry.*), "", entry.id) orelse return; 2041 try self.session.navigate(route); 2042 } 2043 2044 fn submitRemove(self: *This, allocator: std.mem.Allocator) !void { 2045 const comment = self.data.comment_id.len != 0; 2046 const event_id = if (comment) self.data.comment_id else self.data.selected_id; 2047 var id: [evt.event_id_size]u8 = undefined; 2048 _ = std.fmt.hexToBytes(&id, event_id) catch return; 2049 try self.removeEvent(allocator, if (comment) .comment else kind, id); 2050 } 2051 2052 fn removeEvent(self: *This, allocator: std.mem.Allocator, event_kind: evt.EventKind, id: [evt.event_id_size]u8) !void { 2053 if (comptime wasm) return; 2054 const io = self.session.io orelse return; 2055 const src = self.data.repo_source orelse return; 2056 const author = (try self.session.eventAuthor()) orelse return; 2057 2058 switch (src.repo_kind) { 2059 inline else => |repo_kind| { 2060 var any_repo = try rp.AnyRepo(repo_kind, .{}).open(io, allocator, src.localInitOpts()); 2061 defer any_repo.deinit(io, allocator); 2062 switch (any_repo) { 2063 inline else => |*repo| try evt.remove(repo_kind, repo.self_repo_opts, io, allocator, repo, &id, event_kind, author), 2064 } 2065 }, 2066 } 2067 2068 const id_hex = std.fmt.bytesToHex(id, .lower); 2069 const route = ui.RoutablePage.repoEventsRoute(self.data.identity, .removed, event_kind, &id_hex) orelse return; 2070 try self.session.navigate(route); 2071 } 2072 2073 // flip the shown issue's status by re-emitting its event, then reload the 2074 // page rooted at the issue so the view reflects the change. this is the 2075 // terminal path; the web posts the button's form to the status route. 2076 fn toggleStatus(self: *This, allocator: std.mem.Allocator, index: usize) !void { 2077 if (comptime !has_status) return; 2078 if (comptime wasm) return; 2079 const io = self.session.io orelse return; 2080 const src = self.data.repo_source orelse return; 2081 const sel = self.detailed_index[index] orelse return; 2082 const entry = self.window(index).items[sel]; 2083 const author = (try self.session.eventAuthor()) orelse return; 2084 2085 const status: Status = switch (entryStatus(entry)) { 2086 .open => .closed, 2087 .closed => .open, 2088 }; 2089 2090 var id_bytes: [evt.event_id_size]u8 = undefined; 2091 _ = try std.fmt.hexToBytes(&id_bytes, entry.id[0 .. evt.event_id_size * 2]); 2092 2093 switch (src.repo_kind) { 2094 inline else => |repo_kind| { 2095 var any_repo = try rp.AnyRepo(repo_kind, .{}).open(io, allocator, src.localInitOpts()); 2096 defer any_repo.deinit(io, allocator); 2097 switch (any_repo) { 2098 inline else => |*repo| try Event.update(repo_kind, repo.self_repo_opts, io, allocator, repo, &id_bytes, .{ .status = status }, author), 2099 } 2100 }, 2101 } 2102 2103 const route = listRoute(self.data.identity, status, "", entry.id) orelse return; 2104 try self.session.navigate(route); 2105 } 2106 2107 // arrow keys move the tag selection; at the flow's edges focus crosses to 2108 // the neighboring widgets. 2109 fn tagsInput(self: *This, index: usize, key: Key, root_focus: *Focus) !void { 2110 const tf = self.tagFlow(index) orelse return; 2111 const cid = tf.focus.child_id orelse return; 2112 const cur = tf.indexOfFocusId(cid) orelse return; 2113 const count = tf.text_boxes.items.len; 2114 switch (key) { 2115 .arrow_left => if (cur > 0) self.focusTag(index, tf, root_focus, cur - 1) else try self.focusList(index, root_focus), 2116 .arrow_right => if (cur + 1 < count) self.focusTag(index, tf, root_focus, cur + 1), 2117 .arrow_up => if (tf.rowStep(cur, false)) |i| { 2118 self.focusTag(index, tf, root_focus, i); 2119 } else { 2120 _ = self.moveDetailVertical(index, root_focus, false); 2121 }, 2122 .arrow_down => if (tf.rowStep(cur, true)) |i| { 2123 self.focusTag(index, tf, root_focus, i); 2124 } else { 2125 _ = self.moveDetailVertical(index, root_focus, true); 2126 }, 2127 .home => self.focusTag(index, tf, root_focus, 0), 2128 .end => self.focusTag(index, tf, root_focus, count - 1), 2129 else => {}, 2130 } 2131 } 2132 2133 const title_child_index: usize = 0; 2134 const tags_child_index: usize = 2; 2135 2136 // the tool row's first child after the spacer that pushes it right 2137 const first_in_row_index: usize = 1; 2138 2139 fn tagFlow(self: *This, index: usize) ?*ui.TagFlow { 2140 const inner = self.detailInner(index); 2141 if (inner.children.count() <= tags_child_index) return null; 2142 return switch (inner.children.values()[tags_child_index].widget) { 2143 .tag_flow => |*tf| tf, 2144 else => null, 2145 }; 2146 } 2147 2148 fn tagsFocused(self: *This, index: usize) bool { 2149 const inner = self.detailInner(index); 2150 const cid = inner.getFocus().child_id orelse return false; 2151 return inner.children.getIndex(cid) == tags_child_index and self.tagFlow(index) != null; 2152 } 2153 2154 fn toolRowFocused(self: *This, index: usize) bool { 2155 const outer = self.detailOuter(index); 2156 const cid = outer.getFocus().child_id orelse return false; 2157 return outer.children.getIndex(cid) == tool_row_index; 2158 } 2159 2160 fn focusTag(self: *This, index: usize, tf: *ui.TagFlow, root_focus: *Focus, item: usize) void { 2161 root_focus.setFocus(tf.text_boxes.items[item].getFocus().id); 2162 // keep the tag visible on the terminal: its rect offset by the flow's 2163 // position in the pane. 2164 if (self.session.is_terminal and item < tf.rects.items.len) { 2165 if (self.detailInner(index).children.values()[tags_child_index].rect) |flow_rect| { 2166 var rect = tf.rects.items[item]; 2167 rect.x += flow_rect.x; 2168 rect.y += flow_rect.y; 2169 self.detailScroll(index).scrollToRect(rect); 2170 } 2171 } 2172 } 2173 2174 fn titleFocused(self: *This, index: usize) bool { 2175 const inner = self.detailInner(index); 2176 const cid = inner.getFocus().child_id orelse return false; 2177 return inner.children.getIndex(cid) == title_child_index; 2178 } 2179 2180 fn authorFocused(self: *This, index: usize) bool { 2181 const inner = self.detailInner(index); 2182 const cid = inner.getFocus().child_id orelse return false; 2183 return cid == self.author_id[index]; 2184 } 2185 2186 fn descriptionFocused(self: *This, index: usize) bool { 2187 const cid = self.detailInner(index).getFocus().child_id orelse return false; 2188 return cid == self.description_id[index]; 2189 } 2190 2191 // return to the tool row's last-focused button (the header when the 2192 // description page shows no row). 2193 fn focusToolRow(self: *This, index: usize, root_focus: *Focus) void { 2194 const cid = self.toolRow(index).getFocus().child_id orelse return self.focusHeader(root_focus); 2195 root_focus.setFocus(cid); 2196 } 2197 2198 // enter the detail pane. an empty pane (no issues) can't be entered. 2199 fn focusDetail(self: *This, index: usize, root_focus: *Focus) !void { 2200 if (self.detailInner(index).children.count() == 0) return; 2201 root_focus.setFocus(self.detailOuter(index).getFocus().id); 2202 } 2203 2204 // return to the list. 2205 fn focusList(self: *This, index: usize, root_focus: *Focus) !void { 2206 root_focus.setFocus(self.listScroll(index).getFocus().id); 2207 } 2208 2209 // cross to the header tabs above the stack. 2210 fn focusHeader(self: *This, root_focus: *Focus) void { 2211 root_focus.setFocus(self.box.children.keys()[header_index]); 2212 } 2213 2214 pub fn clearGrid(self: *This) void { 2215 self.box.clearGrid(); 2216 } 2217 2218 pub fn getGrid(self: This) ?Grid { 2219 return self.box.getGrid(); 2220 } 2221 2222 pub fn getFocus(self: *This) *Focus { 2223 return self.box.getFocus(); 2224 } 2225 2226 // for the parent's "scroll up at the top jumps to the header" check: at the 2227 // top only while the header tabs hold focus, so up from the split 2228 // crosses into the tabs first. 2229 pub fn getSelectedIndex(self: *This) ?usize { 2230 return if (self.headerActive()) 0 else 1; 2231 } 2232 }; 2233 } 2234