╭ changed ─────────────╮ │src/ui/Repo/Issues.zig│ ╰──────────────────────╯ ╭───────────╮ │← all files│ ╰───────────╯ ╭──────────╮ │← previous│ ╰──────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │217 var list_box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); │ │218 errdefer list_box.deinit(allocator); │ │219 if (data.prev_id) |prev| │ │198 - try addRow(allocator, &list_box, "← previous", try issuesLink(session.page_arena, data.identity, prev)); │ │220 + try addRow(allocator, &list_box, "← previous", try issuesLink(session.page_arena, data.identity, data.tag, prev)); │ │221 for (data.issues) |entry| │ │222 try addRow(allocator, &list_box, entry.issue.title, try issueRowLink(session.page_arena, data.identity, entry.id));│ │223 if (data.next_id) |next| │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │202 - try addRow(allocator, &list_box, "next →", try issuesLink(session.page_arena, data.identity, next)); │ │224 + try addRow(allocator, &list_box, "next →", try issuesLink(session.page_arena, data.identity, data.tag, next));│ │225 // select the window's first issue (past a leading "previous" │ │226 // row) so its description shows on load. │ │227 if (data.issues.len > 0) │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────╮ │325 // mirror the focused issue into the url so it updates as the selection │ │326 // moves, but only while focus is inside this view (the list or detail).│ │327 // when focus sits on the header tab, use the page's base /issues route.│ │328 + // an issue's url is the same whether or not the list is filtered, so │ │329 + // the mirror drops the tag. │ │330 if (root_focus.grandchild_id) |g| { │ │331 if (self.box.getFocus().children.contains(g)) { │ │332 if (self.selectedIssueIndex()) |sel| { │ ╰─────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │309 - if (ui.RoutablePage.repoIssuesRoute(self.data.identity, self.data.issues[sel].id)) |route| │ │333 + if (ui.RoutablePage.repoIssuesRoute(self.data.identity, "", self.data.issues[sel].id)) |route|│ │334 self.session.data.current_page = route; │ │335 } │ │336 } │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │399 try inner.children.put(allocator, tb.getFocus().id, .{ .widget = .{ .text_box = tb }, .rect = null, .min_size = null }); │ │400 } │ │401 │ │402 + // the issue's tags flow under the description like a paragraph; each │ │403 + // links to the list filtered to that tag. │ │404 + { │ │405 + var items: std.ArrayList(ui.TagFlow.Item) = .empty; │ │406 + defer items.deinit(allocator); │ │407 + var tag_iter = evt.Issue.tagIterator(entry.issue.tags); │ │408 + while (tag_iter.next()) |tag| { │ │409 + if (tag.len == 0) continue; │ │410 + try items.append(allocator, .{ .text = tag, .link = try tagLink(self.session.page_arena, self.data.identity, tag) }); │ │411 + } │ │412 + if (items.items.len > 0) { │ │413 + var tf = try ui.TagFlow.init(allocator); │ │414 + errdefer tf.deinit(allocator); │ │415 + try tf.setItems(allocator, items.items); │ │416 + try inner.children.put(allocator, tf.getFocus().id, .{ .widget = .{ .tag_flow = tf }, .rect = null, .min_size = null });│ │417 + } │ │418 + } │ │419 + │ │420 // point the pane at its row so focus recovery can land here. │ │421 inner.getFocus().child_id = inner.children.keys()[0]; │ │422 │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────╮ │452 } │ │453 │ │454 fn detailInput(self: *View, key: Key, root_focus: *Focus) !void { │ │455 + if (self.tagsFocused()) { │ │456 + try self.tagsInput(key, root_focus); │ │457 + } else { │ │458 + try self.descriptionInput(key, root_focus); │ │459 + } │ │460 + } │ │461 + │ │462 + fn descriptionInput(self: *View, key: Key, root_focus: *Focus) !void { │ │463 const sc = self.detailScroll(); │ │464 switch (key) { │ │415 - .arrow_left => try self.focusList(root_focus), │ │465 + .arrow_left => return self.focusList(root_focus), │ │466 .arrow_up => sc.y -= 1, │ │417 - .arrow_down => sc.y += 1, │ │467 + // scroll through the description; once it can't scroll further│ │468 + // (always, on the web's native scroll), move into the tags. │ │469 + .arrow_down => { │ │470 + const before = sc.y; │ │471 + sc.y += 1; │ │472 + sc.clampToContent(); │ │473 + if (sc.y == before) try self.focusTags(root_focus); │ │474 + return; │ │475 + }, │ │476 .page_up => sc.y -= 10, │ │477 .page_down => sc.y += 10, │ │478 .home => sc.y = 0, │ ╰────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │484 else => return, │ │485 } │ │486 sc.clampToContent(); │ │487 + } │ │488 + │ │489 + // left/right step through the tags (left exits to the list at the first); │ │490 + // up/down move to the first tag of the adjacent row, up escaping to the │ │491 + // description from the top row. │ │492 + fn tagsInput(self: *View, key: Key, root_focus: *Focus) !void { │ │493 + const tf = self.tagFlow() orelse return; │ │494 + const cid = tf.focus.child_id orelse return; │ │495 + const cur = tf.indexOfFocusId(cid) orelse return; │ │496 + const count = tf.text_boxes.items.len; │ │497 + const sc = self.detailScroll(); │ │498 + switch (key) { │ │499 + .arrow_left => if (cur > 0) self.focusTag(tf, root_focus, cur - 1) else try self.focusList(root_focus), │ │500 + .arrow_right => if (cur + 1 < count) self.focusTag(tf, root_focus, cur + 1), │ │501 + .arrow_up => if (tf.rowStep(cur, false)) |i| self.focusTag(tf, root_focus, i) else try self.focusDescription(root_focus),│ │502 + .arrow_down => if (tf.rowStep(cur, true)) |i| self.focusTag(tf, root_focus, i), │ │503 + .home => self.focusTag(tf, root_focus, 0), │ │504 + .end => self.focusTag(tf, root_focus, count - 1), │ │505 + .mouse => |mouse| switch (mouse.action) { │ │506 + .scroll => |dir| { │ │507 + sc.y += if (dir == .up) @as(isize, -1) else 1; │ │508 + sc.clampToContent(); │ │509 + }, │ │510 + else => {}, │ │511 + }, │ │512 + else => {}, │ │513 + } │ │514 + } │ │515 + │ │516 + const tags_child_index: usize = 1; │ │517 + │ │518 + fn tagFlow(self: *View) ?*ui.TagFlow { │ │519 + const inner = self.detailInner(); │ │520 + if (inner.children.count() <= tags_child_index) return null; │ │521 + return switch (inner.children.values()[tags_child_index].widget) { │ │522 + .tag_flow => |*tf| tf, │ │523 + else => null, │ │524 + }; │ │525 + } │ │526 + │ │527 + fn tagsFocused(self: *View) bool { │ │528 + const inner = self.detailInner(); │ │529 + const cid = inner.getFocus().child_id orelse return false; │ │530 + return inner.children.getIndex(cid) == tags_child_index; │ │531 + } │ │532 + │ │533 + fn focusTag(self: *View, tf: *ui.TagFlow, root_focus: *Focus, index: usize) void { │ │534 + root_focus.setFocus(tf.text_boxes.items[index].getFocus().id); │ │535 + // keep the tag visible on the terminal: its rect offset by the flow's │ │536 + // position in the pane. │ │537 + if (self.session.is_terminal and index < tf.rects.items.len) { │ │538 + if (self.detailInner().children.values()[tags_child_index].rect) |flow_rect| { │ │539 + var rect = tf.rects.items[index]; │ │540 + rect.x += flow_rect.x; │ │541 + rect.y += flow_rect.y; │ │542 + self.detailScroll().scrollToRect(rect); │ │543 + } │ │544 + } │ │545 } │ │546 │ │547 + fn focusTags(self: *View, root_focus: *Focus) !void { │ │548 + const tf = self.tagFlow() orelse return; │ │549 + if (tf.text_boxes.items.len == 0) return; │ │550 + const cid = tf.focus.child_id orelse tf.text_boxes.items[0].getFocus().id; │ │551 + const index = tf.indexOfFocusId(cid) orelse 0; │ │552 + self.focusTag(tf, root_focus, index); │ │553 + } │ │554 + │ │555 + fn focusDescription(self: *View, root_focus: *Focus) !void { │ │556 + const inner = self.detailInner(); │ │557 + if (inner.children.count() == 0) return; │ │558 + root_focus.setFocus(inner.children.keys()[0]); │ │559 + } │ │560 + │ │561 // enter the detail pane. an empty pane (no issues) can't be entered. │ │562 fn focusDetail(self: *View, root_focus: *Focus) !void { │ │563 if (self.detailInner().children.count() == 0) return; │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────╮ │582 } │ │583 │ │584 // for the parent's "scroll up at the top jumps to the header" check. in the│ │455 - // detail pane that means it can't scroll up any further; otherwise up-arrow│ │456 - // stays in the pane. when the list holds focus, report its selected row │ │457 - // directly (window-navigation rows included). │ │585 + // detail pane that means the description is focused and can't scroll up any│ │586 + // further; up from the tags moves to the description instead. when the list│ │587 + // holds focus, report its selected row directly (window-navigation rows │ │588 + // included). │ │589 pub fn getSelectedIndex(self: *View) ?usize { │ │590 if (self.detailActive()) { │ │591 + if (self.tagsFocused()) return 1; │ │592 return if (self.detailScroll().y == 0) 0 else 1; │ │593 } │ │594 const lb = self.listBox(); │ ╰─────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │597 } │ │598 }; │ │599 │ │468 -// the "a:" navigation link for the issues page rooted at issue `id` within │ │469 -// `identity` ("owner/name"). │ │470 -fn issuesLink(page_arena: *std.heap.ArenaAllocator, identity: []const u8, id: []const u8) ![]const u8 { │ │471 - const route = ui.RoutablePage.repoIssuesRoute(identity, id) orelse return error.RouteTooLong; │ │600 +// the "a:" navigation link for the issues page filtered to the url-encoded │ │601 +// `tag` and rooted at issue `id` within `identity` ("owner/name"). │ │602 +fn issuesLink(page_arena: *std.heap.ArenaAllocator, identity: []const u8, tag: []const u8, id: []const u8) ![]const u8 {│ │603 + const route = ui.RoutablePage.repoIssuesRoute(identity, tag, id) orelse return error.RouteTooLong; │ │604 const url = try route.urlAlloc(page_arena); │ │605 return std.fmt.allocPrint(page_arena.allocator(), "a:{s}", .{url}); │ │606 } │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │608 // the in-page "ai:" anchor for selecting issue `id` in `identity`'s list; the │ │609 // href is only followed with js off. │ │610 fn issueRowLink(page_arena: *std.heap.ArenaAllocator, identity: []const u8, id: []const u8) ![]const u8 {│ │479 - const route = ui.RoutablePage.repoIssuesRoute(identity, id) orelse return error.RouteTooLong; │ │611 + const route = ui.RoutablePage.repoIssuesRoute(identity, "", id) orelse return error.RouteTooLong; │ │612 const url = try route.urlAlloc(page_arena); │ │613 return std.fmt.allocPrint(page_arena.allocator(), "ai:{s}", .{url}); │ │614 } │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯