╭─────────────╮ │scroll to top│ ╰─────────────╯ ╭ Undo.zig ─────────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
277 const DB = rp.Repo(.xit, opts).DB; 278 const haxy_moment = evt.currentMomentFromRepoMoment(opts.hash, moment) catch return null; 279 const index = try momentIndex(opts, haxy_moment) orelse return null; 280 281 // a transaction that consumed nothing inherits the moment it was cloned 282 // from, whose events belong to the transaction that wrote it 283 inherited: { 284 if (history_index == 0) break :inherited; 285 const previous = evt.currentMomentFromRepoMoment(opts.hash, try core.momentAt(history_index - 1)) catch break :inherited; 286 if (try momentIndex(opts, previous)) |value| if (value == index) return 0; 287 } 288 289 const ids = try evt.momentEventIds(DB, opts.hash, haxy_moment, index) orelse return 0; 290 return try ids.count(); 291 } 292 293 // the oldest transaction `record` discarded, or null when it is not an undo 294 fn undoneFrom(arena: *std.heap.ArenaAllocator, record: ?xit.undo.Record) !?u64 { 295 const value = record orelse return null; 296 if (!std.mem.eql(u8, value.action_kind, undo_action_kind)) return null; 297 const parsed = std.json.parseFromSlice(xit.undo.Undo, arena.child_allocator, value.payload, .{ .ignore_unknown_fields = true }) catch |err| switch (err) { 298 error.OutOfMemory => return err, 299 else => return null, 300 }; 301 defer parsed.deinit(); 302 return parsed.value.index; 303 } 304 305 // the moment's own position in the haxy history 306 fn momentIndex(comptime opts: rp.RepoOpts(.xit), haxy_moment: evt.EventDB(opts.hash).HashMap(.read_only)) !?u64 { 307 const cursor = try haxy_moment.getCursor(hash.hashInt(opts.hash, evt.moment_index_key)) orelse return null; 308 return try cursor.readUint(); 309 } 310 311 pub fn formatTimestamp(aa: std.mem.Allocator, timestamp: i64) ![]const u8 { 312 if (timestamp < 0) return aa.dupe(u8, "timestamp unavailable"); 313 const seconds = std.time.epoch.EpochSeconds{ .secs = @intCast(timestamp) }; 314 const year = seconds.getEpochDay().calculateYearDay(); 315 const month = year.calculateMonthDay(); 316 const day = seconds.getDaySeconds(); 317 const months = [_][]const u8{ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" }; 318 return std.fmt.allocPrint(aa, "{s} {d}, {d}, {d:0>2}:{d:0>2}:{d:0>2} UTC", .{ months[month.month.numeric() - 1], month.day_index + 1, year.year, day.getHoursIntoDay(), day.getMinutesIntoHour(), day.getSecondsIntoMinute() }); 319 } 320 321 pub fn execute(io: std.Io, allocator: std.mem.Allocator, source: ui.RepoSource, index: u64) !void { 322 if (source.repo_kind != .xit) return error.NotFound; 323 var any_repo = try rp.AnyRepo(.xit, .{}).open(io, allocator, source.localInitOpts()); 324 defer any_repo.deinit(io, allocator); 325 switch (any_repo) { 326 inline else => |*repo| try repo.undo(io, index), 327 } 328 } 329 330 // discard every earlier transaction by compacting the database. the patch 331 // revisions events point at are unreachable from the refs, so they are named 332 // as extra roots to survive the collection. 333 pub fn clearHistory(io: std.Io, allocator: std.mem.Allocator, source: ui.RepoSource) !void { 334 if (source.repo_kind != .xit) return error.NotFound; 335 var any_repo = try rp.AnyRepo(.xit, .{}).open(io, allocator, source.localInitOpts()); 336 defer any_repo.deinit(io, allocator); 337 switch (any_repo) { 338 inline else => |*repo| { 339 const opts = repo.self_repo_opts; 340 // a repo that has consumed no events has no revisions to protect 341 const roots = if (evt.currentMoment(opts, repo)) |moment| 342 try evt.PatchRev.gcRoots(rp.Repo(.xit, opts).DB, opts.hash, allocator, moment) 343 else |_| 344 &.{}; 345 defer allocator.free(roots); 346 _ = try repo.garbageCollect(io, allocator, .{ .extra_roots = roots }); 347 }, 348 } 349 } 350 351 // keep action failures on the current tab without rebuilding a stale route. 352 pub fn handleRequest(allocator: std.mem.Allocator, session: *ui.Session, target: ui.RoutablePage.RepoUndoRoute) void { 353 perform(allocator, session, target) catch |err| { 354 session.data.undo_failure = @errorName(err); 355 return; 356 }; 357 session.data.undo_failure = null; 358 } 359 360 // both terminal hosts resolve and authorize the request afresh before opening 361 // the repository; the web handler uses its matching http authorization path. 362 pub fn perform(allocator: std.mem.Allocator, session: *ui.Session, target: ui.RoutablePage.RepoUndoRoute) !void { 363 const identity = target.name.slice(); 364 const index = if (target.clear) null else target.index orelse return error.InvalidHistoryIndex; 365 const source = try authorizedSource(session, identity) orelse return error.Forbidden; 366 const io = session.io orelse return error.NotFound; 367 if (index) |value| try execute(io, allocator, source, value) else try clearHistory(io, allocator, source); 368 try session.navigate(ui.RoutablePage.repoUndoRoute(identity, null) orelse return error.RouteTooLong); 369 } 370 371 // the repo an owner may act on, or null when they may not 372 fn authorizedSource(session: *ui.Session, identity: []const u8) !?ui.RepoSource { 373 if (try session.authorize(identity, .owner) == null) return null; 374 if (session.local) |local| return local; 375 const admin_repo = session.admin_repo orelse return error.NotFound; 376 const moment = try evt.currentMoment(evt.admin_repo_opts, admin_repo); 377 const pair = ui.RoutablePage.RepoIdentity.parse(identity) orelse return error.NotFound; 378 const io = session.io orelse return error.NotFound; 379 const repos_dir = session.repos_dir orelse return error.NotFound; 380 const found = try evt.readRepoByOwnerAndName(io, session.page_arena.child_allocator, session.page_arena, moment, repos_dir, pair.owner, pair.name) orelse return error.NotFound; 381 const hex = std.fmt.bytesToHex(found.event_id, .lower); 382 return .{ .path = try std.fs.path.join(session.page_arena.allocator(), &.{ repos_dir, &hex }), .repo_kind = .xit }; 383 } 384 385 pub const View = struct { 386 box: wgt.Box(ui.Widget), 387 data: *const Self, 388 session: *ui.Session, 389 detailed_index: ?usize = null, 390 shown_failure: ?[]const u8 = null, 391 392 const list_max_width = 20; 393 const detail_min_width = 40; 394 const header_index = 0; 395 const content_index = 1; 396 // the undo button and the timestamp; everything below them is rebuilt 397 const fixed_rows = 2; 398 399 pub fn init(allocator: std.mem.Allocator, data: *const Self, session: *ui.Session) !View { 400 var outer = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); 401 errdefer outer.deinit(allocator); 402 403 // the sub header carries the tab's only action 404 { 405 var header = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .horiz }); 406 errdefer header.deinit(allocator); 407 const route = ui.RoutablePage.repoUndoClearRoute(data.identity) orelse return error.RouteTooLong; 408 const link = try std.fmt.allocPrint(session.page_arena.allocator(), "a:{s}", .{try route.toUrl(session.page_arena)}); 409 try addText(allocator, &header, "clear undo history", link, .single); 410 header.getFocus().child_id = header.children.keys()[0]; 411 // a row taller than the header, leaving a blank line beneath it 412 try outer.children.put(allocator, header.getFocus().id, .{ .widget = .{ .box = header }, .rect = null, .min_size = .{ .width = null, .height = 4 } }); 413 } 414 415 if (data.clear) { 416 var center = try initClearForm(allocator, data, session); 417 errdefer center.deinit(allocator); 418 try outer.children.put(allocator, center.getFocus().id, .{ .widget = .{ .center = center }, .rect = null, .min_size = null }); 419 outer.getFocus().child_id = outer.children.keys()[content_index]; 420 return .{ .box = outer, .data = data, .session = session }; 421 } 422 423 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .horiz }); 424 errdefer box.deinit(allocator); 425 { 426 var scroll = blk: { 427 var rows = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert, .stretch = true }); 428 errdefer rows.deinit(allocator); 429 for (data.items) |item| { 430 try addText(allocator, &rows, item.action, item.link, .hidden); 431 // a transaction a later undo discarded says so on its border 432 if (item.undone) rows.children.values()[rows.children.count() - 1].widget.text_box.options.bottom_label = "(undone)"; 433 } 434 if (data.next) |next| { 435 const route = ui.RoutablePage.repoUndoRoute(data.identity, next) orelse return error.RouteTooLong; 436 try addText(allocator, &rows, "next →", try std.fmt.allocPrint(session.page_arena.allocator(), "a:{s}", .{try route.toUrl(session.page_arena)}), .hidden); 437 } 438 if (data.items.len == 0) try addText(allocator, &rows, "no undo history", null, .hidden); 439 rows.getFocus().child_id = rows.children.keys()[0]; 440 break :blk try wgt.Scroll(ui.Widget).init(allocator, .{ .box = rows }, .{ .direction = .vert, .web_native = !session.is_terminal, .fill = true }); 441 }; 442 errdefer scroll.deinit(allocator); 443 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 } }); 444 } 445 { 446 var scroll = blk: { 447 var details = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); 448 errdefer details.deinit(allocator); 449 if (data.items.len > 0) { 450 try addText(allocator, &details, "", null, .single); 451 try addLabeled(allocator, &details, " timestamp ", "", null); 452 details.getFocus().child_id = details.children.keys()[0]; 453 } 454 break :blk try wgt.Scroll(ui.Widget).init(allocator, .{ .box = details }, .{ .direction = .vert, .web_native = !session.is_terminal, .fill = true }); 455 }; 456 errdefer scroll.deinit(allocator); 457 scroll.getFocus().mode = .mouse; 458 try box.children.put(allocator, scroll.getFocus().id, .{ .widget = .{ .scroll = scroll }, .rect = null, .min_size = .{ .width = detail_min_width, .height = null } }); 459 } 460 box.getFocus().child_id = box.children.keys()[0]; 461 try outer.children.put(allocator, box.getFocus().id, .{ .widget = .{ .box = box }, .rect = null, .min_size = null }); 462 outer.getFocus().child_id = outer.children.keys()[content_index]; 463 return .{ .box = outer, .data = data, .session = session }; 464 } 465 466 // the same shape the thread views use to confirm a removal 467 fn initClearForm(allocator: std.mem.Allocator, data: *const Self, session: *ui.Session) !ui.widget.Center { 468 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .round_corners = true, .direction = .vert }); 469 errdefer box.deinit(allocator); 470 const route = ui.RoutablePage.repoUndoClearRoute(data.identity) orelse return error.RouteTooLong; 471 box.getFocus().kind = .{ .custom = try std.fmt.allocPrint(session.page_arena.allocator(), "form:{s}", .{try route.toUrl(session.page_arena)}) }; 472 473 var prompt = try wgt.Text.init(allocator, "are you sure?"); 474 errdefer prompt.deinit(allocator); 475 try box.children.put(allocator, prompt.getFocus().id, .{ .widget = .{ .text = prompt }, .rect = null, .min_size = null }); 476 477 var button = try wgt.TextBox.init(allocator, "clear undo history", .{ .border_style = .single, .round_corners = true, .wrap_kind = .none }); 478 errdefer button.deinit(allocator); 479 button.getFocus().mode = .all; 480 button.getFocus().kind = if (data.can_undo) .{ .custom = "submit" } else .text_box; 481 try box.children.put(allocator, button.getFocus().id, .{ .widget = .{ .text_box = button }, .rect = null, .min_size = null }); 482 box.getFocus().child_id = button.getFocus().id; 483 484 return ui.widget.Center.init(allocator, .{ .box = box }); 485 } 486 487 fn addText(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), text: []const u8, link: ?[]const u8, border: wgt.BorderStyle) !void { 488 var row = try wgt.TextBox.init(allocator, text, .{ .border_style = border, .round_corners = true, .wrap_kind = .word }); 489 errdefer row.deinit(allocator); 490 row.getFocus().mode = .all; 491 if (link) |value| row.getFocus().kind = .{ .custom = value }; 492 try box.children.put(allocator, row.getFocus().id, .{ .widget = .{ .text_box = row }, .rect = null, .min_size = null }); 493 } 494 495 // a labeled row below the timestamp, added as the selected action needs it 496 fn addLabeled(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), label: []const u8, text: []const u8, link: ?[]const u8) !void { 497 try addText(allocator, box, text, link, .single); 498 box.children.values()[box.children.count() - 1].widget.text_box.options.label = label; 499 } 500 501 // enter counts only on the focused button; a click counts anywhere on it 502 fn activated(root_focus: *Focus, button_id: usize, key: Key) bool { 503 return switch (key) { 504 .enter => root_focus.grandchild_id == button_id, 505 .mouse => |mouse| inp.leftClickOn(root_focus, button_id, mouse), 506 else => false, 507 }; 508 } 509 510 // the terminal hosts run the action after rendering; the web renderer 511 // posts the form instead, and wasm has no repository to act on 512 fn requestUndo(self: *View, route: ui.RoutablePage) void { 513 if (builtin.target.cpu.arch == .wasm32) return; 514 if (!self.session.is_terminal or self.session.host_request != null) return; 515 self.session.host_request = .{ .undo = route.repo_undo }; 516 } 517 518 fn headerBox(self: *View) *wgt.Box(ui.Widget) { 519 return &self.box.children.values()[header_index].widget.box; 520 } 521 fn headerActive(self: *View) bool { 522 return self.box.getFocus().child_id == self.headerBox().getFocus().id; 523 } 524 fn contentBox(self: *View) *wgt.Box(ui.Widget) { 525 return &self.box.children.values()[content_index].widget.box; 526 } 527 fn listScroll(self: *View) *wgt.Scroll(ui.Widget) { 528 return &self.contentBox().children.values()[0].widget.scroll; 529 } 530 fn detailScroll(self: *View) *wgt.Scroll(ui.Widget) { 531 return &self.contentBox().children.values()[1].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.detailScroll().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.can_undo 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 try details.children.values()[1].widget.text_box.setContent(allocator, item.timestamp); 562 563 // some actions put buttons where the description would go 564 if (item.buttons.len == 0) { 565 try addLabeled(allocator, details, " description ", item.description, null); 566 } else for (item.buttons) |detail_button| { 567 try addLabeled(allocator, details, detail_button.label, detail_button.text, detail_button.link); 568 } 569 if (failure) |message| try addText(allocator, details, try std.fmt.allocPrint(self.session.page_arena.allocator(), "error: {s}", .{message}), null, .single); 570 self.shown_failure = failure; 571 details.getFocus().kind = if (enabled) .{ .custom = item.form } else .container; 572 details.getFocus().child_id = details.children.keys()[0]; 573 self.detailScroll().x = 0; 574 self.detailScroll().y = 0; 575 self.detailScroll().getFocus().version +%= 1; 576 self.detailed_index = index; 577 } 578 579 pub fn build(self: *View, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 580 self.clearGrid(); 581 if (self.data.clear) return try self.box.build(allocator, constraint, root_focus); 582 try self.refreshDetail(allocator); 583 const rows = &self.listScroll().child.box; 584 if (self.data.items.len == 0) { 585 const failure = self.session.data.undo_failure orelse self.data.failure; 586 if (!std.meta.eql(self.shown_failure, failure)) { 587 const message = if (failure) |name| try std.fmt.allocPrint(self.session.page_arena.allocator(), "error: {s}", .{name}) else "no undo history"; 588 try rows.children.values()[0].widget.text_box.setContent(allocator, message); 589 self.shown_failure = failure; 590 } 591 } 592 for (rows.children.keys(), rows.children.values()) |id, *child| ui.widget.markSelected(&child.widget.text_box, rows.getFocus().child_id == id); 593 const both_fit = if (constraint.max_size.width) |width| width >= list_max_width + detail_min_width else true; 594 self.contentBox().children.values()[0].max_size = if (both_fit) .{ .width = list_max_width, .height = null } else null; 595 const width = if (constraint.max_size.width) |value| if (both_fit) value - list_max_width else value else detail_min_width; 596 self.contentBox().children.values()[1].min_size = .{ .width = width, .height = null }; 597 for (self.detailScroll().child.box.children.values()) |*child| child.max_size = .{ .width = width -| 2, .height = null }; 598 try self.box.build(allocator, constraint, root_focus); 599 } 600 601 pub fn input(self: *View, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 602 // the sub header sits above whichever content the route selects. 603 // scrolling crosses the boundary the same way the arrow keys do 604 const direction = inp.vertDirection(key); 605 if (self.headerActive()) { 606 if (direction == .down) root_focus.setFocus(self.box.children.values()[content_index].widget.getFocus().id); 607 return; 608 } 609 if (self.data.clear) { 610 if (direction == .up) root_focus.setFocus(self.headerBox().getFocus().id) else try self.clearInput(key, root_focus); 611 return; 612 } 613 if (direction == .up and self.contentAtTop()) { 614 root_focus.setFocus(self.headerBox().getFocus().id); 615 return; 616 } 617 try self.refreshDetail(allocator); 618 if (self.detailActive()) { 619 const details = &self.detailScroll().child.box; 620 if (self.selected()) |index| { 621 if (activated(root_focus, details.children.keys()[0], key) and self.data.can_undo and self.data.items[index].index > 0) { 622 self.requestUndo(ui.RoutablePage.repoUndoRoute(self.data.identity, self.data.items[index].index) orelse return error.RouteTooLong); 623 return; 624 } 625 } 626 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); 627 } else if (inp.rowDelta(key, @intCast(self.listScroll().child.box.children.count()))) |delta| { 628 ui.widget.moveRowFocus(&self.listScroll().child.box, self.listScroll(), root_focus, delta); 629 } else switch (key) { 630 .arrow_right, .enter => if (self.selected() != null) { 631 root_focus.setFocus(self.detailScroll().getFocus().id); 632 }, 633 else => {}, 634 } 635 } 636 637 // the confirm button submits the form the web renderer posts 638 fn clearInput(self: *View, key: Key, root_focus: *Focus) !void { 639 const center = &self.box.children.values()[content_index].widget.center; 640 const button_id = center.child.box.getFocus().child_id orelse return; 641 if (!activated(root_focus, button_id, key) or !self.data.can_undo) return; 642 self.requestUndo(ui.RoutablePage.repoUndoClearRoute(self.data.identity) orelse return error.RouteTooLong); 643 } 644 645 fn contentAtTop(self: *View) bool { 646 const scroll = if (self.detailActive()) self.detailScroll() else self.listScroll(); 647 const rows = &scroll.child.box; 648 const id = rows.getFocus().child_id orelse return true; 649 return rows.children.getIndex(id) == 0 and scroll.y == 0; 650 } 651 652 // moving up from the sub header returns to the page's tabs 653 pub fn atTop(self: *View) bool { 654 return self.headerActive(); 655 } 656 657 // arriving from the page's tabs lands on the sub header, not the content 658 pub fn focusHeader(self: *View, root_focus: *Focus) bool { 659 root_focus.setFocus(self.headerBox().getFocus().id); 660 return true; 661 } 662 pub fn deinit(self: *View, allocator: std.mem.Allocator) void { 663 self.box.deinit(allocator); 664 } 665 pub fn clearGrid(self: *View) void { 666 self.box.clearGrid(); 667 } 668 pub fn getGrid(self: View) ?Grid { 669 return self.box.getGrid(); 670 } 671 pub fn getFocus(self: *View) *Focus { 672 return self.box.getFocus(); 673 } 674 }; 675