╭──────────╮ │← previous│ ╰──────────╯ ╭ changed ─────────────╮ │src/ui/Repo/Issues.zig│ ╰──────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────╮ │314 // indices within the stack, 1:1 with the header tabs. │ │223 - const results_view_index: usize = 0; │ │224 - const tags_view_index: usize = 1; │ │225 - // indices within the results box (the horizontal split inside the stack).│ │315 + const open_view_index: usize = 0; │ │316 + const closed_view_index: usize = 1; │ │317 + const tags_view_index: usize = 2; │ │318 + const split_count: usize = 2; │ │319 + // indices within a split (the horizontal box inside the stack). │ │320 const list_index: usize = 0; │ │321 const detail_index: usize = 1; │ │322 const list_max_width: usize = 40; │ ╰───────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │323 const detail_min_width: usize = 40; │ │324 + │ │325 + fn viewIndex(view: ui.RoutablePage.IssuesView) usize { │ │326 + return switch (view) { │ │327 + .open => open_view_index, │ │328 + .closed => closed_view_index, │ │329 + .tags => tags_view_index, │ │330 + }; │ │331 + } │ │332 + │ │333 + fn splitStatus(index: usize) evt.Issue.Status { │ │334 + return if (index == open_view_index) .open else .closed; │ │335 + } │ │336 │ │337 pub fn init(allocator: std.mem.Allocator, data: *const Self, session: *ui.Session) !View { │ │338 var outer = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert });│ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │340 │ │341 // the tabs at the top. │ │342 { │ │237 - var hdr = try Header.init(allocator, session, data.identity, data.count, data.tag); │ │343 + var hdr = try Header.init(allocator, session, data); │ │344 errdefer hdr.deinit(allocator); │ │345 try outer.children.put(allocator, hdr.getFocus().id, .{ .widget = .{ .repo_issues_header = hdr }, .rect = null, .min_size = null });│ │346 + } │ │347 + │ │348 + // the stack enters `outer` before its children enter it, so an error │ │349 + // frees each child exactly once. │ │350 + { │ │351 + var stack = try wgt.Stack(ui.Widget).init(allocator); │ │352 + errdefer stack.deinit(allocator); │ │353 + try outer.children.put(allocator, stack.getFocus().id, .{ .widget = .{ .stack = stack }, .rect = null, .min_size = null }); │ │354 + } │ │355 + const stack = &outer.children.values()[stack_index].widget.stack; │ │356 + │ │357 + // a master-detail split per status list. │ │358 + for ([_]evt.Issue.Status{ .open, .closed }) |status| { │ │359 + var split = try initSplit(allocator, session, data, status); │ │360 + errdefer split.deinit(allocator); │ │361 + try stack.children.put(allocator, split.getFocus().id, .{ .box = split }); │ │362 + } │ │363 + │ │364 + // the tags view │ │365 + { │ │366 + var tf = try ui.TagFlow.init(allocator); │ │367 + errdefer tf.deinit(allocator); │ │368 + var items: std.ArrayList(ui.TagFlow.Item) = .empty; │ │369 + defer items.deinit(allocator); │ │370 + // when filtered, the first item clears the filter │ │371 + if (data.tag.len != 0) │ │372 + try items.append(allocator, .{ .text = "╳", .link = try issuesLink(session.page_arena, data.identity, "", "") }); │ │373 + for (data.tags) |tag| │ │374 + try items.append(allocator, .{ .text = tag, .link = try tagLink(session.page_arena, data.identity, .open, tag) }); │ │375 + try tf.setItems(allocator, items.items); │ │376 + try stack.children.put(allocator, tf.getFocus().id, .{ .tag_flow = tf }); │ │377 } │ │378 + │ │379 + // the stack starts on the page's view. │ │380 + stack.getFocus().child_id = stack.children.keys()[viewIndex(data.view)]; │ │381 + │ │382 + // focus entering the view lands on the tabs first. │ │383 + outer.getFocus().child_id = outer.children.keys()[header_index]; │ │384 │ │385 + return .{ │ │386 + .box = outer, │ │387 + .data = data, │ │388 + .session = session, │ │389 + .detailed_index = .{ null, null }, │ │390 + .description_id = .{ null, null }, │ │391 + }; │ │392 + } │ │393 + │ │394 + // the master-detail split showing `status`'s window. │ │395 + fn initSplit(allocator: std.mem.Allocator, session: *ui.Session, data: *const Self, status: evt.Issue.Status) !wgt.Box(ui.Widget) { │ │396 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .horiz }); │ │397 errdefer box.deinit(allocator); │ │398 + │ │399 + const win = data.window(status); │ │400 │ │401 // the issue list (one focusable row per title), plus a "next" link that │ │402 // reloads the page rooted at the following issue. │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │404 var list_scroll = blk: { │ │405 var list_box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); │ │406 errdefer list_box.deinit(allocator); │ │251 - if (data.prev_id) |prev| │ │407 + if (win.prev_id) |prev| │ │408 try addRow(allocator, &list_box, "← previous", try issuesLink(session.page_arena, data.identity, data.tag, prev)); │ │253 - for (data.issues) |entry| │ │409 + for (win.issues) |entry| │ │410 try addRow(allocator, &list_box, entry.issue.title, try issueRowLink(session.page_arena, data.identity, entry.id));│ │255 - if (data.next_id) |next| │ │411 + if (win.next_id) |next| │ │412 try addRow(allocator, &list_box, "next →", try issuesLink(session.page_arena, data.identity, data.tag, next)); │ │413 // select the window's first issue (past a leading "previous" │ │414 // row) so its description shows on load. │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │259 - if (data.issues.len > 0) │ │260 - list_box.getFocus().child_id = list_box.children.keys()[if (data.prev_id != null) 1 else 0] │ │415 + if (win.issues.len > 0) │ │416 + list_box.getFocus().child_id = list_box.children.keys()[if (win.prev_id != null) 1 else 0] │ │417 else if (list_box.children.count() > 0) │ │418 list_box.getFocus().child_id = list_box.children.keys()[0]; │ │419 break :blk try wgt.Scroll(ui.Widget).init(allocator, .{ .box = list_box }, .{ .direction = .vert, .web_native = !session.is_terminal });│ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │447 } │ │448 │ │449 box.getFocus().child_id = box.children.keys()[list_index]; │ │294 - │ │295 - // the split sits alone in a stack so future tabs can swap other views │ │296 - // in. the stack enters `outer` before the split enters the stack, so an │ │297 - // error frees the split exactly once. │ │298 - { │ │299 - var stack = try wgt.Stack(ui.Widget).init(allocator); │ │300 - errdefer stack.deinit(allocator); │ │301 - try outer.children.put(allocator, stack.getFocus().id, .{ .widget = .{ .stack = stack }, .rect = null, .min_size = null });│ │302 - } │ │303 - const stack = &outer.children.values()[stack_index].widget.stack; │ │304 - stack.getFocus().child_id = box.getFocus().id; │ │305 - try stack.children.put(allocator, box.getFocus().id, .{ .box = box }); │ │306 - │ │307 - // the tags view │ │308 - { │ │309 - var tf = try ui.TagFlow.init(allocator); │ │310 - errdefer tf.deinit(allocator); │ │311 - var items: std.ArrayList(ui.TagFlow.Item) = .empty; │ │312 - defer items.deinit(allocator); │ │313 - // when filtered, the first item clears the filter │ │314 - if (data.tag.len != 0) │ │315 - try items.append(allocator, .{ .text = "╳", .link = try issuesLink(session.page_arena, data.identity, "", "") }); │ │316 - for (data.tags) |tag| │ │317 - try items.append(allocator, .{ .text = tag, .link = try tagLink(session.page_arena, data.identity, tag) }); │ │318 - try tf.setItems(allocator, items.items); │ │319 - try stack.children.put(allocator, tf.getFocus().id, .{ .tag_flow = tf }); │ │320 - } │ │321 - │ │322 - // focus entering the view lands on the tabs first. │ │323 - outer.getFocus().child_id = outer.children.keys()[header_index]; │ │324 - │ │325 - return .{ │ │326 - .box = outer, │ │327 - .data = data, │ │328 - .session = session, │ │329 - .detailed_index = null, │ │330 - .description_id = null, │ │331 - }; │ │450 + return box; │ │451 } │ │452 │ │453 fn addRow(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), label: []const u8, link: []const u8) !void { │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────╮ │470 return &self.box.children.values()[stack_index].widget.stack; │ │471 } │ │472 │ │354 - // the master-detail split inside the stack. │ │355 - fn resultsBox(self: *View) *wgt.Box(ui.Widget) { │ │356 - return &self.viewStack().children.values()[results_view_index].box;│ │473 + // `index`'s master-detail split inside the stack. │ │474 + fn resultsBox(self: *View, index: usize) *wgt.Box(ui.Widget) { │ │475 + return &self.viewStack().children.values()[index].box; │ │476 } │ │477 │ │478 // the tags view's flow inside the stack. │ ╰────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────╮ │480 return &self.viewStack().children.values()[tags_view_index].tag_flow; │ │481 } │ │482 │ │364 - fn listScroll(self: *View) *wgt.Scroll(ui.Widget) { │ │365 - return &self.resultsBox().children.values()[list_index].widget.scroll; │ │483 + fn listScroll(self: *View, index: usize) *wgt.Scroll(ui.Widget) { │ │484 + return &self.resultsBox(index).children.values()[list_index].widget.scroll;│ │485 } │ │486 │ │368 - fn listBox(self: *View) *wgt.Box(ui.Widget) { │ │369 - return &self.listScroll().child.box; │ │487 + fn listBox(self: *View, index: usize) *wgt.Box(ui.Widget) { │ │488 + return &self.listScroll(index).child.box; │ │489 } │ │490 │ │372 - fn detailOuter(self: *View) *wgt.Box(ui.Widget) { │ │373 - return &self.resultsBox().children.values()[detail_index].widget.box; │ │491 + fn detailOuter(self: *View, index: usize) *wgt.Box(ui.Widget) { │ │492 + return &self.resultsBox(index).children.values()[detail_index].widget.box; │ │493 } │ │494 │ │376 - fn detailScroll(self: *View) *wgt.Scroll(ui.Widget) { │ │377 - return &self.detailOuter().children.values()[0].widget.scroll; │ │495 + fn detailScroll(self: *View, index: usize) *wgt.Scroll(ui.Widget) { │ │496 + return &self.detailOuter(index).children.values()[0].widget.scroll; │ │497 } │ │498 │ │380 - fn detailInner(self: *View) *wgt.Box(ui.Widget) { │ │381 - return &self.detailScroll().child.box; │ │499 + fn detailInner(self: *View, index: usize) *wgt.Box(ui.Widget) { │ │500 + return &self.detailScroll(index).child.box; │ │501 + } │ │502 + │ │503 + fn window(self: *View, index: usize) *const Window { │ │504 + return self.data.window(splitStatus(index)); │ │505 } │ │506 │ │384 - fn detailActive(self: *View) bool { │ │385 - const rb = self.resultsBox(); │ │507 + // the stack's selected master-detail split (null when the tags view shows). │ │508 + fn selectedSplitIndex(self: *View) ?usize { │ │509 + const stack = self.viewStack(); │ │510 + const cid = stack.getFocus().child_id orelse return null; │ │511 + const idx = stack.children.getIndex(cid) orelse return null; │ │512 + return if (idx == tags_view_index) null else idx; │ │513 + } │ │514 + │ │515 + fn detailActive(self: *View, index: usize) bool { │ │516 + const rb = self.resultsBox(index); │ │517 const cid = rb.getFocus().child_id orelse return false; │ │518 return rb.children.getIndex(cid) == detail_index; │ │519 } │ ╰────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────╮ │525 │ │526 fn tagsViewActive(self: *View) bool { │ │527 if (self.headerActive()) return false; │ │397 - const stack = self.viewStack(); │ │398 - const cid = stack.getFocus().child_id orelse return false; │ │399 - return stack.children.getIndex(cid) == tags_view_index; │ │528 + return self.selectedSplitIndex() == null; │ │529 } │ │530 │ │531 // the selected issue's index, or null when a window-navigation row is│ ╰───────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────╮ │532 // selected (a leading "previous" row shifts the issue rows down by one).│ │404 - fn selectedIssueIndex(self: *View) ?usize { │ │405 - const lb = self.listBox(); │ │533 + fn selectedIssueIndex(self: *View, index: usize) ?usize { │ │534 + const lb = self.listBox(index); │ │535 const cid = lb.getFocus().child_id orelse return null; │ │536 const idx = lb.children.getIndex(cid) orelse return null; │ │408 - const lead: usize = if (self.data.prev_id != null) 1 else 0; │ │409 - if (idx < lead or idx - lead >= self.data.issues.len) return null; │ │537 + const win = self.window(index); │ │538 + const lead: usize = if (win.prev_id != null) 1 else 0; │ │539 + if (idx < lead or idx - lead >= win.issues.len) return null; │ │540 return idx - lead; │ │541 } │ │542 │ ╰──────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯