╭─────────────╮ │scroll to top│ ╰─────────────╯ ╭ Undo.zig ─────────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
476 const route = ui.RoutablePage.repoUndoClearRoute(data.identity) orelse return error.RouteTooLong; 477 box.getFocus().kind = .{ .custom = try session.page_arena.allocator().print("form:{s}", .{try route.toUrl(session.page_arena)}) }; 478 479 var prompt = try wgt.Text.init(allocator, "are you sure?"); 480 errdefer prompt.deinit(allocator); 481 try box.children.put(allocator, prompt.getFocus().id, .{ .widget = .{ .text = prompt }, .rect = null, .min_size = null }); 482 483 var button = try wgt.TextBox.init(allocator, "clear undo history", .{ .border = .single, .round_corners = true, .wrap_kind = .none }); 484 errdefer button.deinit(allocator); 485 button.getFocus().mode = .all; 486 button.getFocus().kind = if (data.handle.canUndo()) .{ .custom = "submit" } else .text_box; 487 try box.children.put(allocator, button.getFocus().id, .{ .widget = .{ .text_box = button }, .rect = null, .min_size = null }); 488 box.getFocus().child_id = button.getFocus().id; 489 490 return ui.widget.Center.init(allocator, .{ .box = box }); 491 } 492 493 fn addText(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), text: []const u8, link: ?[]const u8, border: wgt.Border) !void { 494 var row = try wgt.TextBox.init(allocator, text, .{ .border = border, .round_corners = true, .wrap_kind = .word }); 495 errdefer row.deinit(allocator); 496 row.getFocus().mode = .all; 497 if (link) |value| row.getFocus().kind = .{ .custom = value }; 498 try box.children.put(allocator, row.getFocus().id, .{ .widget = .{ .text_box = row }, .rect = null, .min_size = null }); 499 } 500 501 // a labeled box, added as the selected action needs it 502 fn addLabeled(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), label: []const u8, text: []const u8, link: ?[]const u8) !void { 503 try addText(allocator, box, text, link, .single); 504 box.children.values()[box.children.count() - 1].widget.text_box.options.top_label.text = label; 505 } 506 507 // the terminal hosts run the action after rendering; the web renderer 508 // posts the form instead, and wasm has no repository to act on 509 fn requestUndo(self: *View, route: ui.RoutablePage) void { 510 if (builtin.target.cpu.arch == .wasm32) return; 511 if (!self.session.is_terminal or self.session.host_request != null) return; 512 self.session.host_request = .{ .undo = route.repo_undo }; 513 } 514 515 fn headerBox(self: *View) *wgt.Box(ui.Widget) { 516 return self.box.children.values()[header_index].widget.header_scroll.row(); 517 } 518 fn headerActive(self: *View) bool { 519 return self.box.getFocus().child_id == self.box.children.keys()[header_index]; 520 } 521 fn contentBox(self: *View) *wgt.Box(ui.Widget) { 522 return &self.box.children.values()[content_index].widget.box; 523 } 524 fn listScroll(self: *View) *wgt.Scroll(ui.Widget) { 525 return &self.contentBox().children.values()[0].widget.scroll; 526 } 527 fn detailFrame(self: *View) *wgt.Box(ui.Widget) { 528 return &self.contentBox().children.values()[1].widget.box; 529 } 530 fn detailScroll(self: *View) *wgt.Scroll(ui.Widget) { 531 return &self.detailFrame().children.values()[ui.widget.list_pane_index].widget.scroll; 532 } 533 fn selected(self: *View) ?usize { 534 const rows = &self.listScroll().child.box; 535 const index = rows.children.getIndex(rows.getFocus().child_id orelse return null) orelse return null; 536 return if (index < self.data.items.len) index else null; 537 } 538 fn detailActive(self: *View) bool { 539 return self.contentBox().getFocus().child_id == self.detailFrame().getFocus().id; 540 } 541 542 fn refreshDetail(self: *View, allocator: std.mem.Allocator) !void { 543 const index = self.selected() orelse return; 544 const failure = self.session.data.undo_failure orelse self.data.failure; 545 if (self.detailed_index == index and std.meta.eql(self.shown_failure, failure)) return; 546 const details = &self.detailScroll().child.box; 547 while (details.children.count() > fixed_rows) { 548 var entry = details.children.pop() orelse break; 549 entry.value.widget.deinit(allocator); 550 } 551 const item = self.data.items[index]; 552 const enabled = self.data.handle.canUndo() and item.index > 0; 553 554 // the fixed rows are filled first, since adding the ones below 555 // invalidates every pointer into the box 556 { 557 const button = &details.children.values()[0].widget.text_box; 558 try button.setContent(allocator, if (item.index == 0) "initial state cannot be undone" else if (item.index == self.data.count - 1) "undo this" else "undo this and all above it"); 559 button.getFocus().kind = if (enabled) .{ .custom = "submit" } else .text_box; 560 } 561 const timestamp = try ui.timestamp.format(self.session.page_arena.allocator(), item.timestamp, try self.session.nowSeconds()); 562 try details.children.values()[1].widget.text_box.setContent(allocator, timestamp.relative); 563 details.children.values()[1].widget.text_box.options.bottom_label.text = timestamp.exact; 564 565 // some actions put buttons where the description would go 566 if (item.buttons.len == 0) { 567 try addLabeled(allocator, details, " description ", item.description, null); 568 } else for (item.buttons) |detail_button| { 569 try addLabeled(allocator, details, detail_button.label, detail_button.text, detail_button.link); 570 } 571 if (failure) |message| try addText(allocator, details, try self.session.page_arena.allocator().print("error: {s}", .{message}), null, .single); 572 self.shown_failure = failure; 573 details.getFocus().kind = if (enabled) .{ .custom = item.form } else .container; 574 details.getFocus().child_id = details.children.keys()[0]; 575 self.detailScroll().x = 0; 576 self.detailScroll().y = 0; 577 self.detailScroll().getFocus().version +%= 1; 578 ui.widget.setListBackLink(self.detailFrame(), item.link); 579 self.detailed_index = index; 580 } 581 582 pub fn build(self: *View, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 583 self.clearGrid(); 584 // the clear form or the empty message stands in for the list 585 if (self.data.clear or self.data.isEmpty(self.session)) return try self.box.build(allocator, constraint, root_focus); 586 try self.refreshDetail(allocator); 587 const rows = &self.listScroll().child.box; 588 if (self.data.items.len == 0) { 589 // an empty list only shows to report its failure 590 const failure = self.session.data.undo_failure orelse self.data.failure orelse unreachable; 591 if (!std.meta.eql(self.shown_failure, failure)) { 592 try rows.children.values()[0].widget.text_box.setContent(allocator, try self.session.page_arena.allocator().print("error: {s}", .{failure})); 593 self.shown_failure = failure; 594 } 595 } 596 for (rows.children.keys(), rows.children.values()) |id, *child| ui.widget.markSelected(&child.widget.text_box, rows.getFocus().child_id == id); 597 const both_fit = if (constraint.max_size.width) |width| width >= list_max_width + detail_min_width else true; 598 ui.widget.showListBack(self.detailFrame(), !both_fit); 599 self.contentBox().children.values()[0].max_size = if (both_fit) .{ .width = list_max_width, .height = null } else null; 600 const width = if (constraint.max_size.width) |value| if (both_fit) value - list_max_width else value else detail_min_width; 601 self.contentBox().children.values()[1].min_size = .{ .width = width, .height = null }; 602 for (self.detailScroll().child.box.children.values()) |*child| child.max_size = .{ .width = width -| 2, .height = null }; 603 try self.box.build(allocator, constraint, root_focus); 604 } 605 606 pub fn input(self: *View, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 607 // the sub header sits above whichever content the route selects. 608 // scrolling crosses the boundary the same way the arrow keys do 609 const direction = inp.vertDirection(key); 610 if (self.headerActive()) { 611 // the empty message has nothing to focus 612 if (direction == .down and !self.data.isEmpty(self.session)) root_focus.setFocus(self.box.children.values()[content_index].widget.getFocus().id); 613 return; 614 } 615 if (self.data.clear) { 616 if (direction == .up) root_focus.setFocus(self.headerBox().getFocus().id) else try self.clearInput(key, root_focus); 617 return; 618 } 619 // the way back to the list, by enter or a click 620 if (self.detailActive()) if (ui.widget.listBackId(self.detailFrame())) |id| { 621 if (inp.activated(root_focus, id, key)) return self.focusList(root_focus); 622 if (root_focus.grandchild_id == id) return switch (direction) { 623 .up => root_focus.setFocus(self.headerBox().getFocus().id), 624 .down => root_focus.setFocus(self.detailScroll().getFocus().id), 625 .none => if (key == .arrow_left) self.focusList(root_focus), 626 }; 627 if (direction == .up and self.contentAtTop()) return root_focus.setFocus(id); 628 }; 629 if (direction == .up and self.contentAtTop()) { 630 root_focus.setFocus(self.headerBox().getFocus().id); 631 return; 632 } 633 try self.refreshDetail(allocator); 634 if (self.detailActive()) { 635 const details = &self.detailScroll().child.box; 636 if (self.selected()) |index| { 637 if (inp.activated(root_focus, details.children.keys()[0], key) and self.data.handle.canUndo() and self.data.items[index].index > 0) { 638 self.requestUndo(ui.RoutablePage.repoUndoRoute(self.data.identity, self.data.items[index].index) orelse return error.RouteTooLong); 639 return; 640 } 641 } 642 if (inp.rowDelta(key, @intCast(details.children.count()))) |delta| { 643 ui.widget.moveRowFocus(details, self.detailScroll(), root_focus, delta); 644 } else if (key == .arrow_left) { 645 self.focusList(root_focus); 646 } 647 } else if (inp.rowDelta(key, @intCast(self.listScroll().child.box.children.count()))) |delta| { 648 ui.widget.moveRowFocus(&self.listScroll().child.box, self.listScroll(), root_focus, delta); 649 } else switch (key) { 650 .arrow_right, .enter => if (self.selected() != null) self.focusDetail(root_focus), 651 // when the window is too narrow to lay out the detail pane beside 652 // the list, a click on a row opens it like enter. the detail was 653 // swapped to the just-selected row above. 654 .mouse => |mouse| if (self.selected() != null and self.contentBox().children.values()[1].rect == null and ui.widget.clickOnSelectedRow(&self.listScroll().child.box, root_focus, mouse)) { 655 self.focusDetail(root_focus); 656 }, 657 else => {}, 658 } 659 } 660 661 // enter the detail pane, landing on the way back to the list when it shows 662 fn focusDetail(self: *View, root_focus: *Focus) void { 663 if (ui.widget.listBackId(self.detailFrame()) != null) return ui.widget.focusListBack(self.detailFrame(), root_focus); 664 root_focus.setFocus(self.detailScroll().getFocus().id); 665 } 666 667 fn focusList(self: *View, root_focus: *Focus) void { 668 root_focus.setFocus(self.listScroll().getFocus().id); 669 } 670 671 // the confirm button submits the form the web renderer posts 672 fn clearInput(self: *View, key: Key, root_focus: *Focus) !void { 673 const center = &self.box.children.values()[content_index].widget.center; 674 const button_id = center.child.box.getFocus().child_id orelse return; 675 if (!inp.activated(root_focus, button_id, key) or !self.data.handle.canUndo()) return; 676 self.requestUndo(ui.RoutablePage.repoUndoClearRoute(self.data.identity) orelse return error.RouteTooLong); 677 } 678 679 fn contentAtTop(self: *View) bool { 680 const scroll = if (self.detailActive()) self.detailScroll() else self.listScroll(); 681 const rows = &scroll.child.box; 682 const id = rows.getFocus().child_id orelse return true; 683 return rows.children.getIndex(id) == 0 and scroll.y == 0; 684 } 685 686 // moving up from the sub header returns to the page's tabs 687 pub fn atTop(self: *View) bool { 688 return self.headerActive(); 689 } 690 691 // arriving from the page's tabs lands on the sub header, not the content 692 pub fn focusHeader(self: *View, root_focus: *Focus) bool { 693 root_focus.setFocus(self.headerBox().getFocus().id); 694 return true; 695 } 696 pub fn deinit(self: *View, allocator: std.mem.Allocator) void { 697 self.box.deinit(allocator); 698 } 699 pub fn clearGrid(self: *View) void { 700 self.box.clearGrid(); 701 } 702 pub fn getGrid(self: View) ?Grid { 703 return self.box.getGrid(); 704 } 705 pub fn getFocus(self: *View) *Focus { 706 return self.box.getFocus(); 707 } 708 }; 709