╭─────────────╮ │scroll to top│ ╰─────────────╯ ╭ Undo.zig ─────────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
401 // the sub header carries the tab's only action 402 { 403 var header = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .horiz }); 404 errdefer header.deinit(allocator); 405 const route = ui.RoutablePage.repoUndoClearRoute(data.identity) orelse return error.RouteTooLong; 406 const link = try std.fmt.allocPrint(session.page_arena.allocator(), "a:{s}", .{try route.toUrl(session.page_arena)}); 407 try addText(allocator, &header, "clear undo history", link, .single); 408 header.getFocus().child_id = header.children.keys()[0]; 409 // a row taller than the header, leaving a blank line beneath it 410 try outer.children.put(allocator, header.getFocus().id, .{ .widget = .{ .box = header }, .rect = null, .min_size = .{ .width = null, .height = 4 } }); 411 } 412 413 if (data.clear) { 414 var center = try initClearForm(allocator, data, session); 415 errdefer center.deinit(allocator); 416 try outer.children.put(allocator, center.getFocus().id, .{ .widget = .{ .center = center }, .rect = null, .min_size = null }); 417 outer.getFocus().child_id = outer.children.keys()[content_index]; 418 return .{ .box = outer, .data = data, .session = session }; 419 } 420 421 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .horiz }); 422 errdefer box.deinit(allocator); 423 { 424 var scroll = blk: { 425 var rows = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert, .stretch = true }); 426 errdefer rows.deinit(allocator); 427 for (data.items) |item| { 428 try addText(allocator, &rows, item.action, item.link, .hidden); 429 // a transaction a later undo discarded says so on its border 430 if (item.undone) rows.children.values()[rows.children.count() - 1].widget.text_box.options.bottom_label = "(undone)"; 431 } 432 if (data.next) |next| { 433 const route = ui.RoutablePage.repoUndoRoute(data.identity, next) orelse return error.RouteTooLong; 434 try addText(allocator, &rows, "next →", try std.fmt.allocPrint(session.page_arena.allocator(), "a:{s}", .{try route.toUrl(session.page_arena)}), .hidden); 435 } 436 if (data.items.len == 0) try addText(allocator, &rows, "no undo history", null, .hidden); 437 rows.getFocus().child_id = rows.children.keys()[0]; 438 break :blk try wgt.Scroll(ui.Widget).init(allocator, .{ .box = rows }, .{ .direction = .vert, .web_native = !session.is_terminal, .fill = true }); 439 }; 440 errdefer scroll.deinit(allocator); 441 try box.children.put(allocator, scroll.getFocus().id, .{ .widget = .{ .scroll = scroll }, .rect = null, .min_size = .{ .width = list_max_width, .height = null }, .max_size = .{ .width = list_max_width, .height = null } }); 442 } 443 { 444 var scroll = blk: { 445 var details = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); 446 errdefer details.deinit(allocator); 447 if (data.items.len > 0) { 448 try addText(allocator, &details, "", null, .single); 449 try addLabeled(allocator, &details, " timestamp ", "", null); 450 details.getFocus().child_id = details.children.keys()[0]; 451 } 452 break :blk try wgt.Scroll(ui.Widget).init(allocator, .{ .box = details }, .{ .direction = .vert, .web_native = !session.is_terminal, .fill = true }); 453 }; 454 errdefer scroll.deinit(allocator); 455 scroll.getFocus().mode = .mouse; 456 try box.children.put(allocator, scroll.getFocus().id, .{ .widget = .{ .scroll = scroll }, .rect = null, .min_size = .{ .width = detail_min_width, .height = null } }); 457 } 458 box.getFocus().child_id = box.children.keys()[0]; 459 try outer.children.put(allocator, box.getFocus().id, .{ .widget = .{ .box = box }, .rect = null, .min_size = null }); 460 outer.getFocus().child_id = outer.children.keys()[content_index]; 461 return .{ .box = outer, .data = data, .session = session }; 462 } 463 464 // the same shape the thread views use to confirm a removal 465 fn initClearForm(allocator: std.mem.Allocator, data: *const Self, session: *ui.Session) !ui.widget.Center { 466 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .round_corners = true, .direction = .vert }); 467 errdefer box.deinit(allocator); 468 const route = ui.RoutablePage.repoUndoClearRoute(data.identity) orelse return error.RouteTooLong; 469 box.getFocus().kind = .{ .custom = try std.fmt.allocPrint(session.page_arena.allocator(), "form:{s}", .{try route.toUrl(session.page_arena)}) }; 470 471 var prompt = try wgt.Text.init(allocator, "are you sure?"); 472 errdefer prompt.deinit(allocator); 473 try box.children.put(allocator, prompt.getFocus().id, .{ .widget = .{ .text = prompt }, .rect = null, .min_size = null }); 474 475 var button = try wgt.TextBox.init(allocator, "clear undo history", .{ .border_style = .single, .round_corners = true, .wrap_kind = .none }); 476 errdefer button.deinit(allocator); 477 button.getFocus().mode = .all; 478 button.getFocus().kind = if (data.can_undo) .{ .custom = "submit" } else .text_box; 479 try box.children.put(allocator, button.getFocus().id, .{ .widget = .{ .text_box = button }, .rect = null, .min_size = null }); 480 box.getFocus().child_id = button.getFocus().id; 481 482 return ui.widget.Center.init(allocator, .{ .box = box }); 483 } 484 485 fn addText(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), text: []const u8, link: ?[]const u8, border: wgt.BorderStyle) !void { 486 var row = try wgt.TextBox.init(allocator, text, .{ .border_style = border, .round_corners = true, .wrap_kind = .word }); 487 errdefer row.deinit(allocator); 488 row.getFocus().mode = .all; 489 if (link) |value| row.getFocus().kind = .{ .custom = value }; 490 try box.children.put(allocator, row.getFocus().id, .{ .widget = .{ .text_box = row }, .rect = null, .min_size = null }); 491 } 492 493 // a labeled row below the timestamp, added as the selected action needs it 494 fn addLabeled(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), label: []const u8, text: []const u8, link: ?[]const u8) !void { 495 try addText(allocator, box, text, link, .single); 496 box.children.values()[box.children.count() - 1].widget.text_box.options.label = label; 497 } 498 499 // enter counts only on the focused button; a click counts anywhere on it 500 fn activated(root_focus: *Focus, button_id: usize, key: Key) bool { 501 return switch (key) { 502 .enter => root_focus.grandchild_id == button_id, 503 .mouse => |mouse| inp.leftClickOn(root_focus, button_id, mouse), 504 else => false, 505 }; 506 } 507 508 // the terminal hosts run the action after rendering; the web renderer 509 // posts the form instead, and wasm has no repository to act on 510 fn requestUndo(self: *View, route: ui.RoutablePage) void { 511 if (builtin.target.cpu.arch == .wasm32) return; 512 if (!self.session.is_terminal or self.session.host_request != null) return; 513 self.session.host_request = .{ .undo = route.repo_undo }; 514 } 515 516 fn headerBox(self: *View) *wgt.Box(ui.Widget) { 517 return &self.box.children.values()[header_index].widget.box; 518 } 519 fn headerActive(self: *View) bool { 520 return self.box.getFocus().child_id == self.headerBox().getFocus().id; 521 } 522 fn contentBox(self: *View) *wgt.Box(ui.Widget) { 523 return &self.box.children.values()[content_index].widget.box; 524 } 525 fn listScroll(self: *View) *wgt.Scroll(ui.Widget) { 526 return &self.contentBox().children.values()[0].widget.scroll; 527 } 528 fn detailScroll(self: *View) *wgt.Scroll(ui.Widget) { 529 return &self.contentBox().children.values()[1].widget.scroll; 530 } 531 fn selected(self: *View) ?usize { 532 const rows = &self.listScroll().child.box; 533 const index = rows.children.getIndex(rows.getFocus().child_id orelse return null) orelse return null; 534 return if (index < self.data.items.len) index else null; 535 } 536 fn detailActive(self: *View) bool { 537 return self.contentBox().getFocus().child_id == self.detailScroll().getFocus().id; 538 } 539 540 fn refreshDetail(self: *View, allocator: std.mem.Allocator) !void { 541 const index = self.selected() orelse return; 542 const failure = self.session.data.undo_failure orelse self.data.failure; 543 if (self.detailed_index == index and std.meta.eql(self.shown_failure, failure)) return; 544 const details = &self.detailScroll().child.box; 545 while (details.children.count() > fixed_rows) { 546 var entry = details.children.pop() orelse break; 547 entry.value.widget.deinit(allocator); 548 } 549 const item = self.data.items[index]; 550 const enabled = self.data.can_undo and item.index > 0; 551 552 // the fixed rows are filled first, since adding the ones below 553 // invalidates every pointer into the box 554 { 555 const button = &details.children.values()[0].widget.text_box; 556 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"); 557 button.getFocus().kind = if (enabled) .{ .custom = "submit" } else .text_box; 558 } 559 try details.children.values()[1].widget.text_box.setContent(allocator, item.timestamp); 560 561 // some actions put buttons where the description would go 562 if (item.buttons.len == 0) { 563 try addLabeled(allocator, details, " description ", item.description, null); 564 } else for (item.buttons) |detail_button| { 565 try addLabeled(allocator, details, detail_button.label, detail_button.text, detail_button.link); 566 } 567 if (failure) |message| try addText(allocator, details, try std.fmt.allocPrint(self.session.page_arena.allocator(), "error: {s}", .{message}), null, .single); 568 self.shown_failure = failure; 569 details.getFocus().kind = if (enabled) .{ .custom = item.form } else .container; 570 details.getFocus().child_id = details.children.keys()[0]; 571 self.detailScroll().x = 0; 572 self.detailScroll().y = 0; 573 self.detailScroll().getFocus().version +%= 1; 574 self.detailed_index = index; 575 } 576 577 pub fn build(self: *View, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 578 self.clearGrid(); 579 if (self.data.clear) return try self.box.build(allocator, constraint, root_focus); 580 try self.refreshDetail(allocator); 581 const rows = &self.listScroll().child.box; 582 if (self.data.items.len == 0) { 583 const failure = self.session.data.undo_failure orelse self.data.failure; 584 if (!std.meta.eql(self.shown_failure, failure)) { 585 const message = if (failure) |name| try std.fmt.allocPrint(self.session.page_arena.allocator(), "error: {s}", .{name}) else "no undo history"; 586 try rows.children.values()[0].widget.text_box.setContent(allocator, message); 587 self.shown_failure = failure; 588 } 589 } 590 for (rows.children.keys(), rows.children.values()) |id, *child| ui.widget.markSelected(&child.widget.text_box, rows.getFocus().child_id == id); 591 const both_fit = if (constraint.max_size.width) |width| width >= list_max_width + detail_min_width else true; 592 self.contentBox().children.values()[0].max_size = if (both_fit) .{ .width = list_max_width, .height = null } else null; 593 const width = if (constraint.max_size.width) |value| if (both_fit) value - list_max_width else value else detail_min_width; 594 self.contentBox().children.values()[1].min_size = .{ .width = width, .height = null }; 595 for (self.detailScroll().child.box.children.values()) |*child| child.max_size = .{ .width = width -| 2, .height = null }; 596 try self.box.build(allocator, constraint, root_focus); 597 } 598 599 pub fn input(self: *View, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 600 // the sub header sits above whichever content the route selects. 601 // scrolling crosses the boundary the same way the arrow keys do 602 const direction = inp.vertDirection(key); 603 if (self.headerActive()) { 604 if (direction == .down) root_focus.setFocus(self.box.children.values()[content_index].widget.getFocus().id); 605 return; 606 } 607 if (self.data.clear) { 608 if (direction == .up) root_focus.setFocus(self.headerBox().getFocus().id) else try self.clearInput(key, root_focus); 609 return; 610 } 611 if (direction == .up and self.contentAtTop()) { 612 root_focus.setFocus(self.headerBox().getFocus().id); 613 return; 614 } 615 try self.refreshDetail(allocator); 616 if (self.detailActive()) { 617 const details = &self.detailScroll().child.box; 618 if (self.selected()) |index| { 619 if (activated(root_focus, details.children.keys()[0], key) and self.data.can_undo and self.data.items[index].index > 0) { 620 self.requestUndo(ui.RoutablePage.repoUndoRoute(self.data.identity, self.data.items[index].index) orelse return error.RouteTooLong); 621 return; 622 } 623 } 624 if (key == .arrow_left) root_focus.setFocus(self.listScroll().getFocus().id) else if (inp.rowDelta(key, @intCast(details.children.count()))) |delta| ui.widget.moveRowFocus(details, self.detailScroll(), root_focus, delta); 625 } else if (inp.rowDelta(key, @intCast(self.listScroll().child.box.children.count()))) |delta| { 626 ui.widget.moveRowFocus(&self.listScroll().child.box, self.listScroll(), root_focus, delta); 627 } else switch (key) { 628 .arrow_right, .enter => if (self.selected() != null) { 629 root_focus.setFocus(self.detailScroll().getFocus().id); 630 }, 631 else => {}, 632 } 633 } 634 635 // the confirm button submits the form the web renderer posts 636 fn clearInput(self: *View, key: Key, root_focus: *Focus) !void { 637 const center = &self.box.children.values()[content_index].widget.center; 638 const button_id = center.child.box.getFocus().child_id orelse return; 639 if (!activated(root_focus, button_id, key) or !self.data.can_undo) return; 640 self.requestUndo(ui.RoutablePage.repoUndoClearRoute(self.data.identity) orelse return error.RouteTooLong); 641 } 642 643 fn contentAtTop(self: *View) bool { 644 const scroll = if (self.detailActive()) self.detailScroll() else self.listScroll(); 645 const rows = &scroll.child.box; 646 const id = rows.getFocus().child_id orelse return true; 647 return rows.children.getIndex(id) == 0 and scroll.y == 0; 648 } 649 650 // moving up from the sub header returns to the page's tabs 651 pub fn atTop(self: *View) bool { 652 return self.headerActive(); 653 } 654 655 // arriving from the page's tabs lands on the sub header, not the content 656 pub fn focusHeader(self: *View, root_focus: *Focus) bool { 657 root_focus.setFocus(self.headerBox().getFocus().id); 658 return true; 659 } 660 pub fn deinit(self: *View, allocator: std.mem.Allocator) void { 661 self.box.deinit(allocator); 662 } 663 pub fn clearGrid(self: *View) void { 664 self.box.clearGrid(); 665 } 666 pub fn getGrid(self: View) ?Grid { 667 return self.box.getGrid(); 668 } 669 pub fn getFocus(self: *View) *Focus { 670 return self.box.getFocus(); 671 } 672 }; 673