╭ changed ───────────────╮ │src/ui/widget/thread.zig│ ╰────────────────────────╯ ╭───────────╮ │← all files│ ╰───────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ 344 return self.tab_ids.getIndex(child_id); │ │ 345 } │ │ 346 }; │ │ 347 + │ │ 348 +// the selected thread's scrollable contents, shared by repo and fork views │ │ 349 +pub fn Detail(comptime kind: evt.EventKind, comptime Data: type) type { │ │ 350 + const Entry = Data.Entry; │ │ 351 + const Event = Data.Event; │ │ 352 + const Status = Data.Status; │ │ 353 + const has_status = @hasField(Event, "status"); │ │ 354 + const supports_conflicts = Event.merge_policy == .field_conflicts; │ │ 355 + const supports_drafts = @hasField(Data, "drafts"); │ │ 356 + const supports_forks = @hasField(Entry, "fork_exists"); │ │ 357 + return struct { │ │ 358 + const This = @This(); │ │ 359 + const thread_name = Data.thread_name; │ │ 360 + │ │ 361 + pub const Exit = enum { none, list, header }; │ │ 362 + pub const Options = struct { actions: bool = true }; │ │ 363 + │ │ 364 + scroll: wgt.Scroll(Widget), │ │ 365 + data: *const Data, │ │ 366 + session: *ui.Session, │ │ 367 + entry: ?Entry, │ │ 368 + title_id: ?usize, │ │ 369 + description_id: ?usize, │ │ 370 + author_id: ?usize, │ │ 371 + actions: bool, │ │ 372 + exit: Exit, │ │ 373 + │ │ 374 + const tool_row_index: usize = 0; │ │ 375 + │ │ 376 + pub fn init(allocator: std.mem.Allocator, data: *const Data, session: *ui.Session, entry: ?Entry, options: Options) !This { │ │ 377 + var content = try wgt.Box(Widget).init(allocator, .{ .border_style = null, .direction = .vert }); │ │ 378 + errdefer content.deinit(allocator); │ │ 379 + var detail_scroll = try wgt.Scroll(Widget).init(allocator, .{ .box = content }, .{ .direction = .vert, .web_native = !session.is_terminal, .fill = true }); │ │ 380 + errdefer detail_scroll.deinit(allocator); │ │ 381 + │ │ 382 + var self: This = .{ │ │ 383 + .scroll = detail_scroll, │ │ 384 + .data = data, │ │ 385 + .session = session, │ │ 386 + .entry = null, │ │ 387 + .title_id = null, │ │ 388 + .description_id = null, │ │ 389 + .author_id = null, │ │ 390 + .actions = options.actions, │ │ 391 + .exit = .none, │ │ 392 + }; │ │ 393 + if (entry) |value| try self.setEntry(allocator, value); │ │ 394 + return self; │ │ 395 + } │ │ 396 + │ │ 397 + pub fn deinit(self: *This, allocator: std.mem.Allocator) void { │ │ 398 + self.scroll.deinit(allocator); │ │ 399 + } │ │ 400 + │ │ 401 + pub fn build(self: *This, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { │ │ 402 + self.clearGrid(); │ │ 403 + try self.scroll.build(allocator, constraint, root_focus); │ │ 404 + } │ │ 405 + │ │ 406 + pub fn input(self: *This, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { │ │ 407 + _ = try self.inputWithExit(allocator, key, root_focus); │ │ 408 + } │ │ 409 + │ │ 410 + pub fn clearGrid(self: *This) void { │ │ 411 + self.scroll.clearGrid(); │ │ 412 + } │ │ 413 + │ │ 414 + pub fn getGrid(self: This) ?Grid { │ │ 415 + return self.scroll.getGrid(); │ │ 416 + } │ │ 417 + │ │ 418 + pub fn getFocus(self: *This) *Focus { │ │ 419 + return self.scroll.getFocus(); │ │ 420 + } │ │ 421 + │ │ 422 + fn inner(self: *This) *wgt.Box(Widget) { │ │ 423 + return &self.scroll.child.box; │ │ 424 + } │ │ 425 + │ │ 426 + fn toolRow(self: *This) *wgt.Box(Widget) { │ │ 427 + return &self.inner().children.values()[tool_row_index].widget.box; │ │ 428 + } │ │ 429 + │ │ 430 + fn entryConflicted(entry: Entry) bool { │ │ 431 + return if (supports_conflicts) entry.conflicted else false; │ │ 432 + } │ │ 433 + │ │ 434 + fn entryStatus(entry: Entry) Status { │ │ 435 + return if (has_status) entry.record.event.status else @enumFromInt(0); │ │ 436 + } │ │ 437 + │ │ 438 + fn entryDraft(entry: Entry) bool { │ │ 439 + return if (supports_drafts) entry.draft else false; │ │ 440 + } │ │ 441 + │ │ 442 + fn entryHasFork(entry: Entry) bool { │ │ 443 + return if (supports_forks) entry.fork_exists else false; │ │ 444 + } │ │ 445 + │ │ 446 + const StatusChange = struct { │ │ 447 + action: []const u8, │ │ 448 + status: Status, │ │ 449 + }; │ │ 450 + │ │ 451 + fn statusChange(status: Status) ?StatusChange { │ │ 452 + const name = @tagName(status); │ │ 453 + if (std.mem.eql(u8, name, "open")) return .{ │ │ 454 + .action = "close", │ │ 455 + .status = std.meta.stringToEnum(Status, "closed") orelse return null, │ │ 456 + }; │ │ 457 + if (std.mem.eql(u8, name, "closed")) return .{ │ │ 458 + .action = "open", │ │ 459 + .status = std.meta.stringToEnum(Status, "open") orelse return null, │ │ 460 + }; │ │ 461 + return null; │ │ 462 + } │ │ 463 + │ │ 464 + fn listRoute(identity: []const u8, status: Status, tag: []const u8, selected: []const u8) ?ui.RoutablePage { │ │ 465 + return Data.listRoute(identity, status, tag, selected); │ │ 466 + } │ │ 467 + │ │ 468 + fn draftsRoute(identity: []const u8) ?ui.RoutablePage { │ │ 469 + return if (supports_drafts) Data.draftsRoute(identity) else null; │ │ 470 + } │ │ 471 + │ │ 472 + fn forkRoute(identity: []const u8, id: []const u8) ?ui.RoutablePage { │ │ 473 + return if (supports_forks) Data.forkRoute(identity, id) else null; │ │ 474 + } │ │ 475 + │ │ 476 + fn resolveRoute(identity: []const u8, selected: []const u8, picks: []const u8) ?ui.RoutablePage { │ │ 477 + return if (supports_conflicts) Data.resolveRoute(identity, selected, picks) else null; │ │ 478 + } │ │ 479 + │ │ 480 + fn listLink(page_arena: *std.heap.ArenaAllocator, identity: []const u8, status: Status, tag: []const u8, id: []const u8) ![]const u8 { │ │ 481 + const route = listRoute(identity, status, tag, id) orelse return error.RouteTooLong; │ │ 482 + return std.fmt.allocPrint(page_arena.allocator(), "a:{s}", .{try route.toUrl(page_arena)}); │ │ 483 + } │ │ 484 + │ │ 485 + fn tagLink(page_arena: *std.heap.ArenaAllocator, identity: []const u8, status: Status, tag: []const u8) ![]const u8 { │ │ 486 + const encoded = try ui.urlEncodeRef(page_arena.allocator(), tag); │ │ 487 + return listLink(page_arena, identity, status, encoded, ""); │ │ 488 + } │ │ 489 + │ │ 490 + fn addToolButton(allocator: std.mem.Allocator, row: *wgt.Box(Widget), label: []const u8, action: []const u8) !void { │ │ 491 + var button = try wgt.TextBox.init(allocator, label, .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .none }); │ │ 492 + errdefer button.deinit(allocator); │ │ 493 + button.getFocus().focusable = true; │ │ 494 + button.getFocus().kind = .{ .custom = action }; │ │ 495 + try row.children.put(allocator, button.getFocus().id, .{ .widget = .{ .text_box = button }, .rect = null, .min_size = .{ .width = try xitui.width.displayWidth(label) + 2, .height = null } }); │ │ 496 + } │ │ 497 + │ │ 498 + pub fn setEntry(self: *This, allocator: std.mem.Allocator, entry: Entry) !void { │ │ 499 + const inner_box = self.inner(); │ │ 500 + const description_page = self.data.description_page and std.mem.eql(u8, entry.id, self.data.selected_id); │ │ 501 + const comment_page = if (std.mem.eql(u8, entry.id, self.data.selected_id)) self.data.comment_page else null; │ │ 502 + │ │ 503 + for (inner_box.children.values()) |*child| child.widget.deinit(allocator); │ │ 504 + inner_box.children.clearAndFree(allocator); │ │ 505 + inner_box.getFocus().child_id = null; │ │ 506 + self.entry = entry; │ │ 507 + self.title_id = null; │ │ 508 + self.author_id = null; │ │ 509 + │ │ 510 + { │ │ 511 + var row = try wgt.Box(Widget).init(allocator, .{ .border_style = null, .direction = .horiz }); │ │ 512 + errdefer row.deinit(allocator); │ │ 513 + try inner_box.children.put(allocator, row.getFocus().id, .{ .widget = .{ .box = row }, .rect = null, .min_size = null }); │ │ 514 + } │ │ 515 + if (self.actions and !description_page and comment_page == null) { │ │ 516 + const row = self.toolRow(); │ │ 517 + const pa = self.session.page_arena.allocator(); │ │ 518 + │ │ 519 + if (entryHasFork(entry) and self.session.data.current_page.parent() != .fork) { │ │ 520 + const route = forkRoute(self.data.identity, entry.id) orelse return error.RouteTooLong; │ │ 521 + try addToolButton(allocator, row, "view fork", try std.fmt.allocPrint(pa, "a:{s}", .{try route.toUrl(self.session.page_arena)})); │ │ 522 + } │ │ 523 + │ │ 524 + { │ │ 525 + var spacer = try Spacer.init(allocator); │ │ 526 + errdefer spacer.deinit(allocator); │ │ 527 + try row.children.put(allocator, spacer.getFocus().id, .{ .widget = .{ .spacer = spacer }, .rect = null, .min_size = null }); │ │ 528 + } │ │ 529 + │ │ 530 + if (supports_drafts and entryDraft(entry)) { │ │ 531 + if (entry.revision_ready) { │ │ 532 + const route = listRoute(self.data.identity, @enumFromInt(0), "", entry.id) orelse return error.RouteTooLong; │ │ 533 + row.getFocus().kind = .{ .custom = try std.fmt.allocPrint(pa, "form:{s}/publish", .{try route.toUrl(self.session.page_arena)}) }; │ │ 534 + try addToolButton(allocator, row, "publish", "submit"); │ │ 535 + } │ │ 536 + if (self.session.data.user_id != null) { │ │ 537 + const edit_route = ui.RoutablePage.repoThreadEditRoute(kind, self.data.identity, entry.id) orelse return error.RouteTooLong; │ │ 538 + try addToolButton(allocator, row, "edit", try std.fmt.allocPrint(pa, "a:{s}", .{try edit_route.toUrl(self.session.page_arena)})); │ │ 539 + const remove_route = ui.RoutablePage.repoThreadRemoveRoute(kind, self.data.identity, entry.id, "") orelse return error.RouteTooLong; │ │ 540 + try addToolButton(allocator, row, "✕", try std.fmt.allocPrint(pa, "a:{s}", .{try remove_route.toUrl(self.session.page_arena)})); │ │ 541 + } │ │ 542 + } else { │ │ 543 + if (!self.session.is_terminal and !entryConflicted(entry) and (self.session.data.is_local or self.session.data.user_id != null)) { │ │ 544 + const label = "add attachment"; │ │ 545 + const action = if (self.data.identity.len == 0) │ │ 546 + try std.fmt.allocPrint(pa, "{s}/{s}:{s}/attach", .{ ui.file_input_prefix, @tagName(kind), entry.id }) │ │ 547 + else │ │ 548 + try std.fmt.allocPrint(pa, "{s}/repo/{s}/{s}:{s}/attach", .{ ui.file_input_prefix, self.data.identity, @tagName(kind), entry.id }); │ │ 549 + try addToolButton(allocator, row, label, action); │ │ 550 + } │ │ 551 + │ │ 552 + if (supports_conflicts and entryConflicted(entry)) { │ │ 553 + const route = resolveRoute(self.data.identity, entry.id, "") orelse return error.RouteTooLong; │ │ 554 + try addToolButton(allocator, row, "resolve conflict", try std.fmt.allocPrint(pa, "a:{s}", .{try route.toUrl(self.session.page_arena)})); │ │ 555 + } else { │ │ 556 + if (has_status and (self.session.data.is_local or self.session.data.user_id != null)) { │ │ 557 + if (statusChange(entryStatus(entry))) |change| { │ │ 558 + const route = ui.RoutablePage.repoThreadCommentsRoute(kind, self.data.identity, entry.id, 0) orelse return error.RouteTooLong; │ │ 559 + row.getFocus().kind = .{ .custom = try std.fmt.allocPrint(pa, "form:{s}/{s}", .{ try route.toUrl(self.session.page_arena), change.action }) }; │ │ 560 + try addToolButton(allocator, row, change.action, "submit"); │ │ 561 + } │ │ 562 + } │ │ 563 + │ │ 564 + const route = ui.RoutablePage.repoThreadEditRoute(kind, self.data.identity, entry.id) orelse return error.RouteTooLong; │ │ 565 + try addToolButton(allocator, row, "edit", try std.fmt.allocPrint(pa, "a:{s}", .{try route.toUrl(self.session.page_arena)})); │ │ 566 + } │ │ 567 + │ │ 568 + if (self.session.data.is_local or self.session.data.user_id != null) { │ │ 569 + const route = ui.RoutablePage.repoThreadRemoveRoute(kind, self.data.identity, entry.id, "") orelse return error.RouteTooLong; │ │ 570 + try addToolButton(allocator, row, "✕", try std.fmt.allocPrint(pa, "a:{s}", .{try route.toUrl(self.session.page_arena)})); │ │ 571 + } │ │ 572 + } │ │ 573 + │ │ 574 + for (row.children.keys(), row.children.values()) |id, *child| { │ │ 575 + if (!child.widget.getFocus().focusable) continue; │ │ 576 + row.getFocus().child_id = id; │ │ 577 + break; │ │ 578 + } │ │ 579 + } │ │ 580 + │ │ 581 + if (supports_drafts and !description_page and comment_page == null) │ │ 582 + try self.data.appendDetails(allocator, inner_box, self.session, entry); │ │ 583 + │ │ 584 + if (comment_page) |comment_view| { │ │ 585 + var back_label_buf: ["← back to ".len + thread_name.len]u8 = undefined; │ │ 586 + const back_label = try std.fmt.bufPrint(&back_label_buf, "← back to {s}", .{thread_name}); │ │ 587 + var back = try Comment.linkBox(allocator, self.session, back_label, ui.RoutablePage.repoThreadCommentsRoute(kind, self.data.identity, entry.id, 0) orelse return error.RouteTooLong); │ │ 588 + errdefer back.deinit(allocator); │ │ 589 + try inner_box.children.put(allocator, back.getFocus().id, .{ .widget = .{ .text_box = back }, .rect = null, .min_size = null }); │ │ 590 + │ │ 591 + try Comment.appendComment(allocator, inner_box, self.session, self.data.identity, kind, comment_view.selected); │ │ 592 + try Comment.appendCount(allocator, inner_box, comment_view.replies.count, "reply", "replies"); │ │ 593 + for (comment_view.replies.comments) |comment| try Comment.appendComment(allocator, inner_box, self.session, self.data.identity, kind, comment); │ │ 594 + try Comment.appendWindowNav(allocator, inner_box, self.session, self.data.identity, kind, &comment_view.selected.comment.event.thread_id, &comment_view.selected.id, comment_view.replies); │ │ 595 + │ │ 596 + var spacer = try Spacer.init(allocator); │ │ 597 + errdefer spacer.deinit(allocator); │ │ 598 + try inner_box.children.put(allocator, spacer.getFocus().id, .{ .widget = .{ .spacer = spacer }, .rect = null, .min_size = null }); │ │ 599 + │ │ 600 + inner_box.getFocus().child_id = inner_box.children.keys()[tool_row_index + 1]; │ │ 601 + self.resetScroll(); │ │ 602 + return; │ │ 603 + } │ │ 604 + │ │ 605 + if (description_page) { │ │ 606 + var back_label_buf: ["← back to ".len + thread_name.len]u8 = undefined; │ │ 607 + const back_label = try std.fmt.bufPrint(&back_label_buf, "← back to {s}", .{thread_name}); │ │ 608 + var text_box = try wgt.TextBox.init(allocator, back_label, .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .none }); │ │ 609 + errdefer text_box.deinit(allocator); │ │ 610 + text_box.getFocus().focusable = true; │ │ 611 + text_box.getFocus().kind = .{ .custom = try listLink(self.session.page_arena, self.data.identity, entryStatus(entry), "", entry.id) }; │ │ 612 + try inner_box.children.put(allocator, text_box.getFocus().id, .{ .widget = .{ .text_box = text_box }, .rect = null, .min_size = null }); │ │ 613 + self.title_id = text_box.getFocus().id; │ │ 614 + } else { │ │ 615 + var title = try wgt.TextBox.init(allocator, entry.record.event.title, .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .word, .label = " title " }); │ │ 616 + errdefer title.deinit(allocator); │ │ 617 + title.getFocus().focusable = true; │ │ 618 + try inner_box.children.put(allocator, title.getFocus().id, .{ .widget = .{ .text_box = title }, .rect = null, .min_size = null }); │ │ 619 + self.title_id = title.getFocus().id; │ │ 620 + │ │ 621 + var author = try ui.authorBox(allocator, self.session.page_arena, entry.author); │ │ 622 + errdefer author.deinit(allocator); │ │ 623 + try inner_box.children.put(allocator, author.getFocus().id, .{ .widget = .{ .text_box = author }, .rect = null, .min_size = null }); │ │ 624 + self.author_id = author.getFocus().id; │ │ 625 + │ │ 626 + var items: std.ArrayList(TagFlow.Item) = .empty; │ │ 627 + defer items.deinit(allocator); │ │ 628 + var tag_iter = Event.tagIterator(entry.record.event.tags); │ │ 629 + while (tag_iter.next()) |tag| { │ │ 630 + if (tag.len == 0) continue; │ │ 631 + try items.append(allocator, .{ .text = tag, .link = try tagLink(self.session.page_arena, self.data.identity, entryStatus(entry), tag) }); │ │ 632 + } │ │ 633 + if (items.items.len > 0) { │ │ 634 + var tags = try TagFlow.init(allocator); │ │ 635 + errdefer tags.deinit(allocator); │ │ 636 + try tags.setItems(allocator, items.items); │ │ 637 + try inner_box.children.put(allocator, tags.getFocus().id, .{ .widget = .{ .tag_flow = tags }, .rect = null, .min_size = null }); │ │ 638 + } │ │ 639 + } │ │ 640 + │ │ 641 + const whole = entry.record.event.description; │ │ 642 + const cut_short = !description_page and whole.len > Data.max_description_size; │ │ 643 + const shown = if (cut_short) │ │ 644 + std.mem.trimEnd(u8, whole[0 .. std.mem.lastIndexOfScalar(u8, whole[0..Data.max_description_size], '\n') orelse 0], " \t\r\n") │ │ 645 + else if (whole.len == 0) │ │ 646 + "(no description)" │ │ 647 + else │ │ 648 + whole; │ │ 649 + var description = try wgt.TextBox.init(allocator, shown, .{ │ │ 650 + .border_style = .single, │ │ 651 + .rounded_corners = true, │ │ 652 + .wrap_kind = .word, │ │ 653 + .label = " description ", │ │ 654 + .bottom_label = if (cut_short) " click or press enter to see more " else "", │ │ 655 + }); │ │ 656 + errdefer description.deinit(allocator); │ │ 657 + description.getFocus().focusable = true; │ │ 658 + if (cut_short) { │ │ 659 + const route = ui.RoutablePage.repoThreadDescriptionRoute(kind, self.data.identity, entry.id) orelse return error.RouteTooLong; │ │ 660 + description.getFocus().kind = .{ .custom = try std.fmt.allocPrint(self.session.page_arena.allocator(), "a:{s}", .{try route.toUrl(self.session.page_arena)}) }; │ │ 661 + } │ │ 662 + try inner_box.children.put(allocator, description.getFocus().id, .{ .widget = .{ .text_box = description }, .rect = null, .min_size = null }); │ │ 663 + self.description_id = description.getFocus().id; │ │ 664 + │ │ 665 + if (!supports_drafts or !entryDraft(entry)) { │ │ 666 + const parent_route = ui.RoutablePage.repoThreadCommentsRoute(kind, self.data.identity, entry.id, 0) orelse return error.RouteTooLong; │ │ 667 + try Attachment.appendRows(allocator, inner_box, self.session, self.data.identity, try parent_route.toUrl(self.session.page_arena), entry.attachments); │ │ 668 + │ │ 669 + if (!description_page) { │ │ 670 + var reply = try Comment.linkBox(allocator, self.session, "new comment", ui.RoutablePage.repoThreadCommentNewRoute(kind, self.data.identity, entry.id, "") orelse return error.RouteTooLong);│ │ 671 + errdefer reply.deinit(allocator); │ │ 672 + try inner_box.children.put(allocator, reply.getFocus().id, .{ .widget = .{ .text_box = reply }, .rect = null, .min_size = null }); │ │ 673 + │ │ 674 + try Comment.appendCount(allocator, inner_box, entry.comments.count, "comment", "comments"); │ │ 675 + for (entry.comments.comments) |comment| try Comment.appendComment(allocator, inner_box, self.session, self.data.identity, kind, comment); │ │ 676 + try Comment.appendWindowNav(allocator, inner_box, self.session, self.data.identity, kind, entry.id, null, entry.comments); │ │ 677 + │ │ 678 + var spacer = try Spacer.init(allocator); │ │ 679 + errdefer spacer.deinit(allocator); │ │ 680 + try inner_box.children.put(allocator, spacer.getFocus().id, .{ .widget = .{ .spacer = spacer }, .rect = null, .min_size = null }); │ │ 681 + } │ │ 682 + } │ │ 683 + │ │ 684 + _ = self.focusFirst(null); │ │ 685 + self.resetScroll(); │ │ 686 + } │ │ 687 + │ │ 688 + fn resetScroll(self: *This) void { │ │ 689 + const detail_scroll = &self.scroll; │ │ 690 + detail_scroll.x = 0; │ │ 691 + detail_scroll.y = 0; │ │ 692 + detail_scroll.getFocus().version +%= 1; │ │ 693 + } │ │ 694 + │ │ 695 + pub fn inputWithExit(self: *This, allocator: std.mem.Allocator, raw_key: Key, root_focus: *Focus) !Exit { │ │ 696 + self.exit = .none; │ │ 697 + const key: Key = if (raw_key == .mouse and raw_key.mouse.action == .scroll) │ │ 698 + (if (raw_key.mouse.action.scroll == .up) .arrow_up else .arrow_down) │ │ 699 + else │ │ 700 + raw_key; │ │ 701 + │ │ 702 + switch (key) { │ │ 703 + .page_up => self.pageDetail(root_focus, -10), │ │ 704 + .page_down => self.pageDetail(root_focus, 10), │ │ 705 + .home => self.jump(root_focus, false), │ │ 706 + .end => self.jump(root_focus, true), │ │ 707 + else => { │ │ 708 + const entry = self.entry orelse return self.exit; │ │ 709 + if (self.data.comment_page != null and std.mem.eql(u8, entry.id, self.data.selected_id)) { │ │ 710 + self.commentInput(key, root_focus); │ │ 711 + } else if (self.toolRowFocused(root_focus)) { │ │ 712 + try self.toolRowInput(allocator, key, root_focus); │ │ 713 + } else { │ │ 714 + try self.childInput(allocator, key, root_focus); │ │ 715 + } │ │ 716 + }, │ │ 717 + } │ │ 718 + return self.exit; │ │ 719 + } │ │ 720 + │ │ 721 + fn childInput(self: *This, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { │ │ 722 + const child_index = self.focusedChild(root_focus) orelse return; │ │ 723 + const inner_box = self.inner(); │ │ 724 + const child_id = inner_box.children.keys()[child_index]; │ │ 725 + const child = &inner_box.children.values()[child_index].widget; │ │ 726 + │ │ 727 + if (child_id == self.title_id) { │ │ 728 + switch (key) { │ │ 729 + .arrow_left => self.exit = .list, │ │ 730 + .arrow_up => if (!self.moveVertical(root_focus, false)) self.focusToolRow(root_focus), │ │ 731 + .arrow_down => _ = self.moveVertical(root_focus, true), │ │ 732 + else => {}, │ │ 733 + } │ │ 734 + return; │ │ 735 + } │ │ 736 + if (child_id == self.author_id) { │ │ 737 + switch (key) { │ │ 738 + .arrow_left => self.exit = .list, │ │ 739 + .arrow_up => _ = self.moveVertical(root_focus, false), │ │ 740 + .arrow_down => _ = self.moveVertical(root_focus, true), │ │ 741 + else => {}, │ │ 742 + } │ │ 743 + return; │ │ 744 + } │ │ 745 + if (child_id == self.description_id) { │ │ 746 + self.descriptionInput(key, root_focus); │ │ 747 + return; │ │ 748 + } │ │ 749 + │ │ 750 + switch (child.*) { │ │ 751 + .tag_flow => |*tags| self.tagsInput(child_index, tags, key, root_focus), │ │ 752 + .copyable_text => |*copyable| switch (key) { │ │ 753 + .arrow_up => if (!self.moveVertical(root_focus, false)) self.focusToolRow(root_focus), │ │ 754 + .arrow_down => _ = self.moveVertical(root_focus, true), │ │ 755 + .arrow_left => if (copyable.selected == 0) { │ │ 756 + self.exit = .list; │ │ 757 + } else try copyable.input(allocator, key, root_focus), │ │ 758 + else => try copyable.input(allocator, key, root_focus), │ │ 759 + }, │ │ 760 + .box => |*row| if (Attachment.removeId(row)) |attachment_id| { │ │ 761 + const remove_id = row.children.keys()[1]; │ │ 762 + switch (key) { │ │ 763 + .arrow_left => if (!self.moveHorizontal(root_focus, false)) { │ │ 764 + self.exit = .list; │ │ 765 + }, │ │ 766 + .arrow_right => _ = self.moveHorizontal(root_focus, true), │ │ 767 + .arrow_up => _ = self.moveVertical(root_focus, false), │ │ 768 + .arrow_down => _ = self.moveVertical(root_focus, true), │ │ 769 + .enter => if (row.getFocus().child_id == remove_id) try self.removeEvent(allocator, .attach, attachment_id), │ │ 770 + .mouse => |mouse| if (inp.leftClickOn(root_focus, remove_id, mouse)) try self.removeEvent(allocator, .attach, attachment_id), │ │ 771 + else => {}, │ │ 772 + } │ │ 773 + } else self.commentInput(key, root_focus), │ │ 774 + else => self.commentInput(key, root_focus), │ │ 775 + } │ │ 776 + } │ │ 777 + │ │ 778 + fn toolRowInput(self: *This, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { │ │ 779 + const row = self.toolRow(); │ │ 780 + const child_id = row.getFocus().child_id orelse return; │ │ 781 + const cur = row.children.getIndex(child_id) orelse return; │ │ 782 + const on_primary = switch (row.children.values()[cur].widget.getFocus().kind) { │ │ 783 + .custom => |action| std.mem.eql(u8, action, "submit"), │ │ 784 + else => false, │ │ 785 + }; │ │ 786 + switch (key) { │ │ 787 + .arrow_left => { │ │ 788 + var previous = cur; │ │ 789 + while (previous > 0) { │ │ 790 + previous -= 1; │ │ 791 + if (!row.children.values()[previous].widget.getFocus().focusable) continue; │ │ 792 + root_focus.setFocus(row.children.keys()[previous]); │ │ 793 + return; │ │ 794 + } │ │ 795 + self.exit = .list; │ │ 796 + }, │ │ 797 + .arrow_right => { │ │ 798 + var next = cur + 1; │ │ 799 + while (next < row.children.count()) : (next += 1) { │ │ 800 + if (!row.children.values()[next].widget.getFocus().focusable) continue; │ │ 801 + root_focus.setFocus(row.children.keys()[next]); │ │ 802 + return; │ │ 803 + } │ │ 804 + }, │ │ 805 + .arrow_up => self.exit = .header, │ │ 806 + .arrow_down => _ = self.moveVertical(root_focus, true), │ │ 807 + .enter => if (on_primary) try self.primaryAction(allocator), │ │ 808 + .mouse => |mouse| if (on_primary and inp.leftClickOn(root_focus, child_id, mouse)) try self.primaryAction(allocator), │ │ 809 + else => {}, │ │ 810 + } │ │ 811 + } │ │ 812 + │ │ 813 + fn primaryAction(self: *This, allocator: std.mem.Allocator) !void { │ │ 814 + const entry = self.entry orelse return; │ │ 815 + if (supports_drafts and entryDraft(entry)) │ │ 816 + try self.publishDraft(allocator) │ │ 817 + else │ │ 818 + try self.toggleStatus(allocator); │ │ 819 + } │ │ 820 + │ │ 821 + fn publishDraft(self: *This, allocator: std.mem.Allocator) !void { │ │ 822 + if (!supports_drafts or comptime wasm) return; │ │ 823 + const entry = self.entry orelse return; │ │ 824 + if (!entryDraft(entry) or !entry.revision_ready) return; │ │ 825 + try self.data.publishDraft(self.session, allocator, entry.id); │ │ 826 + const route = listRoute(self.data.identity, @enumFromInt(0), "", entry.id) orelse return; │ │ 827 + try self.session.navigate(route); │ │ 828 + } │ │ 829 + │ │ 830 + fn descriptionInput(self: *This, key: Key, root_focus: *Focus) void { │ │ 831 + const detail_scroll = &self.scroll; │ │ 832 + switch (key) { │ │ 833 + .arrow_left => self.exit = .list, │ │ 834 + .arrow_up => { │ │ 835 + if (!self.session.is_terminal) { │ │ 836 + _ = self.moveVertical(root_focus, false); │ │ 837 + return; │ │ 838 + } │ │ 839 + if (self.focusAdjacentVisible(root_focus, false)) return; │ │ 840 + const before = detail_scroll.y; │ │ 841 + detail_scroll.y -= 1; │ │ 842 + detail_scroll.clampToContent(); │ │ 843 + if (detail_scroll.y == before) _ = self.moveVertical(root_focus, false); │ │ 844 + }, │ │ 845 + .arrow_down => { │ │ 846 + if (!self.session.is_terminal) { │ │ 847 + _ = self.moveVertical(root_focus, true); │ │ 848 + return; │ │ 849 + } │ │ 850 + if (self.focusAdjacentVisible(root_focus, true)) return; │ │ 851 + const before = detail_scroll.y; │ │ 852 + detail_scroll.y += 1; │ │ 853 + detail_scroll.clampToContent(); │ │ 854 + if (detail_scroll.y == before) _ = self.moveVertical(root_focus, true); │ │ 855 + }, │ │ 856 + else => {}, │ │ 857 + } │ │ 858 + } │ │ 859 + │ │ 860 + fn commentInput(self: *This, key: Key, root_focus: *Focus) void { │ │ 861 + switch (key) { │ │ 862 + .arrow_left => if (!self.moveHorizontal(root_focus, false)) { │ │ 863 + self.exit = .list; │ │ 864 + }, │ │ 865 + .arrow_right => _ = self.moveHorizontal(root_focus, true), │ │ 866 + .arrow_up => _ = self.moveVertical(root_focus, false), │ │ 867 + .arrow_down => _ = self.moveVertical(root_focus, true), │ │ 868 + else => {}, │ │ 869 + } │ │ 870 + } │ │ 871 + │ │ 872 + fn pageDetail(self: *This, root_focus: *Focus, delta: isize) void { │ │ 873 + if (!self.session.is_terminal) return; │ │ 874 + const detail_scroll = &self.scroll; │ │ 875 + detail_scroll.y += delta; │ │ 876 + detail_scroll.clampToContent(); │ │ 877 + self.focusVisible(root_focus, delta > 0); │ │ 878 + } │ │ 879 + │ │ 880 + fn jump(self: *This, root_focus: *Focus, to_end: bool) void { │ │ 881 + if (self.session.is_terminal) { │ │ 882 + const detail_scroll = &self.scroll; │ │ 883 + detail_scroll.y = if (to_end) std.math.maxInt(isize) else 0; │ │ 884 + detail_scroll.clampToContent(); │ │ 885 + self.focusVisible(root_focus, to_end); │ │ 886 + } else { │ │ 887 + const focus = self.inner().getFocus(); │ │ 888 + var chosen: ?usize = null; │ │ 889 + for (focus.children.keys(), focus.children.values()) |id, child| { │ │ 890 + if (!child.focus.focusable) continue; │ │ 891 + chosen = id; │ │ 892 + if (!to_end) break; │ │ 893 + } │ │ 894 + if (chosen) |id| root_focus.setFocus(id); │ │ 895 + } │ │ 896 + } │ │ 897 + │ │ 898 + fn rectVisible(self: *This, rect: layout.IRect) bool { │ │ 899 + const detail_scroll = &self.scroll; │ │ 900 + const viewport = detail_scroll.grid orelse return false; │ │ 901 + const top = detail_scroll.y; │ │ 902 + const bottom = top + @as(isize, @intCast(viewport.size.height - detail_scroll.bar_h)); │ │ 903 + return rect.y + @as(isize, @intCast(rect.size.height)) > top and rect.y < bottom; │ │ 904 + } │ │ 905 + │ │ 906 + fn focusVisible(self: *This, root_focus: *Focus, last: bool) void { │ │ 907 + const inner_box = self.inner(); │ │ 908 + var offset: usize = 0; │ │ 909 + while (offset < inner_box.children.count()) : (offset += 1) { │ │ 910 + const child_index = if (last) inner_box.children.count() - 1 - offset else offset; │ │ 911 + if (self.focusVisibleChild(child_index, root_focus, last)) return; │ │ 912 + } │ │ 913 + } │ │ 914 + │ │ 915 + fn focusVisibleChild(self: *This, child_index: usize, root_focus: *Focus, last: bool) bool { │ │ 916 + const inner_box = self.inner(); │ │ 917 + const child = &inner_box.children.values()[child_index]; │ │ 918 + const child_rect = child.rect orelse return false; │ │ 919 + var chosen: ?usize = null; │ │ 920 + if (child.widget.getFocus().focusable and self.rectVisible(child_rect)) { │ │ 921 + const id = inner_box.children.keys()[child_index]; │ │ 922 + chosen = id; │ │ 923 + if (!last) { │ │ 924 + root_focus.setFocus(id); │ │ 925 + return true; │ │ 926 + } │ │ 927 + } │ │ 928 + │ │ 929 + for (child.widget.getFocus().children.keys(), child.widget.getFocus().children.values()) |id, focus_child| { │ │ 930 + if (!focus_child.focus.focusable) continue; │ │ 931 + const rect: layout.IRect = .{ │ │ 932 + .x = child_rect.x + @as(isize, @intCast(focus_child.rect.x)), │ │ 933 + .y = child_rect.y + @as(isize, @intCast(focus_child.rect.y)), │ │ 934 + .size = focus_child.rect.size, │ │ 935 + }; │ │ 936 + if (!self.rectVisible(rect)) continue; │ │ 937 + chosen = id; │ │ 938 + if (!last) break; │ │ 939 + } │ │ 940 + if (chosen) |id| { │ │ 941 + root_focus.setFocus(id); │ │ 942 + return true; │ │ 943 + } │ │ 944 + return false; │ │ 945 + } │ │ 946 + │ │ 947 + fn focusAdjacentVisible(self: *This, root_focus: *Focus, down: bool) bool { │ │ 948 + const inner_box = self.inner(); │ │ 949 + var next = self.focusedChild(root_focus) orelse return false; │ │ 950 + while (true) { │ │ 951 + if (down) { │ │ 952 + next += 1; │ │ 953 + if (next >= inner_box.children.count()) return false; │ │ 954 + } else { │ │ 955 + if (next == 0) return false; │ │ 956 + next -= 1; │ │ 957 + } │ │ 958 + if (self.focusVisibleChild(next, root_focus, !down)) { │ │ 959 + self.focusChild(next, !down, root_focus); │ │ 960 + return true; │ │ 961 + } │ │ 962 + } │ │ 963 + } │ │ 964 + │ │ 965 + fn focusedChild(self: *This, root_focus: *Focus) ?usize { │ │ 966 + const inner_box = self.inner(); │ │ 967 + var id = root_focus.grandchild_id orelse return null; │ │ 968 + while (inner_box.getFocus().children.get(id)) |child| { │ │ 969 + if (child.parent_id == inner_box.getFocus().id) return inner_box.children.getIndex(id); │ │ 970 + id = child.parent_id; │ │ 971 + } │ │ 972 + return null; │ │ 973 + } │ │ 974 + │ │ 975 + fn childFocusable(self: *This, child_index: usize) bool { │ │ 976 + const focus = self.inner().children.values()[child_index].widget.getFocus(); │ │ 977 + if (focus.focusable) return true; │ │ 978 + for (focus.children.values()) |item| { │ │ 979 + if (item.focus.focusable) return true; │ │ 980 + } │ │ 981 + return false; │ │ 982 + } │ │ 983 + │ │ 984 + fn focusChild(self: *This, child_index: usize, last: bool, root_focus: *Focus) void { │ │ 985 + const inner_box = self.inner(); │ │ 986 + const child = &inner_box.children.values()[child_index]; │ │ 987 + var target = inner_box.children.keys()[child_index]; │ │ 988 + var rect = child.rect orelse return; │ │ 989 + switch (child.widget) { │ │ 990 + .repo_comment => |*comment| { │ │ 991 + if (comment.rowRect(last)) |row_rect| { │ │ 992 + rect.x += row_rect.x; │ │ 993 + rect.y += row_rect.y; │ │ 994 + rect.size = row_rect.size; │ │ 995 + } │ │ 996 + if (last) comment.focusBody(root_focus) else comment.focusMetadata(root_focus); │ │ 997 + }, │ │ 998 + .box => |*box| if (box.children.count() > 0) { │ │ 999 + target = if (child_index == tool_row_index) │ │1000 + box.getFocus().child_id orelse return │ │1001 + else │ │1002 + box.children.keys()[if (last) box.children.count() - 1 else 0]; │ │1003 + root_focus.setFocus(target); │ │1004 + }, │ │1005 + else => root_focus.setFocus(target), │ │1006 + } │ │1007 + const detail_scroll = &self.scroll; │ │1008 + if (detail_scroll.grid) |viewport| { │ │1009 + const viewport_height = viewport.size.height - detail_scroll.bar_h; │ │1010 + if (rect.size.height > viewport_height) { │ │1011 + const bottom_aligned = rect.y + @as(isize, @intCast(rect.size.height - viewport_height)); │ │1012 + detail_scroll.y = std.math.clamp(detail_scroll.y, rect.y, bottom_aligned); │ │1013 + detail_scroll.clampToContent(); │ │1014 + return; │ │1015 + } │ │1016 + } │ │1017 + detail_scroll.scrollToRect(rect); │ │1018 + } │ │1019 + │ │1020 + fn moveVertical(self: *This, root_focus: *Focus, down: bool) bool { │ │1021 + const inner_box = self.inner(); │ │1022 + const current = self.focusedChild(root_focus) orelse return false; │ │1023 + if (inner_box.children.values()[current].widget == .repo_comment) { │ │1024 + const comment = &inner_box.children.values()[current].widget.repo_comment; │ │1025 + if (down and !comment.bodyFocused()) { │ │1026 + self.focusChild(current, true, root_focus); │ │1027 + return true; │ │1028 + } │ │1029 + if (!down and comment.bodyFocused()) { │ │1030 + self.focusChild(current, false, root_focus); │ │1031 + return true; │ │1032 + } │ │1033 + } │ │1034 + │ │1035 + var next = current; │ │1036 + while (true) { │ │1037 + if (down) { │ │1038 + next += 1; │ │1039 + if (next >= inner_box.children.count()) return false; │ │1040 + } else { │ │1041 + if (next == 0) return false; │ │1042 + next -= 1; │ │1043 + } │ │1044 + if (!self.childFocusable(next)) continue; │ │1045 + self.focusChild(next, !down, root_focus); │ │1046 + return true; │ │1047 + } │ │1048 + } │ │1049 + │ │1050 + fn moveHorizontal(self: *This, root_focus: *Focus, right: bool) bool { │ │1051 + const inner_box = self.inner(); │ │1052 + const current = self.focusedChild(root_focus) orelse return false; │ │1053 + const child = &inner_box.children.values()[current]; │ │1054 + if (child.widget == .repo_comment) { │ │1055 + const moved = child.widget.repo_comment.moveHorizontal(root_focus, right); │ │1056 + if (moved) if (child.rect) |rect| self.scroll.scrollToRect(rect); │ │1057 + return moved; │ │1058 + } │ │1059 + const row = switch (child.widget) { │ │1060 + .box => |*box| box, │ │1061 + else => return false, │ │1062 + }; │ │1063 + const selected = row.getFocus().child_id orelse return false; │ │1064 + const selected_index = row.children.getIndex(selected) orelse return false; │ │1065 + const target = if (right) selected_index + 1 else selected_index -| 1; │ │1066 + if (target == selected_index or target >= row.children.count()) return false; │ │1067 + root_focus.setFocus(row.children.keys()[target]); │ │1068 + if (child.rect) |rect| self.scroll.scrollToRect(rect); │ │1069 + return true; │ │1070 + } │ │1071 │ │1072 + fn tagsInput(self: *This, child_index: usize, tags: *TagFlow, key: Key, root_focus: *Focus) void { │ │1073 + const child_id = tags.focus.child_id orelse return; │ │1074 + const cur = tags.indexOfFocusId(child_id) orelse return; │ │1075 + const count = tags.text_boxes.items.len; │ │1076 + switch (key) { │ │1077 + .arrow_left => if (cur > 0) self.focusTag(child_index, tags, root_focus, cur - 1) else { │ │1078 + self.exit = .list; │ │1079 + }, │ │1080 + .arrow_right => if (cur + 1 < count) self.focusTag(child_index, tags, root_focus, cur + 1), │ │1081 + .arrow_up => if (tags.rowStep(cur, false)) |index| { │ │1082 + self.focusTag(child_index, tags, root_focus, index); │ │1083 + } else { │ │1084 + _ = self.moveVertical(root_focus, false); │ │1085 + }, │ │1086 + .arrow_down => if (tags.rowStep(cur, true)) |index| { │ │1087 + self.focusTag(child_index, tags, root_focus, index); │ │1088 + } else { │ │1089 + _ = self.moveVertical(root_focus, true); │ │1090 + }, │ │1091 + .home => self.focusTag(child_index, tags, root_focus, 0), │ │1092 + .end => self.focusTag(child_index, tags, root_focus, count - 1), │ │1093 + else => {}, │ │1094 + } │ │1095 + } │ │1096 + │ │1097 + fn focusTag(self: *This, child_index: usize, tags: *TagFlow, root_focus: *Focus, item: usize) void { │ │1098 + root_focus.setFocus(tags.text_boxes.items[item].getFocus().id); │ │1099 + if (self.session.is_terminal and item < tags.rects.items.len) { │ │1100 + if (self.inner().children.values()[child_index].rect) |flow_rect| { │ │1101 + var rect = tags.rects.items[item]; │ │1102 + rect.x += flow_rect.x; │ │1103 + rect.y += flow_rect.y; │ │1104 + self.scroll.scrollToRect(rect); │ │1105 + } │ │1106 + } │ │1107 + } │ │1108 + │ │1109 + fn toolRowFocused(self: *This, root_focus: *Focus) bool { │ │1110 + return self.focusedChild(root_focus) == tool_row_index; │ │1111 + } │ │1112 + │ │1113 + fn focusToolRow(self: *This, root_focus: *Focus) void { │ │1114 + const child_id = self.toolRow().getFocus().child_id orelse { │ │1115 + self.exit = .header; │ │1116 + return; │ │1117 + }; │ │1118 + root_focus.setFocus(child_id); │ │1119 + if (self.inner().children.values()[tool_row_index].rect) |rect| self.scroll.scrollToRect(rect); │ │1120 + } │ │1121 + │ │1122 + pub fn focusFirst(self: *This, root_focus: ?*Focus) bool { │ │1123 + const inner_box = self.inner(); │ │1124 + for (tool_row_index + 1..inner_box.children.count()) |child_index| { │ │1125 + if (!self.childFocusable(child_index)) continue; │ │1126 + if (root_focus) |focus| │ │1127 + self.focusChild(child_index, false, focus) │ │1128 + else │ │1129 + inner_box.getFocus().child_id = inner_box.children.keys()[child_index]; │ │1130 + return true; │ │1131 + } │ │1132 + return false; │ │1133 + } │ │1134 + │ │1135 + pub fn atTop(self: *This, root_focus: *Focus) bool { │ │1136 + const focused = self.focusedChild(root_focus) orelse return false; │ │1137 + for (0..self.inner().children.count()) |child_index| { │ │1138 + if (!self.childFocusable(child_index)) continue; │ │1139 + return focused == child_index; │ │1140 + } │ │1141 + return false; │ │1142 + } │ │1143 + │ │1144 + fn removeEvent(self: *This, allocator: std.mem.Allocator, event_kind: evt.EventKind, id: [evt.event_id_size]u8) !void { │ │1145 + if (comptime wasm) return; │ │1146 + │ │1147 + if (comptime supports_drafts) { │ │1148 + const entry = self.entry orelse return; │ │1149 + if (event_kind == kind and entryDraft(entry)) { │ │1150 + try Data.removeDraft(self.session, allocator, &id); │ │1151 + const route = draftsRoute(self.data.identity) orelse return; │ │1152 + try self.session.navigate(route); │ │1153 + return; │ │1154 + } │ │1155 + } │ │1156 + │ │1157 + const io = self.session.io orelse return; │ │1158 + const source = self.data.repo_source orelse return; │ │1159 + const author = (try self.session.eventAuthor()) orelse return; │ │1160 + │ │1161 + switch (source.repo_kind) { │ │1162 + inline else => |repo_kind| { │ │1163 + var any_repo = try rp.AnyRepo(repo_kind, .{}).open(io, allocator, source.localInitOpts()); │ │1164 + defer any_repo.deinit(io, allocator); │ │1165 + switch (any_repo) { │ │1166 + inline else => |*repo| try evt.remove(.repo, repo_kind, repo.self_repo_opts, io, allocator, repo, &id, event_kind, author), │ │1167 + } │ │1168 + }, │ │1169 + } │ │1170 + │ │1171 + const id_hex = std.fmt.bytesToHex(id, .lower); │ │1172 + const route = ui.RoutablePage.repoEventsRoute(self.data.identity, .removed, event_kind, &id_hex) orelse return; │ │1173 + try self.session.navigate(route); │ │1174 + } │ │1175 + │ │1176 + fn toggleStatus(self: *This, allocator: std.mem.Allocator) !void { │ │1177 + if (comptime (!has_status or wasm)) return; │ │1178 + const entry = self.entry orelse return; │ │1179 + const io = self.session.io orelse return; │ │1180 + const source = self.data.repo_source orelse return; │ │1181 + const author = (try self.session.eventAuthor()) orelse return; │ │1182 + const status = (statusChange(entryStatus(entry)) orelse return).status; │ │1183 + const id = try evt.parseEventId(entry.id); │ │1184 + │ │1185 + switch (source.repo_kind) { │ │1186 + inline else => |repo_kind| { │ │1187 + var any_repo = try rp.AnyRepo(repo_kind, .{}).open(io, allocator, source.localInitOpts()); │ │1188 + defer any_repo.deinit(io, allocator); │ │1189 + switch (any_repo) { │ │1190 + inline else => |*repo| try Event.update(repo_kind, repo.self_repo_opts, io, allocator, repo, &id, .{ .status = status }, author), │ │1191 + } │ │1192 + }, │ │1193 + } │ │1194 + │ │1195 + const route = listRoute(self.data.identity, status, "", entry.id) orelse return; │ │1196 + try self.session.navigate(route); │ │1197 + } │ │1198 + }; │ │1199 +} │ │1200 + │ │1201 pub fn View(comptime kind: evt.EventKind, comptime Data: type) type { │ │1202 const Self = Data; │ │1203 const HeaderType = Data.Header; │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────╮ │1208 const has_status = @hasField(Event, "status"); │ │1209 const supports_conflicts = Event.merge_policy == .field_conflicts; │ │1210 const supports_drafts = @hasField(Self, "drafts"); │ │ 358 - const supports_forks = @hasField(Entry, "fork_exists"); │ │1211 const FieldConflict = if (supports_conflicts) Data.FieldConflict else void;│ │1212 const ViewKind = Data.ViewKind; │ │1213 const thread_name = Data.thread_name; │ ╰─────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────╮ │1214 + const DetailType = Data.Detail;│ │1215 │ │1216 return struct { │ │1217 const This = @This(); │ ╰─────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────╮ │1221 box: wgt.Box(Widget), // vert: [header_index] = tabs, [stack_index] = stack│ │1222 data: *const Self, │ │1223 session: *ui.Session, │ │ 371 - // per-split state, indexed like the stack's split children: the thread the│ │ 372 - // pane shows and its focus ids. │ │ 373 - detailed_index: [view_count]?usize, │ │ 374 - title_id: [view_count]?usize, │ │ 375 - description_id: [view_count]?usize, │ │ 376 - author_id: [view_count]?usize, │ │ 377 - detail_only: bool, │ │1224 │ │1225 const header_index: usize = 0; │ │1226 const stack_index: usize = 1; │ ╰─────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │1278 │ │1279 fn entryDraft(entry: Entry) bool { │ │1280 return if (supports_drafts) entry.draft else false; │ │ 435 - } │ │ 436 - │ │ 437 - fn entryHasFork(entry: Entry) bool { │ │ 438 - return if (supports_forks) entry.fork_exists else false; │ │ 439 - } │ │ 440 - │ │ 441 - const StatusChange = struct { │ │ 442 - action: []const u8, │ │ 443 - status: Status, │ │ 444 - }; │ │ 445 - │ │ 446 - fn statusChange(status: Status) ?StatusChange { │ │ 447 - const name = @tagName(status); │ │ 448 - if (std.mem.eql(u8, name, "open")) return .{ │ │ 449 - .action = "close", │ │ 450 - .status = std.meta.stringToEnum(Status, "closed") orelse return null, │ │ 451 - }; │ │ 452 - if (std.mem.eql(u8, name, "closed")) return .{ │ │ 453 - .action = "open", │ │ 454 - .status = std.meta.stringToEnum(Status, "open") orelse return null, │ │ 455 - }; │ │ 456 - return null; │ │1281 } │ │1282 │ │1283 fn listRoute(identity: []const u8, status: Status, tag: []const u8, selected: []const u8) ?ui.RoutablePage {│ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────╮ │1288 return if (supports_drafts) Data.draftsRoute(identity) else null; │ │1289 } │ │1290 │ │ 467 - fn forkRoute(identity: []const u8, id: []const u8) ?ui.RoutablePage { │ │ 468 - return if (supports_forks) Data.forkRoute(identity, id) else null; │ │ 469 - } │ │ 470 - │ │1291 fn conflictsRoute(identity: []const u8, selected: []const u8) ?ui.RoutablePage { │ │1292 return if (supports_conflicts) Data.conflictsRoute(identity, selected) else null;│ │1293 } │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────╮ │1485 .box = outer, │ │1486 .data = data, │ │1487 .session = session, │ │ 668 - .detailed_index = @splat(null),│ │ 669 - .title_id = @splat(null), │ │ 670 - .description_id = @splat(null),│ │ 671 - .author_id = @splat(null), │ │ 672 - .detail_only = false, │ │1488 }; │ │1489 } │ │1490 │ ╰─────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ 676 - // build the selected thread's existing detail pane without its list or │ │ 677 - // thread-page header, for pages that provide their own surrounding UI. │ │ 678 - pub fn initDetail(allocator: std.mem.Allocator, data: *const Self, session: *ui.Session) !This { │ │ 679 - var self = try init(allocator, data, session); │ │ 680 - errdefer self.deinit(allocator); │ │ 681 - self.detail_only = true; │ │ 682 - self.box.getFocus().child_id = self.box.children.keys()[stack_index]; │ │ 683 - const index = self.selectedSplitIndex() orelse return error.NotFound; │ │ 684 - self.resultsBox(index).getFocus().child_id = self.resultsBox(index).children.keys()[detail_index]; │ │ 685 - return self; │ │ 686 - } │ │ 687 - │ │1491 // the master-detail split showing `status`'s window, or the conflicts │ │1492 // window when null. │ │1493 fn initSplit(allocator: std.mem.Allocator, session: *ui.Session, data: *const Self, status_maybe: ?Status, drafts: bool) !wgt.Box(Widget) {│ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │1528 try box.children.put(allocator, list_scroll.getFocus().id, .{ .widget = .{ .scroll = list_scroll }, .rect = null, .min_size = .{ .width = list_max_width, .height = null }, .max_size = .{ .width = list_max_width, .height = null } });│ │1529 } │ │1530 │ │ 728 - // the detail pane — a frame around its scrollable contents │ │1531 + // frame the detail pane inside the master-detail split │ │1532 { │ │ 730 - var detail_outer = blk: { │ │ 731 - var detail_scroll = blk2: { │ │ 732 - var detail_inner = try wgt.Box(Widget).init(allocator, .{ .border_style = null, .direction = .vert }); │ │ 733 - errdefer detail_inner.deinit(allocator); │ │ 734 - // fill the pane (content top-left, scroll bar pinned to the │ │ 735 - // edge) rather than shrinking to the description. │ │ 736 - break :blk2 try wgt.Scroll(Widget).init(allocator, .{ .box = detail_inner }, .{ .direction = .vert, .web_native = !session.is_terminal, .fill = true }); │ │ 737 - }; │ │ 738 - errdefer detail_scroll.deinit(allocator); │ │ 739 - var frame = try wgt.Box(Widget).init(allocator, .{ .border_style = .hidden, .direction = .vert }); │ │ 740 - errdefer frame.deinit(allocator); │ │ 741 - // the frame's selected child is its scroll, so the focus chain │ │ 742 - // reaches the detail contents. │ │ 743 - frame.getFocus().child_id = detail_scroll.getFocus().id; │ │ 744 - try frame.children.put(allocator, detail_scroll.getFocus().id, .{ .widget = .{ .scroll = detail_scroll }, .rect = null, .min_size = null }); │ │ 745 - break :blk frame; │ │ 746 - }; │ │ 747 - errdefer detail_outer.deinit(allocator); │ │ 748 - try box.children.put(allocator, detail_outer.getFocus().id, .{ .widget = .{ .box = detail_outer }, .rect = null, .min_size = .{ .width = detail_min_width, .height = null } }); │ │1533 + var frame = try wgt.Box(Widget).init(allocator, .{ .border_style = .hidden, .direction = .vert }); │ │1534 + errdefer frame.deinit(allocator); │ │1535 + var detail_view = try DetailType.init(allocator, data, session, null, .{}); │ │1536 + errdefer detail_view.deinit(allocator); │ │1537 + frame.getFocus().child_id = detail_view.getFocus().id; │ │1538 + try frame.children.put(allocator, detail_view.getFocus().id, .{ .widget = @unionInit(Widget, Data.detail_widget_name, detail_view), .rect = null, .min_size = null }); │ │1539 + try box.children.put(allocator, frame.getFocus().id, .{ .widget = .{ .box = frame }, .rect = null, .min_size = .{ .width = detail_min_width, .height = null } }); │ │1540 } │ │1541 │ │1542 box.getFocus().child_id = box.children.keys()[list_index]; │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │1844 row.getFocus().focusable = true; │ │1845 if (link.len != 0) row.getFocus().kind = .{ .custom = link }; │ │1846 try box.children.put(allocator, row.getFocus().id, .{ .widget = .{ .text_box = row }, .rect = null, .min_size = null, .max_size = .{ .width = null, .height = 5 } }); │ │1056 - } │ │1057 - │ │1058 - fn addToolButton(allocator: std.mem.Allocator, row: *wgt.Box(Widget), label: []const u8, action: []const u8) !void { │ │1059 - var button = try wgt.TextBox.init(allocator, label, .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .none }); │ │1060 - errdefer button.deinit(allocator); │ │1061 - button.getFocus().focusable = true; │ │1062 - button.getFocus().kind = .{ .custom = action }; │ │1063 - try row.children.put(allocator, button.getFocus().id, .{ .widget = .{ .text_box = button }, .rect = null, .min_size = .{ .width = try xitui.width.displayWidth(label) + 2, .height = null } });│ │1847 } │ │1848 │ │1849 pub fn deinit(self: *This, allocator: std.mem.Allocator) void { │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯