╭──────────╮ │← previous│ ╰──────────╯ ╭ changed ─────────────╮ │src/ui/Repo/Issues.zig│ ╰──────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────╮ │ 96 .selected_id = try aa.dupe(u8, selected_id), │ │ 97 .open = .empty, │ │ 98 .closed = .empty, │ │ 92 - .view = view, │ │ 99 + // a description url shows a status list; the issue's own status picks│ │100 + // it once the issue is read. │ │101 + .view = if (view == .description) .open else view, │ │102 + .description_page = view == .description, │ │103 .tags = &.{}, │ │104 }; │ │105 } │ ╰───────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────╮ │231 .open = open_window, │ │232 .closed = closed_window, │ │233 .view = resolved_view, │ │234 + .description_page = empty.description_page,│ │235 .tags = tags.items, │ │236 }; │ │237 } │ ╰────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────╮ │361 .closed => closed_view_index, │ │362 .tags => tags_view_index, │ │363 .new, .edit => form_view_index, │ │364 + // init resolves a description url to its issue's status list│ │365 + .description => unreachable, │ │366 }; │ │367 } │ │368 │ ╰──────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │696 self.session.data.current_page = (if (index == form_view_index and self.data.view == .edit) │ │697 ui.RoutablePage.repoIssuesEditRoute(self.data.identity, self.data.selected_id) │ │698 else if (index == viewIndex(self.data.view) and self.data.selected_id.len != 0) │ │686 - ui.RoutablePage.repoIssuesRoute(self.data.identity, .open, self.data.tag, self.data.selected_id) │ │699 + (if (self.data.description_page) │ │700 + ui.RoutablePage.repoIssuesDescriptionRoute(self.data.identity, self.data.selected_id) │ │701 + else │ │702 + ui.RoutablePage.repoIssuesRoute(self.data.identity, .open, self.data.tag, self.data.selected_id))│ │703 else if (index == tags_view_index) │ │704 ui.RoutablePage.repoIssuesTagsRoute(self.data.identity, self.data.tag) │ │705 else if (index == form_view_index) │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │718 if (root_focus.grandchild_id) |g| { │ │719 if (self.resultsBox(i).getFocus().children.contains(g)) { │ │720 if (self.selectedIssueIndex(i)) |sel| { │ │705 - if (ui.RoutablePage.repoIssuesRoute(self.data.identity, splitStatus(i), "", self.window(i).issues[sel].id)) |route|│ │706 - self.session.data.current_page = route; │ │721 + // selecting another issue leaves the description page │ │722 + // behind, like it leaves the tag filter behind. │ │723 + const id = self.window(i).issues[sel].id; │ │724 + const mirrored = if (self.data.description_page and std.mem.eql(u8, id, self.data.selected_id)) │ │725 + ui.RoutablePage.repoIssuesDescriptionRoute(self.data.identity, id) │ │726 + else │ │727 + ui.RoutablePage.repoIssuesRoute(self.data.identity, splitStatus(i), "", id); │ │728 + if (mirrored) |route| self.session.data.current_page = route; │ │729 } │ │730 } │ │731 } │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │794 fn populateDetail(self: *View, allocator: std.mem.Allocator, index: usize, sel: usize) !void { │ │795 const entry = self.window(index).issues[sel]; │ │796 const inner = self.detailInner(index); │ │797 + // the /description page replaces its issue's detail with a back link │ │798 + // and the whole description; other issues keep their normal detail. │ │799 + const description_page = self.data.description_page and std.mem.eql(u8, entry.id, self.data.selected_id);│ │800 │ │801 for (inner.children.values()) |*child| child.widget.deinit(allocator); │ │802 inner.children.clearAndFree(allocator); │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────╮ │803 inner.getFocus().child_id = null; │ │804 │ │780 - // the open/close button │ │805 + // the tool row: the open/close and edit buttons; the description page │ │806 + // shows none. │ │807 { │ │808 + const row = self.toolRow(index); │ │809 + for (row.children.values()) |*child| child.widget.deinit(allocator);│ │810 + row.children.clearAndFree(allocator); │ │811 + row.getFocus().child_id = null; │ │812 + row.getFocus().kind = .container; │ │813 + } │ │814 + if (!description_page) { │ │815 const action: []const u8 = switch (entry.issue.status) { │ │816 .open => "close", │ │817 .closed => "open", │ ╰─────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │818 }; │ │819 const row = self.toolRow(index); │ │787 - for (row.children.values()) |*child| child.widget.deinit(allocator); │ │788 - row.children.clearAndFree(allocator); │ │820 const pa = self.session.page_arena.allocator(); │ │821 row.getFocus().kind = .{ .custom = if (self.data.identity.len == 0) │ │822 try std.fmt.allocPrint(pa, "form:/issues/{s}/{s}", .{ entry.id, action })│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │852 row.getFocus().child_id = row.children.keys()[button_in_row_index]; │ │853 } │ │854 │ │824 - // the issue's title as a focusable word-wrapped text box. │ │825 - { │ │826 - var tb = try wgt.TextBox(ui.Widget).init(allocator, entry.issue.title, .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .word, .label = " title " }); │ │855 + if (description_page) { │ │856 + // the back link, in the title slot so the pane's input handling │ │857 + // applies to it unchanged. │ │858 + var tb = try wgt.TextBox(ui.Widget).init(allocator, "← back to issue", .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .none }); │ │859 errdefer tb.deinit(allocator); │ │860 tb.getFocus().focusable = true; │ │861 + tb.getFocus().kind = .{ .custom = try issuesLink(self.session.page_arena, self.data.identity, entry.issue.status, "", entry.id) }; │ │862 try inner.children.put(allocator, tb.getFocus().id, .{ .widget = .{ .text_box = tb }, .rect = null, .min_size = null }); │ │830 - } │ │863 + } else { │ │864 + // the issue's title as a focusable word-wrapped text box. │ │865 + { │ │866 + var tb = try wgt.TextBox(ui.Widget).init(allocator, entry.issue.title, .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .word, .label = " title " });│ │867 + errdefer tb.deinit(allocator); │ │868 + tb.getFocus().focusable = true; │ │869 + try inner.children.put(allocator, tb.getFocus().id, .{ .widget = .{ .text_box = tb }, .rect = null, .min_size = null }); │ │870 + } │ │871 │ │832 - // the issue's tags, each linking to this status's list filtered to │ │833 - // that tag. │ │834 - { │ │835 - var items: std.ArrayList(ui.TagFlow.Item) = .empty; │ │836 - defer items.deinit(allocator); │ │837 - var tag_iter = evt.Issue.tagIterator(entry.issue.tags); │ │838 - while (tag_iter.next()) |tag| { │ │839 - if (tag.len == 0) continue; │ │840 - try items.append(allocator, .{ .text = tag, .link = try tagLink(self.session.page_arena, self.data.identity, splitStatus(index), tag) }); │ │841 - } │ │842 - if (items.items.len > 0) { │ │843 - var tf = try ui.TagFlow.init(allocator); │ │844 - errdefer tf.deinit(allocator); │ │845 - try tf.setItems(allocator, items.items); │ │846 - try inner.children.put(allocator, tf.getFocus().id, .{ .widget = .{ .tag_flow = tf }, .rect = null, .min_size = null }); │ │872 + // the issue's tags, each linking to this status's list filtered to │ │873 + // that tag. │ │874 + { │ │875 + var items: std.ArrayList(ui.TagFlow.Item) = .empty; │ │876 + defer items.deinit(allocator); │ │877 + var tag_iter = evt.Issue.tagIterator(entry.issue.tags); │ │878 + while (tag_iter.next()) |tag| { │ │879 + if (tag.len == 0) continue; │ │880 + try items.append(allocator, .{ .text = tag, .link = try tagLink(self.session.page_arena, self.data.identity, splitStatus(index), tag) }); │ │881 + } │ │882 + if (items.items.len > 0) { │ │883 + var tf = try ui.TagFlow.init(allocator); │ │884 + errdefer tf.deinit(allocator); │ │885 + try tf.setItems(allocator, items.items); │ │886 + try inner.children.put(allocator, tf.getFocus().id, .{ .widget = .{ .tag_flow = tf }, .rect = null, .min_size = null }); │ │887 + } │ │888 } │ │889 } │ │890 │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │850 - // the description as a focusable word-wrapped text box. │ │891 + // the description as a focusable word-wrapped text box. past the limit │ │892 + // it's cut at the last whole line and links to the /description page, │ │893 + // which shows the whole thing. │ │894 self.description_id[index] = blk: { │ │852 - const description = if (entry.issue.description.len == 0) "(no description)" else entry.issue.description; │ │853 - var tb = try wgt.TextBox(ui.Widget).init(allocator, description, .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .word, .label = " description " });│ │895 + const whole = entry.issue.description; │ │896 + const cut_short = !description_page and whole.len > max_description_size; │ │897 + const shown = if (cut_short) │ │898 + std.mem.trimEnd(u8, whole[0 .. std.mem.lastIndexOfScalar(u8, whole[0..max_description_size], '\n') orelse 0], " \t\r\n") │ │899 + else if (whole.len == 0) │ │900 + "(no description)" │ │901 + else │ │902 + whole; │ │903 + var tb = try wgt.TextBox(ui.Widget).init(allocator, shown, .{ │ │904 + .border_style = .single, │ │905 + .rounded_corners = true, │ │906 + .wrap_kind = .word, │ │907 + .label = " description ", │ │908 + .bottom_label = if (cut_short) " click or press enter to see more " else "", │ │909 + }); │ │910 errdefer tb.deinit(allocator); │ │911 tb.getFocus().focusable = true; │ │912 + if (cut_short) tb.getFocus().kind = .{ .custom = try descriptionLink(self.session.page_arena, self.data.identity, entry.id) }; │ │913 try inner.children.put(allocator, tb.getFocus().id, .{ .widget = .{ .text_box = tb }, .rect = null, .min_size = null }); │ │914 break :blk tb.getFocus().id; │ │915 }; │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯