╭ changed ──────╮ │src/ui/Fork.zig│ ╰───────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │162 { │ │163 var stack = try wgt.Stack(ui.Widget).init(allocator); │ │164 errdefer stack.deinit(allocator); │ │165 - if (data.patch.repo_source == null) { │ │166 - var fallback = try initFallback(allocator, &data.patch, session); │ │167 - errdefer fallback.deinit(allocator); │ │168 - try stack.children.put(allocator, fallback.getFocus().id, .{ .scroll = fallback }); │ │169 - } else { │ │170 - var patch_view = try Patches.View.initDetail(allocator, &data.patch, session); │ │171 - errdefer patch_view.deinit(allocator); │ │172 - try stack.children.put(allocator, patch_view.getFocus().id, .{ .repo_patches = patch_view }); │ │173 - } │ │165 + const selected = data.patch.selectedThread() orelse return error.NotFound; │ │166 + var patch_detail = try Patches.Detail.init(allocator, &data.patch, session, selected.*, .{ .actions = data.patch.repo_source != null });│ │167 + errdefer patch_detail.deinit(allocator); │ │168 + try stack.children.put(allocator, patch_detail.getFocus().id, .{ .repo_patch_detail = patch_detail }); │ │169 { │ │170 var files = try Files.View.init(allocator, &data.files, session); │ │171 errdefer files.deinit(allocator); │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │195 } │ │196 box.getFocus().child_id = box.children.keys()[header_index]; │ │197 return .{ .box = box }; │ │203 - } │ │204 - │ │205 - fn initFallback(allocator: std.mem.Allocator, data: *const Patches, session: *ui.Session) !wgt.Scroll(ui.Widget) { │ │206 - const entry = data.selectedThread() orelse return error.NotFound; │ │207 - var content = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); │ │208 - errdefer content.deinit(allocator); │ │209 - { │ │210 - var text_box = try wgt.TextBox.init(allocator, entry.record.event.title, .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .word, .label = " title " });│ │211 - errdefer text_box.deinit(allocator); │ │212 - text_box.getFocus().focusable = true; │ │213 - try content.children.put(allocator, text_box.getFocus().id, .{ .widget = .{ .text_box = text_box }, .rect = null, .min_size = null }); │ │214 - } │ │215 - { │ │216 - var author = try ui.authorBox(allocator, session.page_arena, entry.author); │ │217 - errdefer author.deinit(allocator); │ │218 - try content.children.put(allocator, author.getFocus().id, .{ .widget = .{ .text_box = author }, .rect = null, .min_size = null }); │ │219 - } │ │220 - inline for (.{ │ │221 - .{ entry.record.event.tags, " tags " }, │ │222 - .{ entry.record.event.description, " description " }, │ │223 - }) |field| { │ │224 - var text_box = try wgt.TextBox.init(allocator, field[0], .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .word, .label = field[1] }); │ │225 - errdefer text_box.deinit(allocator); │ │226 - text_box.getFocus().focusable = true; │ │227 - try content.children.put(allocator, text_box.getFocus().id, .{ .widget = .{ .text_box = text_box }, .rect = null, .min_size = null }); │ │228 - } │ │229 - content.getFocus().child_id = content.children.keys()[0]; │ │230 - return wgt.Scroll(ui.Widget).init(allocator, .{ .box = content }, .{ .direction = .vert, .web_native = !session.is_terminal, .fill = true }); │ │198 } │ │199 │ │200 pub fn deinit(self: *View, allocator: std.mem.Allocator) void { │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────╮ │220 .fork_header => try child.input(allocator, key, root_focus), │ │221 .stack => if (stack.getSelected()) |selected| { │ │222 const at_top = switch (selected.*) { │ │256 - .repo_patches => |*view| view.atTop(root_focus), │ │223 + .repo_patch_detail => |*view| view.atTop(root_focus), │ │224 .repo_files => |*view| view.getSelectedIndex() == 0, │ │225 .repo_commits => |*view| view.getSelectedIndex() == 0, │ │226 .home_settings => |*view| view.getSelectedIndex() == 0,│ ╰────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │227 .home_auth => |*view| view.getSelectedIndex() == 0, │ │228 .quit => |*view| view.getSelectedIndex() == 0, │ │262 - .scroll => |*scroll| scroll.y == 0, │ │229 else => false, │ │230 }; │ │231 if (at_top) next_index = header_index else try child.input(allocator, key, root_focus);│ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────╮ │235 .down => switch (child.*) { │ │236 .fork_header => { │ │237 if (stack.getSelected()) |selected| switch (selected.*) { │ │272 - .repo_patches => |*view| if (view.focusDetail(root_focus)) return, │ │238 + .repo_patch_detail => |*view| if (view.focusFirst(root_focus)) return,│ │239 .repo_files => |*view| if (view.focusCloneUrl(root_focus)) return, │ │240 .repo_commits => |*view| if (view.focusCloneUrl(root_focus)) return, │ │241 else => {}, │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ──────────────────╮ │src/ui/Repo/Discussions.zig│ ╰───────────────────────────╯ ╭─────────────────────────────────────────────────────────────╮ │263 }; │ │264 } │ │265 pub const View = thread.View(.discuss, Self); │ │266 +pub const Detail = thread.Detail(.discuss, Self); │ │267 +pub const detail_widget_name = "repo_discussion_detail";│ │268 │ │269 pub const Header = thread.Header; │ │270 │ ╰─────────────────────────────────────────────────────────────╯ ╭ changed ─────────────╮ │src/ui/Repo/Issues.zig│ ╰──────────────────────╯ ╭────────────────────────────────────────────────────────╮ │348 } │ │349 │ │350 pub const View = thread.View(.issue, Self); │ │351 +pub const Detail = thread.Detail(.issue, Self); │ │352 +pub const detail_widget_name = "repo_issue_detail";│ │353 │ │354 pub const Header = thread.Header; │ │355 │ ╰────────────────────────────────────────────────────────╯ ╭ changed ──────────────╮ │src/ui/Repo/Patches.zig│ ╰───────────────────────╯ ╭────────────────────────────────────────────────────────╮ │672 } │ │673 │ │674 pub const View = thread.View(.patch, Self); │ │675 +pub const Detail = thread.Detail(.patch, Self); │ │676 +pub const detail_widget_name = "repo_patch_detail";│ │677 │ │678 pub const Header = thread.Header; │ │679 │ ╰────────────────────────────────────────────────────────╯ ╭ changed ────────╮ │src/ui/widget.zig│ ╰─────────────────╯ ╭───────────────────────────────────────────────────────────╮ │49 repo_issues: ui.Repo.Issues.View, │ │50 repo_patches: ui.Repo.Patches.View, │ │51 repo_discussions: ui.Repo.Discussions.View, │ │52 + repo_issue_detail: ui.Repo.Issues.Detail, │ │53 + repo_patch_detail: ui.Repo.Patches.Detail, │ │54 + repo_discussion_detail: ui.Repo.Discussions.Detail,│ │55 repo_events_header: ui.Repo.Events.Header.View, │ │56 repo_events: ui.Repo.Events.View, │ │57 repo_comment: ui.Repo.Comment.Item, │ ╰───────────────────────────────────────────────────────────╯ ╭ changed ───────────────╮ │src/ui/widget/thread.zig│ ╰────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ 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; │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯