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