╭──────────╮ │← previous│ ╰──────────╯ ╭ changed ───────────────╮ │src/ui/widget/thread.zig│ ╰────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │456 tab_ids: std.AutoArrayHashMapUnmanaged(usize, usize), │ │431 - // the tab whose view the stack shows. focus lands on the search box too, │ │457 + // the tab whose view the stack shows. focus lands on the boxes too, │ │458 // so the selection is remembered rather than read off the focus chain. │ │459 selected_tab: ?usize = null, │ │460 + // how many text boxes come before the tabs │ │461 + box_count: usize, │ │462 + │ │463 + // which box enter submitted from │ │464 + pub const Submission = struct { kind: enum { search, assignee }, text: []u8 }; │ │465 │ │435 - // the search box's place in the box, ahead of the tabs │ │436 - const search_index: usize = 0; │ │466 + // the assignee box's label and prefilled text │ │467 + pub const AssigneeBox = struct { label: []const u8, text: []const u8 }; │ │468 │ │438 - pub fn init(allocator: std.mem.Allocator, session: *ui.Session, search: ?[]const u8) !Header { │ │469 + // a null `assignee` leaves out the assignee box │ │470 + pub fn init(allocator: std.mem.Allocator, session: *ui.Session, search: ?[]const u8, assignee: ?AssigneeBox) !Header {│ │471 var box = try wgt.Box(Widget).init(allocator, .{ .border = null, .direction = .horiz }); │ │472 errdefer box.deinit(allocator); │ │473 │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │478 try box.children.put(allocator, search_box.getFocus().id, .{ .widget = .{ .search_box = search_box }, .rect = null, .min_size = widget.SearchBox.min_size }); │ │479 } │ │480 │ │449 - return .{ .box = box, .tab_ids = .empty }; │ │481 + if (assignee) |value| { │ │482 + var assignee_box = try widget.SearchBox.init(allocator, session, value.label, "assignee", value.text); │ │483 + errdefer assignee_box.deinit(allocator); │ │484 + try box.children.put(allocator, assignee_box.getFocus().id, .{ .widget = .{ .search_box = assignee_box }, .rect = null, .min_size = widget.SearchBox.min_size });│ │485 + } │ │486 + │ │487 + return .{ .box = box, .tab_ids = .empty, .box_count = box.children.count() }; │ │488 } │ │489 │ │490 pub fn addTab(self: *Header, allocator: std.mem.Allocator, label: []const u8, link: []const u8, view_index: usize) !void { │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │535 } │ │536 │ │537 pub fn input(self: *Header, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { │ │500 - const search_box = self.searchBox(); │ │501 - if (self.box.getFocus().child_id == search_box.getFocus().id) { │ │502 - // tab, and right-arrow past the last character, step to the tabs │ │538 + if (self.focusedBox()) |index| { │ │539 + const search_box = self.searchBox(index); │ │540 + // tab, and right-arrow past the last character, step to the next box or the tabs │ │541 if (key == .tab or (key == .arrow_right and search_box.atEnd())) { │ │504 - root_focus.setFocus(self.tab_ids.keys()[0]); │ │542 + root_focus.setFocus(if (index + 1 < self.box_count) self.searchBox(index + 1).getFocus().id else self.tab_ids.keys()[0]);│ │543 + return; │ │544 + } │ │545 + // back-tab, and left-arrow before the first character, step to the previous box │ │546 + if (index > 0 and (key == .back_tab or (key == .arrow_left and search_box.text_input.cursor == 0))) { │ │547 + root_focus.setFocus(self.searchBox(index - 1).getFocus().id); │ │548 return; │ │549 } │ │550 return search_box.input(allocator, key, root_focus); │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────╮ │553 if (key == .back_tab or (key == .arrow_left and current_tab == 0)) { │ │554 // remembered here as well as in build, which may not run between keys│ │555 self.selected_tab = current_tab; │ │513 - root_focus.setFocus(search_box.getFocus().id); │ │556 + root_focus.setFocus(self.searchBox(self.box_count - 1).getFocus().id);│ │557 return; │ │558 } │ │559 if (inp.moveTab(key, current_tab, self.tab_ids.count())) |new_tab| { │ ╰───────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │561 } │ │562 } │ │563 │ │521 - // the typed query when enter should submit it: the box holds focus. the │ │564 + // the typed text when enter should submit it: a box holds focus. the │ │565 // caller owns the returned text. │ │523 - pub fn submittedText(self: *Header, allocator: std.mem.Allocator) !?[]u8 { │ │524 - const search_box = self.searchBox(); │ │525 - if (self.box.getFocus().child_id != search_box.getFocus().id) return null; │ │526 - return try search_box.text(allocator); │ │566 + pub fn submittedText(self: *Header, allocator: std.mem.Allocator) !?Submission { │ │567 + const index = self.focusedBox() orelse return null; │ │568 + return .{ .kind = if (index == 0) .search else .assignee, .text = try self.searchBox(index).text(allocator) };│ │569 + } │ │570 + │ │571 + // the index of the box holding focus, if one does │ │572 + fn focusedBox(self: *Header) ?usize { │ │573 + const child_id = self.box.getFocus().child_id orelse return null; │ │574 + const index = self.box.children.getIndex(child_id) orelse return null; │ │575 + return if (index < self.box_count) index else null; │ │576 } │ │577 │ │529 - fn searchBox(self: *Header) *widget.SearchBox { │ │530 - return &self.box.children.values()[search_index].widget.search_box; │ │578 + fn searchBox(self: *Header, index: usize) *widget.SearchBox { │ │579 + return &self.box.children.values()[index].widget.search_box; │ │580 } │ │581 │ │582 pub fn clearGrid(self: *Header) void { │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │611 // the assignee row's form action, which also identifies the row │ │612 const assign_suffix = "/assign"; │ │613 │ │565 -// an input that assigns on enter, then one unassign button per assignee. a │ │566 -// viewer who may not change the thread only sees who is assigned. │ │567 -fn appendAssigneeRow( │ │568 - allocator: std.mem.Allocator, │ │569 - box: *wgt.Box(Widget), │ │570 - session: *ui.Session, │ │571 - thread_url: []const u8, │ │572 - assignees: []const []const u8, │ │573 - can_modify: bool, │ │574 -) !void { │ │614 +// an input that assigns on enter, in a form of its own │ │615 +fn appendAssigneeRow(allocator: std.mem.Allocator, box: *wgt.Box(Widget), session: *ui.Session, thread_url: []const u8) !void { │ │616 const pa = session.page_arena.allocator(); │ │576 - if (!can_modify) { │ │577 - if (assignees.len == 0) return; │ │578 - const joined = try std.mem.join(pa, " ", assignees); │ │579 - var assigned = try wgt.TextBox.init(allocator, joined, .{ .border = .single, .round_corners = true, .wrap_kind = .word, .top_label = .{ .text = " assigned " } });│ │580 - errdefer assigned.deinit(allocator); │ │581 - try box.children.put(allocator, assigned.getFocus().id, .{ .widget = .{ .text_box = assigned }, .rect = null, .min_size = null }); │ │582 - return; │ │583 - } │ │617 var row = try wgt.Box(Widget).init(allocator, .{ .border = null, .direction = .horiz }); │ │618 errdefer row.deinit(allocator); │ │619 row.getFocus().kind = .{ .custom = try pa.print("form:{s}" ++ assign_suffix, .{thread_url}) }; │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │625 row.getFocus().child_id = text_input.getFocus().id; │ │626 try row.children.put(allocator, text_input.getFocus().id, .{ .widget = .{ .text_input = text_input }, .rect = null, .min_size = .{ .width = null, .height = 3 } }); │ │627 │ │595 - if (assignees.len > 0) { │ │596 - const items = try pa.alloc(WordFlow.Item, assignees.len); │ │597 - for (items, assignees) |*item, email| item.* = .{ │ │598 - .text = try pa.print(" unassign {s} ", .{email}), │ │599 - .link = try pa.print("{s}{s}/assignee:{s}/unassign", .{ ui.submit_action_prefix, thread_url, try ui.urlEncodeRef(pa, email) }), │ │600 - }; │ │601 - var flow = try WordFlow.init(allocator); │ │602 - errdefer flow.deinit(allocator); │ │603 - try flow.setItems(allocator, items); │ │604 - // the buttons post their own forms, so enter in the input still submits │ │605 - // the assign form │ │606 - flow.getFocus().kind = .{ .custom = try pa.print("form:{s}", .{thread_url}) }; │ │607 - try row.children.put(allocator, flow.getFocus().id, .{ .widget = .{ .word_flow = flow }, .rect = null, .min_size = null }); │ │628 + try box.children.put(allocator, row.getFocus().id, .{ .widget = .{ .box = row }, .rect = null, .min_size = null }); │ │629 +} │ │630 + │ │631 +const unassign_suffix = "/unassign"; │ │632 +const assignee_seg = "/assignee:"; │ │633 + │ │634 +// one row per assignee: its name or email, linking to the issues assigned to │ │635 +// it, and for an editor a ✕ that unassigns │ │636 +fn appendAssignedRows( │ │637 + allocator: std.mem.Allocator, │ │638 + box: *wgt.Box(Widget), │ │639 + session: *ui.Session, │ │640 + thread_url: []const u8, │ │641 + assignees: []const ui.Assignee, │ │642 + links: []const []const u8, │ │643 + can_modify: bool, │ │644 +) !void { │ │645 + const pa = session.page_arena.allocator(); │ │646 + for (assignees, links) |assignee, link| { │ │647 + var row = try wgt.Box(Widget).init(allocator, .{ .border = null, .direction = .horiz }); │ │648 + errdefer row.deinit(allocator); │ │649 + │ │650 + var tb = try wgt.TextBox.init(allocator, assignee.display(), .{ .border = .single, .round_corners = true, .wrap_kind = .none, .top_label = .{ .text = " assigned " } }); │ │651 + errdefer tb.deinit(allocator); │ │652 + tb.getFocus().mode = .all; │ │653 + tb.getFocus().kind = .{ .custom = link }; │ │654 + try row.children.put(allocator, tb.getFocus().id, .{ .widget = .{ .text_box = tb }, .rect = null, .min_size = null }); │ │655 + │ │656 + if (can_modify) { │ │657 + var remove = try wgt.TextBox.init(allocator, "✕", .{ .border = .single, .round_corners = true, .wrap_kind = .none }); │ │658 + errdefer remove.deinit(allocator); │ │659 + remove.getFocus().mode = .all; │ │660 + remove.getFocus().kind = .{ .custom = try pa.print("{s}{s}" ++ assignee_seg ++ "{s}" ++ unassign_suffix, .{ ui.submit_action_prefix, thread_url, try ui.urlEncodeRef(pa, assignee.email) }) };│ │661 + try row.children.put(allocator, remove.getFocus().id, .{ .widget = .{ .text_box = remove }, .rect = null, .min_size = .{ .width = 3, .height = null } }); │ │662 + } │ │663 + │ │664 + row.getFocus().child_id = row.children.keys()[0]; │ │665 + try box.children.put(allocator, row.getFocus().id, .{ .widget = .{ .box = row }, .rect = null, .min_size = null }); │ │666 } │ │667 +} │ │668 │ │610 - try box.children.put(allocator, row.getFocus().id, .{ .widget = .{ .box = row }, .rect = null, .min_size = null }); │ │669 +// the url-encoded email an assignee row's ✕ unassigns │ │670 +fn unassignEmail(row: *wgt.Box(Widget)) ?[]const u8 { │ │671 + if (row.children.count() < 2) return null; │ │672 + const action = switch (row.children.values()[1].widget.getFocus().kind) { │ │673 + .custom => |custom| custom, │ │674 + else => return null, │ │675 + }; │ │676 + if (!std.mem.startsWith(u8, action, ui.submit_action_prefix) or !std.mem.endsWith(u8, action, unassign_suffix)) return null; │ │677 + const at = std.mem.lastIndexOf(u8, action, assignee_seg) orelse return null; │ │678 + return action[at + assignee_seg.len .. action.len - unassign_suffix.len]; │ │679 } │ │680 │ │681 // whether `row` is the assignee row │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │842 } │ │843 │ │844 // a list link keeps the filters the page carries: the label, and the │ │777 - // search query when one is set │ │778 - fn listLink(page_arena: *std.heap.ArenaAllocator, identity: []const u8, status: Status, label: []const u8, id: []const u8, search: []const u8) ![]const u8 { │ │845 + // search query and url-encoded assignee when set │ │846 + fn listLink(page_arena: *std.heap.ArenaAllocator, identity: []const u8, status: Status, label: []const u8, id: []const u8, search: []const u8, assignee: []const u8) ![]const u8 {│ │847 var route = listRoute(identity, status, label, id) orelse return error.RouteTooLong; │ │848 if (search.len != 0) route = route.withSearch(search) orelse return error.RouteTooLong; │ │849 + if (assignee.len != 0) route = route.withEncodedAssignee(assignee) orelse return error.RouteTooLong; │ │850 return page_arena.allocator().print("a:{s}", .{try route.toUrl(page_arena)}); │ │851 } │ │852 │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │784 - fn labelLink(page_arena: *std.heap.ArenaAllocator, identity: []const u8, status: Status, label: []const u8, search: []const u8) ![]const u8 { │ │853 + fn labelLink(page_arena: *std.heap.ArenaAllocator, identity: []const u8, status: Status, label: []const u8, search: []const u8, assignee: []const u8) ![]const u8 {│ │854 const encoded = try ui.urlEncodeRef(page_arena.allocator(), label); │ │786 - return listLink(page_arena, identity, status, encoded, "", search); │ │855 + return listLink(page_arena, identity, status, encoded, "", search, assignee); │ │856 } │ │857 │ │858 fn addToolButton(allocator: std.mem.Allocator, row: *wgt.Box(Widget), label: []const u8, bottom_label: []const u8, action: []const u8) !void { │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │979 var text_box = try wgt.TextBox.init(allocator, back_label, .{ .border = .single, .round_corners = true, .wrap_kind = .none }); │ │980 errdefer text_box.deinit(allocator); │ │981 text_box.getFocus().mode = .all; │ │913 - text_box.getFocus().kind = .{ .custom = try listLink(self.session.page_arena, self.data.identity, entryStatus(entry), "", entry.id, self.data.search orelse "") }; │ │982 + text_box.getFocus().kind = .{ .custom = try listLink(self.session.page_arena, self.data.identity, entryStatus(entry), "", entry.id, self.data.search orelse "", "") };│ │983 try inner_box.children.put(allocator, text_box.getFocus().id, .{ .widget = .{ .text_box = text_box }, .rect = null, .min_size = null }); │ │984 self.title_id = text_box.getFocus().id; │ │985 } else { │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯