╭─────────────╮ │scroll to top│ ╰─────────────╯ ╭ Issues.zig ───────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
142 } else { 143 const kv = try issue_id_set.getIndexKeyValuePair(@intCast(prev_rank)) orelse return error.NotFound; 144 var prev_key: [@sizeOf(u64) + evt.event_id_size]u8 = undefined; 145 _ = try kv.key_cursor.readBytes(&prev_key); 146 const prev_hex = std.fmt.bytesToHex(prev_key[@sizeOf(u64)..].*, .lower); 147 prev_id = try aa.dupe(u8, &prev_hex); 148 } 149 } 150 151 break :blk try issue_id_set.iteratorFrom(&order_key); 152 }; 153 154 // collect this window's issues, plus a peek at the one after it (its id is 155 // the next window's start). the set's keys are orderKey 156 // ([timestamp][event-id]); the trailing bytes of each key are the issue 157 // event id. 158 var issues: std.ArrayList(IssueWithId) = .empty; 159 var next_id: ?[]const u8 = null; 160 while (try iter.next()) |id_cursor_val| { 161 var id_cursor = id_cursor_val; 162 const id_kv = try id_cursor.readKeyValuePair(); 163 var order_key: [@sizeOf(u64) + evt.event_id_size]u8 = undefined; 164 _ = try id_kv.key_cursor.readBytes(&order_key); 165 const id_hex = std.fmt.bytesToHex(order_key[@sizeOf(u64)..].*, .lower); 166 if (issues.items.len == page_size) { 167 next_id = try aa.dupe(u8, &id_hex); 168 break; 169 } 170 const issue_cursor = try event_id_to_issue.getCursor(hash.hashInt(repo_opts.hash, order_key[@sizeOf(u64)..])) orelse continue; 171 const issue_map = try DB.HashMap(.read_only).init(issue_cursor); 172 try issues.append(aa, .{ 173 .id = try aa.dupe(u8, &id_hex), 174 .issue = try evt.read(evt.Issue, DB, repo_opts.hash, arena, issue_map), 175 }); 176 } 177 178 return .{ 179 .identity = empty.identity, 180 .tag = empty.tag, 181 .selected_id = empty.selected_id, 182 .issues = issues.items, 183 .prev_id = prev_id, 184 .next_id = next_id, 185 }; 186 } 187 188 pub const View = struct { 189 // a horizontal split with the issue list on the left and a detail pane on 190 // the right showing the selected issue's description. 191 box: wgt.Box(ui.Widget), 192 data: *const Self, 193 session: *ui.Session, 194 // the issue whose description the pane currently shows (index into data.issues). 195 detailed_index: ?usize, 196 197 const list_index: usize = 0; 198 const detail_index: usize = 1; 199 const list_max_width: usize = 40; 200 const detail_min_width: usize = 40; 201 202 pub fn init(allocator: std.mem.Allocator, data: *const Self, session: *ui.Session) !View { 203 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .horiz }); 204 errdefer box.deinit(allocator); 205 206 // the issue list (one focusable row per title), plus a "next" link that 207 // reloads the page rooted at the following issue. 208 { 209 var list_scroll = blk: { 210 var list_box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); 211 errdefer list_box.deinit(allocator); 212 if (data.prev_id) |prev| 213 try addRow(allocator, &list_box, "← previous", try issuesLink(session.page_arena, data.identity, data.tag, prev)); 214 for (data.issues) |entry| 215 try addRow(allocator, &list_box, entry.issue.title, try issueRowLink(session.page_arena, data.identity, entry.id)); 216 if (data.next_id) |next| 217 try addRow(allocator, &list_box, "next →", try issuesLink(session.page_arena, data.identity, data.tag, next)); 218 // select the window's first issue (past a leading "previous" 219 // row) so its description shows on load. 220 if (data.issues.len > 0) 221 list_box.getFocus().child_id = list_box.children.keys()[if (data.prev_id != null) 1 else 0] 222 else if (list_box.children.count() > 0) 223 list_box.getFocus().child_id = list_box.children.keys()[0]; 224 break :blk try wgt.Scroll(ui.Widget).init(allocator, .{ .box = list_box }, .{ .direction = .vert, .web_native = !session.is_terminal }); 225 }; 226 errdefer list_scroll.deinit(allocator); 227 try box.children.put(allocator, list_scroll.getFocus().id, .{ .widget = .{ .scroll = list_scroll }, .rect = null, .min_size = .{ .width = list_max_width, .height = null }, .max_size = .{ .width = list_max_width, .height = null } }); 228 } 229 230 // the detail pane — a frame around a scroll of the description 231 { 232 var detail_outer = blk: { 233 var detail_scroll = blk2: { 234 var detail_inner = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); 235 errdefer detail_inner.deinit(allocator); 236 // fill the pane (content top-left, scroll bar pinned to the 237 // edge) rather than shrinking to the description. 238 break :blk2 try wgt.Scroll(ui.Widget).init(allocator, .{ .box = detail_inner }, .{ .direction = .vert, .web_native = !session.is_terminal, .fill = true }); 239 }; 240 errdefer detail_scroll.deinit(allocator); 241 var frame = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = .hidden, .direction = .vert }); 242 errdefer frame.deinit(allocator); 243 // the frame's selected child is its scroll, so the focus chain 244 // reaches the description (populateDetail points the scroll's 245 // inner box at it), letting focus recovery descend into the pane. 246 frame.getFocus().child_id = detail_scroll.getFocus().id; 247 try frame.children.put(allocator, detail_scroll.getFocus().id, .{ .widget = .{ .scroll = detail_scroll }, .rect = null, .min_size = null }); 248 break :blk frame; 249 }; 250 errdefer detail_outer.deinit(allocator); 251 try box.children.put(allocator, detail_outer.getFocus().id, .{ .widget = .{ .box = detail_outer }, .rect = null, .min_size = .{ .width = detail_min_width, .height = null } }); 252 } 253 254 box.getFocus().child_id = box.children.keys()[list_index]; 255 256 return .{ 257 .box = box, 258 .data = data, 259 .session = session, 260 .detailed_index = null, 261 }; 262 } 263 264 fn addRow(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), label: []const u8, link: []const u8) !void { 265 var row = try wgt.TextBox(ui.Widget).init(allocator, label, .{ .border_style = .hidden, .rounded_corners = true, .wrap_kind = .word }); 266 errdefer row.deinit(allocator); 267 row.getFocus().focusable = true; 268 if (link.len != 0) row.getFocus().kind = .{ .custom = link }; 269 try box.children.put(allocator, row.getFocus().id, .{ .widget = .{ .text_box = row }, .rect = null, .min_size = null, .max_size = .{ .width = null, .height = 5 } }); 270 } 271 272 pub fn deinit(self: *View, allocator: std.mem.Allocator) void { 273 self.box.deinit(allocator); 274 } 275 276 fn listScroll(self: *View) *wgt.Scroll(ui.Widget) { 277 return &self.box.children.values()[list_index].widget.scroll; 278 } 279 280 fn listBox(self: *View) *wgt.Box(ui.Widget) { 281 return &self.listScroll().child.box; 282 } 283 284 fn detailOuter(self: *View) *wgt.Box(ui.Widget) { 285 return &self.box.children.values()[detail_index].widget.box; 286 } 287 288 fn detailScroll(self: *View) *wgt.Scroll(ui.Widget) { 289 return &self.detailOuter().children.values()[0].widget.scroll; 290 } 291 292 fn detailInner(self: *View) *wgt.Box(ui.Widget) { 293 return &self.detailScroll().child.box; 294 } 295 296 fn detailActive(self: *View) bool { 297 const cid = self.box.getFocus().child_id orelse return false; 298 return self.box.children.getIndex(cid) == detail_index; 299 } 300 301 // the selected issue's index, or null when a window-navigation row is 302 // selected (a leading "previous" row shifts the issue rows down by one). 303 fn selectedIssueIndex(self: *View) ?usize { 304 const lb = self.listBox(); 305 const cid = lb.getFocus().child_id orelse return null; 306 const idx = lb.children.getIndex(cid) orelse return null; 307 const lead: usize = if (self.data.prev_id != null) 1 else 0; 308 if (idx < lead or idx - lead >= self.data.issues.len) return null; 309 return idx - lead; 310 } 311 312 pub fn build(self: *View, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 313 self.clearGrid(); 314 315 // swap the detail pane to the selected issue when it changes. 316 try self.refreshDetail(allocator); 317 318 // mirror the focused issue into the url so it updates as the selection 319 // moves, but only while focus is inside this view (the list or detail). 320 // when focus sits on the header tab, use the page's base /issues route. 321 // an issue's url is the same whether or not the list is filtered, so 322 // the mirror drops the tag. 323 if (root_focus.grandchild_id) |g| { 324 if (self.box.getFocus().children.contains(g)) { 325 if (self.selectedIssueIndex()) |sel| { 326 if (ui.RoutablePage.repoIssuesRoute(self.data.identity, "", self.data.issues[sel].id)) |route| 327 self.session.data.current_page = route; 328 } 329 } 330 } 331 332 // the selected list row shows a border (the focused TextBox upgrades it 333 // to a double border itself); the rest stay borderless. 334 const lb = self.listBox(); 335 for (lb.children.keys(), lb.children.values()) |id, *child| { 336 switch (child.widget) { 337 .text_box => |*tb| tb.options.border_style = if (lb.getFocus().child_id == id) .single else .hidden, 338 else => {}, 339 } 340 } 341 342 // the description shows a single border while selected (the focused 343 // TextBox upgrades it to a double border itself). 344 const inner = self.detailInner(); 345 for (inner.children.keys(), inner.children.values()) |id, *child| { 346 switch (child.widget) { 347 .text_box => |*tb| tb.options.border_style = if (inner.getFocus().child_id == id) .single else .hidden, 348 else => {}, 349 } 350 } 351 352 // cap the list at list_max_width only while the detail pane fits beside 353 // it. the box drops the detail when the width can't hold both minimums, 354 // so when it's that narrow we lift the cap and let the list fill the 355 // whole width. 356 const both_panes_fit = if (constraint.max_size.width) |w| w >= list_max_width + detail_min_width else true; 357 self.box.children.values()[list_index].max_size = if (both_panes_fit) .{ .width = list_max_width, .height = null } else null; 358 359 // stretch the detail pane across the rest of the width so it fills the 360 // area rather than shrinking to its content; its scroll fills the pane. 361 if (constraint.max_size.width) |w| { 362 self.box.children.values()[detail_index].min_size = .{ .width = if (both_panes_fit) w - list_max_width else w, .height = null }; 363 } else { 364 self.box.children.values()[detail_index].min_size = .{ .width = detail_min_width, .height = null }; 365 } 366 367 try self.box.build(allocator, constraint, root_focus); 368 } 369 370 fn refreshDetail(self: *View, allocator: std.mem.Allocator) !void { 371 const sel = self.selectedIssueIndex() orelse return; 372 if (self.detailed_index) |d| if (d == sel) return; 373 try self.populateDetail(allocator, sel); 374 self.detailed_index = sel; 375 } 376 377 fn populateDetail(self: *View, allocator: std.mem.Allocator, sel: usize) !void { 378 const entry = self.data.issues[sel]; 379 const inner = self.detailInner(); 380 381 for (inner.children.values()) |*child| child.widget.deinit(allocator); 382 inner.children.clearAndFree(allocator); 383 inner.getFocus().child_id = null; 384 385 // the issue's tags, each linking to the list filtered to that tag. 386 { 387 var items: std.ArrayList(ui.TagFlow.Item) = .empty; 388 defer items.deinit(allocator); 389 var tag_iter = evt.Issue.tagIterator(entry.issue.tags); 390 while (tag_iter.next()) |tag| { 391 if (tag.len == 0) continue; 392 try items.append(allocator, .{ .text = tag, .link = try tagLink(self.session.page_arena, self.data.identity, tag) }); 393 } 394 if (items.items.len > 0) { 395 var tf = try ui.TagFlow.init(allocator); 396 errdefer tf.deinit(allocator); 397 try tf.setItems(allocator, items.items); 398 try inner.children.put(allocator, tf.getFocus().id, .{ .widget = .{ .tag_flow = tf }, .rect = null, .min_size = null }); 399 } 400 } 401 402 // the description as a focusable word-wrapped text box. its hidden 403 // border reserves the space the border occupies when focused, so 404 // focusing doesn't shift layout. 405 { 406 var tb = try wgt.TextBox(ui.Widget).init(allocator, entry.issue.description, .{ .border_style = .hidden, .rounded_corners = true, .wrap_kind = .word }); 407 errdefer tb.deinit(allocator); 408 tb.getFocus().focusable = true; 409 try inner.children.put(allocator, tb.getFocus().id, .{ .widget = .{ .text_box = tb }, .rect = null, .min_size = null }); 410 } 411 412 // point the pane at its first row so focus recovery can land here. 413 inner.getFocus().child_id = inner.children.keys()[0]; 414 415 // reset the scroll to the top for the newly-shown issue: directly on the 416 // terminal (the wasm offset), and via a version bump on the web (so the 417 // renderer's scroll id changes and JS drops the preserved position). 418 const sc = self.detailScroll(); 419 sc.x = 0; 420 sc.y = 0; 421 sc.getFocus().version +%= 1; 422 } 423 424 pub fn input(self: *View, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 425 _ = allocator; 426 if (self.detailActive()) { 427 try self.detailInput(key, root_focus); 428 } else { 429 try self.listInput(key, root_focus); 430 } 431 } 432 433 fn listInput(self: *View, key: Key, root_focus: *Focus) !void { 434 // up/down (and the scroll wheel) move the selection a row; page up/down 435 // jump a fixed amount. right/Enter cross into the detail pane. 436 if (inp.rowDelta(key, @intCast(self.listBox().children.count()))) |delta| { 437 ui.moveRowFocus(self.listBox(), self.listScroll(), root_focus, delta); 438 return; 439 } 440 switch (key) { 441 .enter, .arrow_right => try self.focusDetail(root_focus), 442 else => {}, 443 } 444 } 445 446 fn detailInput(self: *View, key: Key, root_focus: *Focus) !void { 447 if (self.tagsFocused()) { 448 try self.tagsInput(key, root_focus); 449 } else { 450 try self.descriptionInput(key, root_focus); 451 } 452 } 453 454 fn descriptionInput(self: *View, key: Key, root_focus: *Focus) !void { 455 const sc = self.detailScroll(); 456 switch (key) { 457 .arrow_left => return self.focusList(root_focus), 458 // once the scroll can't move further, cross into the tags. 459 .arrow_up => { 460 const before = sc.y; 461 sc.y -= 1; 462 sc.clampToContent(); 463 if (sc.y == before) try self.focusTags(root_focus); 464 return; 465 }, 466 .arrow_down => sc.y += 1, 467 .page_up => sc.y -= 10, 468 .page_down => sc.y += 10, 469 .home => sc.y = 0, 470 .end => sc.y = std.math.maxInt(isize), 471 .mouse => |mouse| switch (mouse.action) { 472 .scroll => |dir| sc.y += if (dir == .up) @as(isize, -1) else 1, 473 else => {}, 474 }, 475 else => return, 476 } 477 sc.clampToContent(); 478 } 479 480 // arrow keys move the tag selection; at the flow's edges focus crosses to 481 // the neighboring widgets. 482 fn tagsInput(self: *View, key: Key, root_focus: *Focus) !void { 483 const tf = self.tagFlow() orelse return; 484 const cid = tf.focus.child_id orelse return; 485 const cur = tf.indexOfFocusId(cid) orelse return; 486 const count = tf.text_boxes.items.len; 487 const sc = self.detailScroll(); 488 switch (key) { 489 .arrow_left => if (cur > 0) self.focusTag(tf, root_focus, cur - 1) else try self.focusList(root_focus), 490 .arrow_right => if (cur + 1 < count) self.focusTag(tf, root_focus, cur + 1), 491 .arrow_up => if (tf.rowStep(cur, false)) |i| self.focusTag(tf, root_focus, i), 492 .arrow_down => if (tf.rowStep(cur, true)) |i| self.focusTag(tf, root_focus, i) else try self.focusDescription(root_focus), 493 .home => self.focusTag(tf, root_focus, 0), 494 .end => self.focusTag(tf, root_focus, count - 1), 495 .mouse => |mouse| switch (mouse.action) { 496 .scroll => |dir| { 497 sc.y += if (dir == .up) @as(isize, -1) else 1; 498 sc.clampToContent(); 499 }, 500 else => {}, 501 }, 502 else => {}, 503 } 504 } 505 506 const tags_child_index: usize = 0; 507 508 fn tagFlow(self: *View) ?*ui.TagFlow { 509 const inner = self.detailInner(); 510 if (inner.children.count() == 0) return null; 511 return switch (inner.children.values()[tags_child_index].widget) { 512 .tag_flow => |*tf| tf, 513 else => null, 514 }; 515 } 516 517 fn tagsFocused(self: *View) bool { 518 const inner = self.detailInner(); 519 const cid = inner.getFocus().child_id orelse return false; 520 return inner.children.getIndex(cid) == tags_child_index and self.tagFlow() != null; 521 } 522 523 fn focusTag(self: *View, tf: *ui.TagFlow, root_focus: *Focus, index: usize) void { 524 root_focus.setFocus(tf.text_boxes.items[index].getFocus().id); 525 // keep the tag visible on the terminal: its rect offset by the flow's 526 // position in the pane. 527 if (self.session.is_terminal and index < tf.rects.items.len) { 528 if (self.detailInner().children.values()[tags_child_index].rect) |flow_rect| { 529 var rect = tf.rects.items[index]; 530 rect.x += flow_rect.x; 531 rect.y += flow_rect.y; 532 self.detailScroll().scrollToRect(rect); 533 } 534 } 535 } 536 537 fn focusTags(self: *View, root_focus: *Focus) !void { 538 const tf = self.tagFlow() orelse return; 539 if (tf.text_boxes.items.len == 0) return; 540 const cid = tf.focus.child_id orelse tf.text_boxes.items[0].getFocus().id; 541 const index = tf.indexOfFocusId(cid) orelse 0; 542 self.focusTag(tf, root_focus, index); 543 } 544 545 fn focusDescription(self: *View, root_focus: *Focus) !void { 546 const inner = self.detailInner(); 547 const index: usize = if (self.tagFlow() != null) 1 else 0; 548 if (inner.children.count() <= index) return; 549 root_focus.setFocus(inner.children.keys()[index]); 550 } 551 552 // enter the detail pane. an empty pane (no issues) can't be entered. 553 fn focusDetail(self: *View, root_focus: *Focus) !void { 554 if (self.detailInner().children.count() == 0) return; 555 root_focus.setFocus(self.detailOuter().getFocus().id); 556 } 557 558 // return to the list. 559 fn focusList(self: *View, root_focus: *Focus) !void { 560 root_focus.setFocus(self.listScroll().getFocus().id); 561 } 562 563 pub fn clearGrid(self: *View) void { 564 self.box.clearGrid(); 565 } 566 567 pub fn getGrid(self: View) ?Grid { 568 return self.box.getGrid(); 569 } 570 571 pub fn getFocus(self: *View) *Focus { 572 return self.box.getFocus(); 573 } 574 575 // for the parent's "scroll up at the top jumps to the header" check. when 576 // the list holds focus, report its selected row directly. 577 pub fn getSelectedIndex(self: *View) ?usize { 578 if (self.detailActive()) { 579 return if (self.detailScroll().y == 0 and self.paneTopFocused()) 0 else 1; 580 } 581 const lb = self.listBox(); 582 const cid = lb.getFocus().child_id orelse return null; 583 return lb.children.getIndex(cid); 584 } 585 586 // whether the focused element is the detail pane's topmost one, so up has 587 // nowhere further to go within the pane. 588 fn paneTopFocused(self: *View) bool { 589 const tf = self.tagFlow() orelse return true; 590 if (!self.tagsFocused()) return false; 591 const cid = tf.focus.child_id orelse return true; 592 const cur = tf.indexOfFocusId(cid) orelse return true; 593 return tf.rowStep(cur, false) == null; 594 } 595 }; 596 597 // the "a:" navigation link for the issues page filtered to the url-encoded 598 // `tag` and rooted at issue `id` within `identity` ("owner/name"). 599 fn issuesLink(page_arena: *std.heap.ArenaAllocator, identity: []const u8, tag: []const u8, id: []const u8) ![]const u8 { 600 const route = ui.RoutablePage.repoIssuesRoute(identity, tag, id) orelse return error.RouteTooLong; 601 const url = try route.urlAlloc(page_arena); 602 return std.fmt.allocPrint(page_arena.allocator(), "a:{s}", .{url}); 603 } 604 605 // the in-page "ai:" anchor for selecting issue `id` in `identity`'s list; the 606 // href is only followed with js off. 607 fn issueRowLink(page_arena: *std.heap.ArenaAllocator, identity: []const u8, id: []const u8) ![]const u8 { 608 const route = ui.RoutablePage.repoIssuesRoute(identity, "", id) orelse return error.RouteTooLong; 609 const url = try route.urlAlloc(page_arena); 610 return std.fmt.allocPrint(page_arena.allocator(), "ai:{s}", .{url}); 611 } 612 613 // the "a:" link to the issues list filtered to `tag` (raw; encoded here). 614 fn tagLink(page_arena: *std.heap.ArenaAllocator, identity: []const u8, tag: []const u8) ![]const u8 { 615 const encoded = try ui.urlEncodeRef(page_arena.allocator(), tag); 616 const route = ui.RoutablePage.repoIssuesRoute(identity, encoded, "") orelse return error.RouteTooLong; 617 const url = try route.urlAlloc(page_arena); 618 return std.fmt.allocPrint(page_arena.allocator(), "a:{s}", .{url}); 619 } 620