╭─────────────╮ │scroll to top│ ╰─────────────╯ ╭ Issues.zig ───────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
204 const detail_index: usize = 1; 205 const list_max_width: usize = 40; 206 const detail_min_width: usize = 40; 207 208 pub fn init(allocator: std.mem.Allocator, data: *const Self, session: *ui.Session) !View { 209 var outer = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); 210 errdefer outer.deinit(allocator); 211 212 // the tabs at the top. 213 { 214 var sub_header = try SubHeader.View.init(allocator, session, data.count); 215 errdefer sub_header.deinit(allocator); 216 try outer.children.put(allocator, sub_header.getFocus().id, .{ .widget = .{ .repo_issues_sub_header = sub_header }, .rect = null, .min_size = null }); 217 } 218 219 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .horiz }); 220 errdefer box.deinit(allocator); 221 222 // the issue list (one focusable row per title), plus a "next" link that 223 // reloads the page rooted at the following issue. 224 { 225 var list_scroll = blk: { 226 var list_box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); 227 errdefer list_box.deinit(allocator); 228 if (data.prev_id) |prev| 229 try addRow(allocator, &list_box, "← previous", try issuesLink(session.page_arena, data.identity, data.tag, prev)); 230 for (data.issues) |entry| 231 try addRow(allocator, &list_box, entry.issue.title, try issueRowLink(session.page_arena, data.identity, entry.id)); 232 if (data.next_id) |next| 233 try addRow(allocator, &list_box, "next →", try issuesLink(session.page_arena, data.identity, data.tag, next)); 234 // select the window's first issue (past a leading "previous" 235 // row) so its description shows on load. 236 if (data.issues.len > 0) 237 list_box.getFocus().child_id = list_box.children.keys()[if (data.prev_id != null) 1 else 0] 238 else if (list_box.children.count() > 0) 239 list_box.getFocus().child_id = list_box.children.keys()[0]; 240 break :blk try wgt.Scroll(ui.Widget).init(allocator, .{ .box = list_box }, .{ .direction = .vert, .web_native = !session.is_terminal }); 241 }; 242 errdefer list_scroll.deinit(allocator); 243 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 } }); 244 } 245 246 // the detail pane — a frame around a scroll of the description 247 { 248 var detail_outer = blk: { 249 var detail_scroll = blk2: { 250 var detail_inner = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); 251 errdefer detail_inner.deinit(allocator); 252 // fill the pane (content top-left, scroll bar pinned to the 253 // edge) rather than shrinking to the description. 254 break :blk2 try wgt.Scroll(ui.Widget).init(allocator, .{ .box = detail_inner }, .{ .direction = .vert, .web_native = !session.is_terminal, .fill = true }); 255 }; 256 errdefer detail_scroll.deinit(allocator); 257 var frame = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = .hidden, .direction = .vert }); 258 errdefer frame.deinit(allocator); 259 // the frame's selected child is its scroll, so the focus chain 260 // reaches the description (populateDetail points the scroll's 261 // inner box at it), letting focus recovery descend into the pane. 262 frame.getFocus().child_id = detail_scroll.getFocus().id; 263 try frame.children.put(allocator, detail_scroll.getFocus().id, .{ .widget = .{ .scroll = detail_scroll }, .rect = null, .min_size = null }); 264 break :blk frame; 265 }; 266 errdefer detail_outer.deinit(allocator); 267 try box.children.put(allocator, detail_outer.getFocus().id, .{ .widget = .{ .box = detail_outer }, .rect = null, .min_size = .{ .width = detail_min_width, .height = null } }); 268 } 269 270 box.getFocus().child_id = box.children.keys()[list_index]; 271 272 // the split sits alone in a stack so future tabs can swap other views 273 // in. the stack enters `outer` before the split enters the stack, so an 274 // error frees the split exactly once. 275 { 276 var stack = try wgt.Stack(ui.Widget).init(allocator); 277 errdefer stack.deinit(allocator); 278 try outer.children.put(allocator, stack.getFocus().id, .{ .widget = .{ .stack = stack }, .rect = null, .min_size = null }); 279 } 280 const stack = &outer.children.values()[stack_index].widget.stack; 281 stack.getFocus().child_id = box.getFocus().id; 282 try stack.children.put(allocator, box.getFocus().id, .{ .box = box }); 283 284 // focus entering the view lands on the tabs first. 285 outer.getFocus().child_id = outer.children.keys()[sub_header_index]; 286 287 return .{ 288 .box = outer, 289 .data = data, 290 .session = session, 291 .detailed_index = null, 292 }; 293 } 294 295 fn addRow(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), label: []const u8, link: []const u8) !void { 296 var row = try wgt.TextBox(ui.Widget).init(allocator, label, .{ .border_style = .hidden, .rounded_corners = true, .wrap_kind = .word }); 297 errdefer row.deinit(allocator); 298 row.getFocus().focusable = true; 299 if (link.len != 0) row.getFocus().kind = .{ .custom = link }; 300 try box.children.put(allocator, row.getFocus().id, .{ .widget = .{ .text_box = row }, .rect = null, .min_size = null, .max_size = .{ .width = null, .height = 5 } }); 301 } 302 303 pub fn deinit(self: *View, allocator: std.mem.Allocator) void { 304 self.box.deinit(allocator); 305 } 306 307 fn subHeader(self: *View) *SubHeader.View { 308 return &self.box.children.values()[sub_header_index].widget.repo_issues_sub_header; 309 } 310 311 // the master-detail split inside the stack. 312 fn resultsBox(self: *View) *wgt.Box(ui.Widget) { 313 return &self.box.children.values()[stack_index].widget.stack.children.values()[0].box; 314 } 315 316 fn listScroll(self: *View) *wgt.Scroll(ui.Widget) { 317 return &self.resultsBox().children.values()[list_index].widget.scroll; 318 } 319 320 fn listBox(self: *View) *wgt.Box(ui.Widget) { 321 return &self.listScroll().child.box; 322 } 323 324 fn detailOuter(self: *View) *wgt.Box(ui.Widget) { 325 return &self.resultsBox().children.values()[detail_index].widget.box; 326 } 327 328 fn detailScroll(self: *View) *wgt.Scroll(ui.Widget) { 329 return &self.detailOuter().children.values()[0].widget.scroll; 330 } 331 332 fn detailInner(self: *View) *wgt.Box(ui.Widget) { 333 return &self.detailScroll().child.box; 334 } 335 336 fn detailActive(self: *View) bool { 337 const rb = self.resultsBox(); 338 const cid = rb.getFocus().child_id orelse return false; 339 return rb.children.getIndex(cid) == detail_index; 340 } 341 342 fn subHeaderActive(self: *View) bool { 343 const cid = self.box.getFocus().child_id orelse return false; 344 return self.box.children.getIndex(cid) == sub_header_index; 345 } 346 347 // the selected issue's index, or null when a window-navigation row is 348 // selected (a leading "previous" row shifts the issue rows down by one). 349 fn selectedIssueIndex(self: *View) ?usize { 350 const lb = self.listBox(); 351 const cid = lb.getFocus().child_id orelse return null; 352 const idx = lb.children.getIndex(cid) orelse return null; 353 const lead: usize = if (self.data.prev_id != null) 1 else 0; 354 if (idx < lead or idx - lead >= self.data.issues.len) return null; 355 return idx - lead; 356 } 357 358 pub fn build(self: *View, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 359 self.clearGrid(); 360 361 // swap the detail pane to the selected issue when it changes. 362 try self.refreshDetail(allocator); 363 364 // mirror the focused issue into the url so it updates as the selection 365 // moves, but only while focus is inside this view (the list or detail). 366 // when focus sits on the header tab, use the page's base /issues route. 367 // an issue's url is the same whether or not the list is filtered, so 368 // the mirror drops the tag. 369 if (root_focus.grandchild_id) |g| { 370 if (self.resultsBox().getFocus().children.contains(g)) { 371 if (self.selectedIssueIndex()) |sel| { 372 if (ui.RoutablePage.repoIssuesRoute(self.data.identity, "", self.data.issues[sel].id)) |route| 373 self.session.data.current_page = route; 374 } 375 } 376 } 377 378 // the selected list row shows a border (the focused TextBox upgrades it 379 // to a double border itself); the rest stay borderless. 380 const lb = self.listBox(); 381 for (lb.children.keys(), lb.children.values()) |id, *child| { 382 switch (child.widget) { 383 .text_box => |*tb| tb.options.border_style = if (lb.getFocus().child_id == id) .single else .hidden, 384 else => {}, 385 } 386 } 387 388 // the pane's selected child shows the selection border: the description 389 // directly, the tags via the flow's selected item. 390 const inner = self.detailInner(); 391 for (inner.children.keys(), inner.children.values()) |id, *child| { 392 switch (child.widget) { 393 .text_box => |*tb| tb.options.border_style = if (inner.getFocus().child_id == id) .single else .hidden, 394 else => {}, 395 } 396 } 397 if (self.tagFlow()) |tf| tf.selected = self.tagsFocused(); 398 399 // cap the list at list_max_width only while the detail pane fits beside 400 // it. the box drops the detail when the width can't hold both minimums, 401 // so when it's that narrow we lift the cap and let the list fill the 402 // whole width. 403 const both_panes_fit = if (constraint.max_size.width) |w| w >= list_max_width + detail_min_width else true; 404 self.resultsBox().children.values()[list_index].max_size = if (both_panes_fit) .{ .width = list_max_width, .height = null } else null; 405 406 // stretch the detail pane across the rest of the width so it fills the 407 // area rather than shrinking to its content; its scroll fills the pane. 408 if (constraint.max_size.width) |w| { 409 self.resultsBox().children.values()[detail_index].min_size = .{ .width = if (both_panes_fit) w - list_max_width else w, .height = null }; 410 } else { 411 self.resultsBox().children.values()[detail_index].min_size = .{ .width = detail_min_width, .height = null }; 412 } 413 414 try self.box.build(allocator, constraint, root_focus); 415 } 416 417 fn refreshDetail(self: *View, allocator: std.mem.Allocator) !void { 418 const sel = self.selectedIssueIndex() orelse return; 419 if (self.detailed_index) |d| if (d == sel) return; 420 try self.populateDetail(allocator, sel); 421 self.detailed_index = sel; 422 } 423 424 fn populateDetail(self: *View, allocator: std.mem.Allocator, sel: usize) !void { 425 const entry = self.data.issues[sel]; 426 const inner = self.detailInner(); 427 428 for (inner.children.values()) |*child| child.widget.deinit(allocator); 429 inner.children.clearAndFree(allocator); 430 inner.getFocus().child_id = null; 431 432 // the issue's tags, each linking to the list filtered to that tag. 433 { 434 var items: std.ArrayList(ui.TagFlow.Item) = .empty; 435 defer items.deinit(allocator); 436 var tag_iter = evt.Issue.tagIterator(entry.issue.tags); 437 while (tag_iter.next()) |tag| { 438 if (tag.len == 0) continue; 439 try items.append(allocator, .{ .text = tag, .link = try tagLink(self.session.page_arena, self.data.identity, tag) }); 440 } 441 if (items.items.len > 0) { 442 var tf = try ui.TagFlow.init(allocator); 443 errdefer tf.deinit(allocator); 444 try tf.setItems(allocator, items.items); 445 try inner.children.put(allocator, tf.getFocus().id, .{ .widget = .{ .tag_flow = tf }, .rect = null, .min_size = null }); 446 } 447 } 448 449 // the description as a focusable word-wrapped text box. its hidden 450 // border reserves the space the border occupies when focused, so 451 // focusing doesn't shift layout. 452 { 453 var tb = try wgt.TextBox(ui.Widget).init(allocator, entry.issue.description, .{ .border_style = .hidden, .rounded_corners = true, .wrap_kind = .word }); 454 errdefer tb.deinit(allocator); 455 tb.getFocus().focusable = true; 456 try inner.children.put(allocator, tb.getFocus().id, .{ .widget = .{ .text_box = tb }, .rect = null, .min_size = null }); 457 } 458 459 // select the description by default 460 inner.getFocus().child_id = inner.children.keys()[inner.children.count() - 1]; 461 462 // reset the scroll to the top for the newly-shown issue: directly on the 463 // terminal (the wasm offset), and via a version bump on the web (so the 464 // renderer's scroll id changes and JS drops the preserved position). 465 const sc = self.detailScroll(); 466 sc.x = 0; 467 sc.y = 0; 468 sc.getFocus().version +%= 1; 469 } 470 471 pub fn input(self: *View, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 472 if (self.subHeaderActive()) { 473 // down from the tabs re-enters the split; other keys move the tabs. 474 if (inp.vertDirection(key) == .down) { 475 root_focus.setFocus(self.box.children.keys()[stack_index]); 476 } else { 477 try self.subHeader().input(allocator, key, root_focus); 478 } 479 return; 480 } 481 if (self.detailActive()) { 482 try self.detailInput(key, root_focus); 483 } else { 484 try self.listInput(key, root_focus); 485 } 486 } 487 488 fn listInput(self: *View, key: Key, root_focus: *Focus) !void { 489 // up/down (and the scroll wheel) move the selection a row; page up/down 490 // jump a fixed amount. right/Enter cross into the detail pane. up from 491 // the top row crosses into the sub header tabs. 492 if (inp.rowDelta(key, @intCast(self.listBox().children.count()))) |delta| { 493 const lb = self.listBox(); 494 const at_top = if (lb.getFocus().child_id) |cid| lb.children.getIndex(cid) == 0 else true; 495 if (delta < 0 and at_top) return self.focusSubHeader(root_focus); 496 ui.moveRowFocus(lb, self.listScroll(), root_focus, delta); 497 return; 498 } 499 switch (key) { 500 .enter, .arrow_right => try self.focusDetail(root_focus), 501 else => {}, 502 } 503 } 504 505 fn detailInput(self: *View, key: Key, root_focus: *Focus) !void { 506 if (self.tagsFocused()) { 507 try self.tagsInput(key, root_focus); 508 } else { 509 try self.descriptionInput(key, root_focus); 510 } 511 } 512 513 fn descriptionInput(self: *View, key: Key, root_focus: *Focus) !void { 514 const sc = self.detailScroll(); 515 switch (key) { 516 .arrow_left => return self.focusList(root_focus), 517 // once the scroll can't move further, cross into the tags (or the 518 // sub header tabs when the issue has none). 519 .arrow_up => { 520 const before = sc.y; 521 sc.y -= 1; 522 sc.clampToContent(); 523 if (sc.y == before) { 524 if (self.tagFlow() != null) try self.focusTags(root_focus) else self.focusSubHeader(root_focus); 525 } 526 return; 527 }, 528 .arrow_down => sc.y += 1, 529 .page_up => sc.y -= 10, 530 .page_down => sc.y += 10, 531 .home => sc.y = 0, 532 .end => sc.y = std.math.maxInt(isize), 533 .mouse => |mouse| switch (mouse.action) { 534 .scroll => |dir| sc.y += if (dir == .up) @as(isize, -1) else 1, 535 else => {}, 536 }, 537 else => return, 538 } 539 sc.clampToContent(); 540 } 541 542 // arrow keys move the tag selection; at the flow's edges focus crosses to 543 // the neighboring widgets. 544 fn tagsInput(self: *View, key: Key, root_focus: *Focus) !void { 545 const tf = self.tagFlow() orelse return; 546 const cid = tf.focus.child_id orelse return; 547 const cur = tf.indexOfFocusId(cid) orelse return; 548 const count = tf.text_boxes.items.len; 549 const sc = self.detailScroll(); 550 switch (key) { 551 .arrow_left => if (cur > 0) self.focusTag(tf, root_focus, cur - 1) else try self.focusList(root_focus), 552 .arrow_right => if (cur + 1 < count) self.focusTag(tf, root_focus, cur + 1), 553 .arrow_up => if (tf.rowStep(cur, false)) |i| self.focusTag(tf, root_focus, i) else self.focusSubHeader(root_focus), 554 .arrow_down => if (tf.rowStep(cur, true)) |i| self.focusTag(tf, root_focus, i) else try self.focusDescription(root_focus), 555 .home => self.focusTag(tf, root_focus, 0), 556 .end => self.focusTag(tf, root_focus, count - 1), 557 .mouse => |mouse| switch (mouse.action) { 558 .scroll => |dir| { 559 sc.y += if (dir == .up) @as(isize, -1) else 1; 560 sc.clampToContent(); 561 }, 562 else => {}, 563 }, 564 else => {}, 565 } 566 } 567 568 const tags_child_index: usize = 0; 569 570 fn tagFlow(self: *View) ?*ui.TagFlow { 571 const inner = self.detailInner(); 572 if (inner.children.count() == 0) return null; 573 return switch (inner.children.values()[tags_child_index].widget) { 574 .tag_flow => |*tf| tf, 575 else => null, 576 }; 577 } 578 579 fn tagsFocused(self: *View) bool { 580 const inner = self.detailInner(); 581 const cid = inner.getFocus().child_id orelse return false; 582 return inner.children.getIndex(cid) == tags_child_index and self.tagFlow() != null; 583 } 584 585 fn focusTag(self: *View, tf: *ui.TagFlow, root_focus: *Focus, index: usize) void { 586 root_focus.setFocus(tf.text_boxes.items[index].getFocus().id); 587 // keep the tag visible on the terminal: its rect offset by the flow's 588 // position in the pane. 589 if (self.session.is_terminal and index < tf.rects.items.len) { 590 if (self.detailInner().children.values()[tags_child_index].rect) |flow_rect| { 591 var rect = tf.rects.items[index]; 592 rect.x += flow_rect.x; 593 rect.y += flow_rect.y; 594 self.detailScroll().scrollToRect(rect); 595 } 596 } 597 } 598 599 fn focusTags(self: *View, root_focus: *Focus) !void { 600 const tf = self.tagFlow() orelse return; 601 if (tf.text_boxes.items.len == 0) return; 602 const cid = tf.focus.child_id orelse tf.text_boxes.items[0].getFocus().id; 603 const index = tf.indexOfFocusId(cid) orelse 0; 604 self.focusTag(tf, root_focus, index); 605 } 606 607 fn focusDescription(self: *View, root_focus: *Focus) !void { 608 const inner = self.detailInner(); 609 const index: usize = if (self.tagFlow() != null) 1 else 0; 610 if (inner.children.count() <= index) return; 611 root_focus.setFocus(inner.children.keys()[index]); 612 } 613 614 // enter the detail pane. an empty pane (no issues) can't be entered. 615 fn focusDetail(self: *View, root_focus: *Focus) !void { 616 if (self.detailInner().children.count() == 0) return; 617 root_focus.setFocus(self.detailOuter().getFocus().id); 618 } 619 620 // return to the list. 621 fn focusList(self: *View, root_focus: *Focus) !void { 622 root_focus.setFocus(self.listScroll().getFocus().id); 623 } 624 625 // cross to the sub header tabs above the split. 626 fn focusSubHeader(self: *View, root_focus: *Focus) void { 627 root_focus.setFocus(self.box.children.keys()[sub_header_index]); 628 } 629 630 pub fn clearGrid(self: *View) void { 631 self.box.clearGrid(); 632 } 633 634 pub fn getGrid(self: View) ?Grid { 635 return self.box.getGrid(); 636 } 637 638 pub fn getFocus(self: *View) *Focus { 639 return self.box.getFocus(); 640 } 641 642 // for the parent's "scroll up at the top jumps to the header" check: at the 643 // top only while the sub header tabs hold focus, so up from the split 644 // crosses into the tabs first. 645 pub fn getSelectedIndex(self: *View) ?usize { 646 return if (self.subHeaderActive()) 0 else 1; 647 } 648 }; 649 650 // the "a:" navigation link for the issues page filtered to the url-encoded 651 // `tag` and rooted at issue `id` within `identity` ("owner/name"). 652 fn issuesLink(page_arena: *std.heap.ArenaAllocator, identity: []const u8, tag: []const u8, id: []const u8) ![]const u8 { 653 const route = ui.RoutablePage.repoIssuesRoute(identity, tag, id) orelse return error.RouteTooLong; 654 const url = try route.urlAlloc(page_arena); 655 return std.fmt.allocPrint(page_arena.allocator(), "a:{s}", .{url}); 656 } 657 658 // the in-page "ai:" anchor for selecting issue `id` in `identity`'s list; the 659 // href is only followed with js off. 660 fn issueRowLink(page_arena: *std.heap.ArenaAllocator, identity: []const u8, id: []const u8) ![]const u8 { 661 const route = ui.RoutablePage.repoIssuesRoute(identity, "", id) orelse return error.RouteTooLong; 662 const url = try route.urlAlloc(page_arena); 663 return std.fmt.allocPrint(page_arena.allocator(), "ai:{s}", .{url}); 664 } 665 666 // the "a:" link to the issues list filtered to `tag` (raw; encoded here). 667 fn tagLink(page_arena: *std.heap.ArenaAllocator, identity: []const u8, tag: []const u8) ![]const u8 { 668 const encoded = try ui.urlEncodeRef(page_arena.allocator(), tag); 669 const route = ui.RoutablePage.repoIssuesRoute(identity, encoded, "") orelse return error.RouteTooLong; 670 const url = try route.urlAlloc(page_arena); 671 return std.fmt.allocPrint(page_arena.allocator(), "a:{s}", .{url}); 672 } 673