╭──────────╮ │← previous│ ╰──────────╯ ╭ changed ─╮ │src/ui.zig│ ╰──────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │1452 return &self.scroll.child.flow_box; │ │1453 } │ │1454 }; │ │1455 +}; │ │1456 + │ │1457 +// a left-to-right flow of variable-width focusable items that wraps at the │ │1458 +// available width, like words in a paragraph. unlike FlowBox there is no cell │ │1459 +// grid: each item is as wide as its text. selection movement is driven by the │ │1460 +// owning view via indexOfFocusId/verticalNeighbor. │ │1461 +pub const TagFlow = struct { │ │1462 + focus: *Focus, │ │1463 + grid: ?Grid, │ │1464 + text_boxes: std.ArrayList(wgt.TextBox(Widget)), │ │1465 + // last build's item rects (content space), for vertical navigation and │ │1466 + // scroll-into-view. │ │1467 + rects: std.ArrayList(layout.IRect), │ │1468 + // backs the per-item strings the text boxes borrow. │ │1469 + arena: std.heap.ArenaAllocator, │ │1470 + │ │1471 + pub const Item = struct { text: []const u8, link: []const u8 = "" }; │ │1472 + │ │1473 + pub fn init(allocator: std.mem.Allocator) !TagFlow { │ │1474 + return .{ │ │1475 + .focus = try Focus.create(allocator, .container), │ │1476 + .grid = null, │ │1477 + .text_boxes = .empty, │ │1478 + .rects = .empty, │ │1479 + .arena = std.heap.ArenaAllocator.init(allocator), │ │1480 + }; │ │1481 + } │ │1482 + │ │1483 + pub fn deinit(self: *TagFlow, allocator: std.mem.Allocator) void { │ │1484 + self.focus.destroy(allocator); │ │1485 + if (self.grid) |*grid| { │ │1486 + grid.deinit(); │ │1487 + self.grid = null; │ │1488 + } │ │1489 + for (self.text_boxes.items) |*tb| tb.deinit(allocator); │ │1490 + self.text_boxes.deinit(allocator); │ │1491 + self.rects.deinit(allocator); │ │1492 + self.arena.deinit(); │ │1493 + } │ │1494 + │ │1495 + // both the item text and the link are copied into the arena, so the caller's │ │1496 + // slices needn't outlive this call. │ │1497 + pub fn setItems(self: *TagFlow, allocator: std.mem.Allocator, items: []const Item) !void { │ │1498 + for (self.text_boxes.items) |*tb| tb.deinit(allocator); │ │1499 + self.text_boxes.clearAndFree(allocator); │ │1500 + │ │1501 + _ = self.arena.reset(.retain_capacity); │ │1502 + const aa = self.arena.allocator(); │ │1503 + │ │1504 + self.focus.clear(); │ │1505 + self.focus.child_id = null; │ │1506 + │ │1507 + for (items) |item| { │ │1508 + const line = try aa.dupe(u8, item.text); │ │1509 + │ │1510 + var text_box = try wgt.TextBox(Widget).init(allocator, line, .{ .border_style = .hidden, .rounded_corners = true, .wrap_kind = .none });│ │1511 + errdefer text_box.deinit(allocator); │ │1512 + text_box.getFocus().focusable = true; │ │1513 + │ │1514 + if (item.link.len > 0) { │ │1515 + text_box.getFocus().kind = .{ .custom = try aa.dupe(u8, item.link) }; │ │1516 + } │ │1517 + │ │1518 + try self.text_boxes.append(allocator, text_box); │ │1519 + } │ │1520 + │ │1521 + if (self.text_boxes.items.len > 0) { │ │1522 + self.focus.child_id = self.text_boxes.items[0].getFocus().id; │ │1523 + } │ │1524 + } │ │1525 + │ │1526 + pub fn build(self: *TagFlow, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { │ │1527 + self.clearGrid(); │ │1528 + self.focus.clear(); │ │1529 + │ │1530 + if (self.text_boxes.items.len == 0) return; │ │1531 + const max_width = constraint.max_size.width orelse 80; │ │1532 + if (max_width == 0) return; │ │1533 + │ │1534 + // lay the items out left to right, wrapping to a new row when the next │ │1535 + // one wouldn't fit. │ │1536 + self.rects.clearRetainingCapacity(); │ │1537 + var x: usize = 0; │ │1538 + var y: usize = 0; │ │1539 + var row_height: usize = 0; │ │1540 + var total_width: usize = 0; │ │1541 + for (self.text_boxes.items) |*tb| { │ │1542 + tb.options.border_style = if (self.focus.child_id == tb.getFocus().id) .single else .hidden; │ │1543 + try tb.build(allocator, .{ │ │1544 + .min_size = .{ .width = null, .height = null }, │ │1545 + .max_size = .{ .width = max_width, .height = null }, │ │1546 + }, root_focus); │ │1547 + const tb_grid_maybe = tb.getGrid(); │ │1548 + const w: usize = if (tb_grid_maybe) |g| g.size.width else 0; │ │1549 + const h: usize = if (tb_grid_maybe) |g| g.size.height else 0; │ │1550 + if (x > 0 and x + w > max_width) { │ │1551 + x = 0; │ │1552 + y += row_height; │ │1553 + row_height = 0; │ │1554 + } │ │1555 + try self.rects.append(allocator, .{ .x = @intCast(x), .y = @intCast(y), .size = .{ .width = w, .height = h } }); │ │1556 + if (h > row_height) row_height = h; │ │1557 + x += w; │ │1558 + if (x > total_width) total_width = x; │ │1559 + } │ │1560 + │ │1561 + const total_height = y + row_height; │ │1562 + if (total_width == 0 or total_height == 0) return; │ │1563 + │ │1564 + var grid = try Grid.init(allocator, .{ .width = total_width, .height = total_height }); │ │1565 + errdefer grid.deinit(); │ │1566 + │ │1567 + for (self.text_boxes.items, self.rects.items) |*tb, rect| { │ │1568 + const tb_grid = tb.getGrid() orelse continue; │ │1569 + try self.focus.addChild(allocator, tb.getFocus(), tb_grid.size, @intCast(rect.x), @intCast(rect.y)); │ │1570 + try grid.drawGrid(tb_grid, @intCast(rect.x), @intCast(rect.y)); │ │1571 + } │ │1572 + │ │1573 + self.grid = grid; │ │1574 + } │ │1575 + │ │1576 + pub fn input(self: *TagFlow, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { │ │1577 + _ = self; │ │1578 + _ = allocator; │ │1579 + _ = key; │ │1580 + _ = root_focus; │ │1581 + } │ │1582 + │ │1583 + pub fn clearGrid(self: *TagFlow) void { │ │1584 + if (self.grid) |*grid| { │ │1585 + grid.deinit(); │ │1586 + self.grid = null; │ │1587 + } │ │1588 + for (self.text_boxes.items) |*tb| tb.clearGrid(); │ │1589 + } │ │1590 + │ │1591 + pub fn getGrid(self: TagFlow) ?Grid { │ │1592 + return self.grid; │ │1593 + } │ │1594 + │ │1595 + pub fn getFocus(self: *TagFlow) *Focus { │ │1596 + return self.focus; │ │1597 + } │ │1598 + │ │1599 + pub fn indexOfFocusId(self: TagFlow, focus_id: usize) ?usize { │ │1600 + for (self.text_boxes.items, 0..) |tb, i| { │ │1601 + if (tb.box.focus.id == focus_id) return i; │ │1602 + } │ │1603 + return null; │ │1604 + } │ │1605 + │ │1606 + // the first item of the row below (when `downward`) or above, or null at │ │1607 + // the flow's edge. items are laid out in order, so y is nondecreasing. │ │1608 + pub fn rowStep(self: TagFlow, index: usize, downward: bool) ?usize { │ │1609 + const rects = self.rects.items; │ │1610 + if (index >= rects.len) return null; │ │1611 + const cur_y = rects[index].y; │ │1612 + if (downward) { │ │1613 + for (rects[index + 1 ..], index + 1..) |r, i| { │ │1614 + if (r.y > cur_y) return i; │ │1615 + } │ │1616 + return null; │ │1617 + } │ │1618 + // walk the earlier rows; the last one visited is the row above. │ │1619 + var prev_first: ?usize = null; │ │1620 + var i: usize = 0; │ │1621 + while (i < rects.len and rects[i].y < cur_y) { │ │1622 + prev_first = i; │ │1623 + const y = rects[i].y; │ │1624 + while (i < rects.len and rects[i].y == y) i += 1; │ │1625 + } │ │1626 + return prev_first; │ │1627 + } │ │1628 }; │ │1629 │ │1630 // an invisible widget that fills the horizontal space granted by its parent. │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ──────╮ │src/ui/Repo.zig│ ╰───────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────╮ │108 .repo_refs => |r| r.after, │ │109 else => 0, │ │110 }; │ │111 - // the issue the issues tab's window is rooted at ("" = the first window).│ │111 + // the issues tab's tag filter and the issue its window is rooted at. │ │112 + const issues_tag: []const u8 = switch (route) { │ │113 + .repo_issues => |i| i.tag.slice(), │ │114 + else => "", │ │115 + }; │ │116 const issues_selected: []const u8 = switch (route) { │ │117 .repo_issues => |i| i.selected.slice(), │ │118 else => "", │ ╰───────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │146 return .{ │ │147 // files and commits resolve the same ref, so either's serves the header, │ │148 // which points both tabs at it. │ │145 - .header = try Header.init(arena, repo.name, owner.name, files.ref_or_oid, files.ref_or_oid_value), │ │149 + .header = try Header.init(arena, repo.name, owner.name, files.ref_or_oid, files.ref_or_oid_value, issues_tag),│ │150 .repo = repo, │ │151 .files = files, │ │152 .commits = commits, │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │153 .refs = try Refs.init(arena, session, &found.event_id, rf.identity, refs_kind, refs_after), │ │150 - .issues = try Issues.init(arena, session, &found.event_id, rf.identity, issues_selected), │ │154 + .issues = try Issues.init(arena, session, &found.event_id, rf.identity, issues_tag, issues_selected),│ │155 .settings = Settings.init(), │ │156 .auth = Auth.init(), │ │157 .quit = Quit.init(), │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │264 .repo_commits => self.session.data.current_page = .{ .repo_commits = .{ .name = self.data.commits_route_name, .after = self.data.commits_after } }, │ │265 // the refs tab mirrors this page's paginated column + offset. │ │266 .repo_refs => self.session.data.current_page = .{ .repo_refs = .{ .name = self.data.identity, .kind = self.data.refs.kind, .after = self.data.refs.after } }, │ │263 - // the issues tab mirrors the issue this page's window is rooted at. │ │264 - .repo_issues => self.session.data.current_page = ui.RoutablePage.repoIssuesRoute(self.data.identity.slice(), self.data.issues.selected_id) orelse self.session.data.current_page,│ │267 + // the issues tab mirrors this page's tag filter (issue urls │ │268 + // themselves never carry the tag). │ │269 + .repo_issues => self.session.data.current_page = ui.RoutablePage.repoIssuesRoute(self.data.identity.slice(), self.data.issues.tag, "") orelse self.session.data.current_page, │ │270 .home_settings => self.session.data.current_page = .{ .repo_settings = self.data.identity }, │ │271 .home_auth => self.session.data.current_page = .{ .repo_auth = self.data.identity }, │ │272 // the quit tab is tty-only and not a route, so leave current_page │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ─────────────╮ │src/ui/Repo/Header.zig│ ╰──────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────╮ │21 // it (switching tabs keeps the same ref). the value is url-encoded. │ │22 ref_or_oid: RefOrOid, │ │23 ref_or_oid_value: []const u8, │ │24 +// the issues tab's tag filter, url-encoded ("" = unfiltered), so the tab links│ │25 +// back to the filtered list. │ │26 +issues_tag: []const u8, │ │27 │ │28 const Self = @This(); │ │29 │ ╰───────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │27 -pub fn init(arena: *std.heap.ArenaAllocator, name: []const u8, owner_name: []const u8, ref_or_oid: RefOrOid, ref_or_oid_value: []const u8) !Self { │ │30 +pub fn init(arena: *std.heap.ArenaAllocator, name: []const u8, owner_name: []const u8, ref_or_oid: RefOrOid, ref_or_oid_value: []const u8, issues_tag: []const u8) !Self {│ │31 return .{ │ │32 .name = name, │ │33 .owner_name = owner_name, │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────╮ │35 .auth_tab = AuthTab.init(), │ │36 .ref_or_oid = ref_or_oid, │ │37 .ref_or_oid_value = ref_or_oid_value,│ │38 + .issues_tag = issues_tag, │ │39 }; │ │40 } │ │41 │ ╰─────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │111 const commits_route = ui.RoutablePage.repoCommitsRoute(identity, data.ref_or_oid, data.ref_or_oid_value, 0) orelse return error.RouteTooLong;│ │112 const commits_link = try std.fmt.allocPrint(aa, "ai:{s}", .{try commits_route.urlAlloc(session.page_arena)}); │ │113 const refs_link = try std.fmt.allocPrint(aa, "ai:/repo/{s}/{s}/refs", .{ data.owner_name, data.name }); │ │110 - const issues_link = try std.fmt.allocPrint(aa, "ai:/repo/{s}/{s}/issues", .{ data.owner_name, data.name }); │ │114 + const issues_link = if (data.issues_tag.len == 0) │ │115 + try std.fmt.allocPrint(aa, "ai:/repo/{s}/{s}/issues", .{ data.owner_name, data.name }) │ │116 + else │ │117 + try std.fmt.allocPrint(aa, "ai:/repo/{s}/{s}/issues/tag/{s}", .{ data.owner_name, data.name, data.issues_tag }); │ │118 const settings_link = try std.fmt.allocPrint(aa, "ai:/repo/{s}/{s}/settings", .{ data.owner_name, data.name }); │ │119 const auth_link = try std.fmt.allocPrint(aa, "ai:/repo/{s}/{s}/auth", .{ data.owner_name, data.name }); │ │120 │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ─────────────╮ │src/ui/Repo/Issues.zig│ ╰──────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────╮ │24 │ │25 // "owner/name", so the view can build /repo/owner/name/issues/... links.│ │26 identity: []const u8, │ │27 +// the url-encoded tag the list is filtered to ("" = unfiltered). │ │28 +tag: []const u8, │ │29 // the hex event id of the issue this window is rooted at ("" = the first│ │30 // window), mirrored into the url. │ │31 selected_id: []const u8, │ ╰─────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯