╭ Comments.zig ─────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
1 const std = @import("std"); 2 const evt = @import("../../event.zig"); 3 const ui = @import("../../ui.zig"); 4 const xit = @import("xit"); 5 const hash = xit.hash; 6 const rp = xit.repo; 7 const xitui = xit.xitui; 8 const wgt = xitui.widget; 9 const layout = xitui.layout; 10 const Key = xitui.input.Key; 11 const Grid = xitui.grid.Grid; 12 const Focus = xitui.focus.Focus; 13 14 pub const page_size = 10; 15 16 pub const CommentWithId = struct { 17 id: []const u8, 18 comment: evt.Comment.Record, 19 author: ui.Author = .unknown, 20 parent_author: ?ui.Author = null, 21 }; 22 23 pub const Window = struct { 24 comments: []const CommentWithId, 25 start: usize, 26 count: usize, 27 has_prev: bool, 28 has_more: bool, 29 30 pub const empty: Window = .{ .comments = &.{}, .start = 0, .count = 0, .has_prev = false, .has_more = false }; 31 }; 32 33 identity: []const u8, 34 thread_id: []const u8, 35 selected: CommentWithId, 36 replies: Window, 37 show_thread: bool, 38 39 const Self = @This(); 40 41 pub fn initFromRepo( 42 comptime repo_kind: rp.RepoKind, 43 comptime repo_opts: rp.RepoOpts(repo_kind), 44 arena: *std.heap.ArenaAllocator, 45 repo: *rp.Repo(repo_kind, repo_opts), 46 io: std.Io, 47 admin_moment: ?evt.AdminDB.HashMap(.read_only), 48 identity: []const u8, 49 thread_id: []const u8, 50 selected_id: []const u8, 51 start: usize, 52 ) !Self { 53 const gpa = arena.child_allocator; 54 var event_db_maybe: ?evt.LocalEventDB(repo_opts.hash) = if (repo_kind == .git) try evt.LocalEventDB(repo_opts.hash).openReadOnly(io, gpa, repo.core.repo_dir) else null; 55 defer if (event_db_maybe) |*event_db| event_db.deinit(io, gpa); 56 const haxy_moment = if (event_db_maybe) |*event_db| 57 try evt.currentMomentFromDb(repo_opts.hash, event_db.db) 58 else if (repo_kind == .git) 59 return error.NotFound 60 else 61 try evt.currentMoment(repo_opts, repo); 62 return init(repo_opts.hash, arena, admin_moment, haxy_moment, identity, thread_id, selected_id, start); 63 } 64 65 // read a comment permalink and one window of its immediate replies. 66 pub fn init( 67 comptime hash_kind: hash.HashKind, 68 arena: *std.heap.ArenaAllocator, 69 admin_moment: ?evt.AdminDB.HashMap(.read_only), 70 haxy_moment: evt.EventDB(hash_kind).HashMap(.read_only), 71 identity: []const u8, 72 thread_id: []const u8, 73 selected_id: []const u8, 74 start: usize, 75 ) !Self { 76 const selected = (try readOne(hash_kind, arena, admin_moment, haxy_moment, selected_id)) orelse return error.NotFound; 77 if (!std.mem.eql(u8, &selected.comment.event.thread_id, thread_id)) return error.NotFound; 78 79 var show_thread = false; 80 if (try idBytes(thread_id)) |thread_bytes| { 81 if (try haxy_moment.getCursor(hash.hashInt(hash_kind, evt.Issue.record_map_key))) |issues_cursor| { 82 const DB = evt.EventDB(hash_kind); 83 const issues = try DB.HashMap(.read_only).init(issues_cursor); 84 if (try issues.getCursor(hash.hashInt(hash_kind, &thread_bytes))) |issue_cursor| { 85 const issue_map = try DB.HashMap(.read_only).init(issue_cursor); 86 const issue = try evt.read(evt.Issue.Record, DB, hash_kind, arena, issue_map); 87 show_thread = !issue.deleted; 88 } 89 } 90 } 91 92 return .{ 93 .identity = try arena.allocator().dupe(u8, identity), 94 .thread_id = try arena.allocator().dupe(u8, thread_id), 95 .selected = selected, 96 .replies = try loadWindow(hash_kind, arena, admin_moment, haxy_moment, evt.Comment.parent_id_to_comment_id_set_key, selected_id, start), 97 .show_thread = show_thread, 98 }; 99 } 100 101 // read one chronological comment window from an id-to-comment-set index. 102 pub fn loadWindow( 103 comptime hash_kind: hash.HashKind, 104 arena: *std.heap.ArenaAllocator, 105 admin_moment: ?evt.AdminDB.HashMap(.read_only), 106 haxy_moment: evt.EventDB(hash_kind).HashMap(.read_only), 107 index_key: []const u8, 108 owner_id: []const u8, 109 start: usize, 110 ) !Window { 111 const DB = evt.EventDB(hash_kind); 112 const index_cursor = try haxy_moment.getCursor(hash.hashInt(hash_kind, index_key)) orelse return .{ 113 .comments = &.{}, 114 .start = start, 115 .count = 0, 116 .has_prev = start > 0, 117 .has_more = false, 118 }; 119 const index = try DB.HashMap(.read_only).init(index_cursor); 120 const set_cursor = try index.getCursor(hash.hashInt(hash_kind, owner_id)) orelse return .{ 121 .comments = &.{}, 122 .start = start, 123 .count = 0, 124 .has_prev = start > 0, 125 .has_more = false, 126 }; 127 const set = try DB.SortedSet(.read_only).init(set_cursor); 128 const count: usize = @intCast(try set.count()); 129 const end = @min(start + page_size, count); 130 var iter = try set.iteratorFromIndex(start); 131 var comments: std.ArrayList(CommentWithId) = .empty; 132 var i = start; 133 while (i < end) : (i += 1) { 134 var cursor = (try iter.next()) orelse break; 135 const kv = try cursor.readKeyValuePair(); 136 var order_key: [@sizeOf(u64) + evt.event_id_size]u8 = undefined; 137 _ = try kv.key_cursor.readBytes(&order_key); 138 const id_hex = std.fmt.bytesToHex(order_key[@sizeOf(u64)..].*, .lower); 139 const entry = (try readOneBytes(hash_kind, arena, admin_moment, haxy_moment, order_key[@sizeOf(u64)..], &id_hex)) orelse continue; 140 try comments.append(arena.allocator(), entry); 141 } 142 return .{ 143 .comments = comments.items, 144 .start = start, 145 .count = count, 146 .has_prev = start > 0, 147 .has_more = end < count, 148 }; 149 } 150 151 fn readOne( 152 comptime hash_kind: hash.HashKind, 153 arena: *std.heap.ArenaAllocator, 154 admin_moment: ?evt.AdminDB.HashMap(.read_only), 155 haxy_moment: evt.EventDB(hash_kind).HashMap(.read_only), 156 id: []const u8, 157 ) !?CommentWithId { 158 const bytes = (try idBytes(id)) orelse return null; 159 return readOneBytes(hash_kind, arena, admin_moment, haxy_moment, &bytes, id); 160 } 161 162 fn readOneBytes( 163 comptime hash_kind: hash.HashKind, 164 arena: *std.heap.ArenaAllocator, 165 admin_moment: ?evt.AdminDB.HashMap(.read_only), 166 haxy_moment: evt.EventDB(hash_kind).HashMap(.read_only), 167 id_bytes: []const u8, 168 id: []const u8, 169 ) !?CommentWithId { 170 const DB = evt.EventDB(hash_kind); 171 const records_cursor = try haxy_moment.getCursor(hash.hashInt(hash_kind, evt.Comment.record_map_key)) orelse return null; 172 const records = try DB.HashMap(.read_only).init(records_cursor); 173 const record_cursor = try records.getCursor(hash.hashInt(hash_kind, id_bytes)) orelse return null; 174 const record_map = try DB.HashMap(.read_only).init(record_cursor); 175 const comment = try evt.read(evt.Comment.Record, DB, hash_kind, arena, record_map); 176 const parent_author: ?ui.Author = if (!std.mem.eql(u8, &comment.event.parent_id, &comment.event.thread_id)) blk: { 177 const parent_bytes = (try idBytes(&comment.event.parent_id)) orelse break :blk null; 178 const parent_cursor = try records.getCursor(hash.hashInt(hash_kind, &parent_bytes)) orelse break :blk null; 179 const parent_map = try DB.HashMap(.read_only).init(parent_cursor); 180 const parent = try evt.read(evt.Comment.Record, DB, hash_kind, arena, parent_map); 181 break :blk try ui.Author.initFromEmail(admin_moment, arena, parent.author_email); 182 } else null; 183 return .{ 184 .id = try arena.allocator().dupe(u8, id), 185 .comment = comment, 186 .author = try ui.Author.initFromEmail(admin_moment, arena, comment.author_email), 187 .parent_author = parent_author, 188 }; 189 } 190 191 fn idBytes(id: []const u8) !?[evt.event_id_size]u8 { 192 if (id.len != evt.event_id_size * 2) return null; 193 var bytes: [evt.event_id_size]u8 = undefined; 194 _ = std.fmt.hexToBytes(&bytes, id) catch return null; 195 if (!std.mem.eql(u8, id, &std.fmt.bytesToHex(bytes, .lower))) return null; 196 return bytes; 197 } 198 199 pub const Item = struct { 200 box: wgt.Box(ui.Widget), 201 202 const metadata_index: usize = 0; 203 const body_index: usize = 1; 204 205 pub fn init(allocator: std.mem.Allocator, session: *ui.Session, identity: []const u8, entry: CommentWithId) !Item { 206 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); 207 errdefer box.deinit(allocator); 208 209 { 210 var bar = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .horiz }); 211 errdefer bar.deinit(allocator); 212 213 var author = try ui.authorBox(allocator, session.page_arena, entry.author); 214 errdefer author.deinit(allocator); 215 try bar.children.put(allocator, author.getFocus().id, .{ .widget = .{ .text_box = author }, .rect = null, .min_size = null, .shrink = true }); 216 217 var permalink = try linkBox(allocator, session, "permalink", ui.RoutablePage.repoCommentsRoute(identity, &entry.comment.event.thread_id, entry.id, 0) orelse return error.RouteTooLong); 218 errdefer permalink.deinit(allocator); 219 try bar.children.put(allocator, permalink.getFocus().id, .{ .widget = .{ .text_box = permalink }, .rect = null, .min_size = .{ .width = "permalink".len + 2, .height = null } }); 220 221 if (!std.mem.eql(u8, &entry.comment.event.parent_id, &entry.comment.event.thread_id)) { 222 const parent_text = if (entry.parent_author) |parent_author| switch (parent_author) { 223 .unknown => "", 224 .email, .user_name => |text| text, 225 } else ""; 226 var parent = try wgt.TextBox(ui.Widget).init(allocator, parent_text, .{ 227 .border_style = .single, 228 .rounded_corners = true, 229 .wrap_kind = .none, 230 .label = " replying to ", 231 }); 232 errdefer parent.deinit(allocator); 233 parent.getFocus().focusable = true; 234 const route = ui.RoutablePage.repoCommentsRoute(identity, &entry.comment.event.thread_id, &entry.comment.event.parent_id, 0) orelse return error.RouteTooLong; 235 parent.getFocus().kind = .{ .custom = try std.fmt.allocPrint(session.page_arena.allocator(), "a:{s}", .{try route.toUrl(session.page_arena)}) }; 236 try bar.children.put(allocator, parent.getFocus().id, .{ .widget = .{ .text_box = parent }, .rect = null, .min_size = .{ .width = @max(parent_text.len, " replying to ".len) + 2, .height = null } }); 237 } 238 239 bar.getFocus().child_id = bar.children.keys()[0]; 240 try box.children.put(allocator, bar.getFocus().id, .{ .widget = .{ .box = bar }, .rect = null, .min_size = null }); 241 } 242 243 const body_text = if (entry.comment.deleted) "(deleted)" else entry.comment.event.body; 244 var body_box = try wgt.TextBox(ui.Widget).init(allocator, body_text, .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .word }); 245 errdefer body_box.deinit(allocator); 246 body_box.getFocus().focusable = true; 247 try box.children.put(allocator, body_box.getFocus().id, .{ .widget = .{ .text_box = body_box }, .rect = null, .min_size = null }); 248 box.getFocus().child_id = box.children.keys()[metadata_index]; 249 250 return .{ .box = box }; 251 } 252 253 fn metadata(self: *Item) *wgt.Box(ui.Widget) { 254 return &self.box.children.values()[metadata_index].widget.box; 255 } 256 257 fn body(self: *Item) *wgt.TextBox(ui.Widget) { 258 return &self.box.children.values()[body_index].widget.text_box; 259 } 260 261 pub fn bodyFocused(self: *Item) bool { 262 return self.box.getFocus().child_id == self.body().getFocus().id; 263 } 264 265 pub fn focusMetadata(self: *Item, root_focus: *Focus) void { 266 const bar = self.metadata(); 267 root_focus.setFocus(bar.getFocus().child_id orelse bar.children.keys()[0]); 268 } 269 270 pub fn focusBody(self: *Item, root_focus: *Focus) void { 271 root_focus.setFocus(self.body().getFocus().id); 272 } 273 274 pub fn moveHorizontal(self: *Item, root_focus: *Focus, right: bool) bool { 275 if (self.bodyFocused()) return false; 276 const bar = self.metadata(); 277 const selected = bar.getFocus().child_id orelse return false; 278 const selected_index = bar.children.getIndex(selected) orelse return false; 279 const target = if (right) selected_index + 1 else selected_index -| 1; 280 if (target == selected_index or target >= bar.children.count()) return false; 281 root_focus.setFocus(bar.children.keys()[target]); 282 return true; 283 } 284 285 pub fn rowRect(self: *Item, body_row: bool) ?layout.IRect { 286 return self.box.children.values()[if (body_row) body_index else metadata_index].rect; 287 } 288 289 pub fn deinit(self: *Item, allocator: std.mem.Allocator) void { 290 self.box.deinit(allocator); 291 } 292 293 pub fn build(self: *Item, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 294 try self.box.build(allocator, constraint, root_focus); 295 } 296 297 pub fn input(self: *Item, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 298 try self.box.input(allocator, key, root_focus); 299 } 300 301 pub fn clearGrid(self: *Item) void { 302 self.box.clearGrid(); 303 } 304 305 pub fn getGrid(self: Item) ?Grid { 306 return self.box.getGrid(); 307 } 308 309 pub fn getFocus(self: *Item) *Focus { 310 return self.box.getFocus(); 311 } 312 }; 313 314 // append one comment's metadata bar and body. 315 pub fn appendComment(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), session: *ui.Session, identity: []const u8, entry: CommentWithId) !void { 316 var item = try Item.init(allocator, session, identity, entry); 317 errdefer item.deinit(allocator); 318 try box.children.put(allocator, item.getFocus().id, .{ .widget = .{ .repo_comment = item }, .rect = null, .min_size = null }); 319 } 320 321 pub fn appendCount(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), count: usize, singular: []const u8, plural: []const u8, arena: std.mem.Allocator) !void { 322 const text = try std.fmt.allocPrint(arena, "{d} {s}", .{ count, if (count == 1) singular else plural }); 323 var count_box = try wgt.TextBox(ui.Widget).init(allocator, text, .{ .border_style = .hidden, .wrap_kind = .none }); 324 errdefer count_box.deinit(allocator); 325 try box.children.put(allocator, count_box.getFocus().id, .{ .widget = .{ .text_box = count_box }, .rect = null, .min_size = null }); 326 } 327 328 pub fn appendWindowNav(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), session: *ui.Session, identity: []const u8, thread_id: []const u8, selected_id: ?[]const u8, window: Window) !void { 329 if (!window.has_prev and !window.has_more) return; 330 var row = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .horiz }); 331 errdefer row.deinit(allocator); 332 if (window.has_prev) { 333 const start = window.start -| page_size; 334 const route = if (selected_id) |id| 335 ui.RoutablePage.repoCommentsRoute(identity, thread_id, id, start) 336 else 337 ui.RoutablePage.repoIssueCommentsRoute(identity, thread_id, start); 338 var previous = try linkBox(allocator, session, "← previous", route orelse return error.RouteTooLong); 339 errdefer previous.deinit(allocator); 340 try row.children.put(allocator, previous.getFocus().id, .{ .widget = .{ .text_box = previous }, .rect = null, .min_size = null }); 341 } 342 if (window.has_more) { 343 const start = window.start + page_size; 344 const route = if (selected_id) |id| 345 ui.RoutablePage.repoCommentsRoute(identity, thread_id, id, start) 346 else 347 ui.RoutablePage.repoIssueCommentsRoute(identity, thread_id, start); 348 var next = try linkBox(allocator, session, "next →", route orelse return error.RouteTooLong); 349 errdefer next.deinit(allocator); 350 try row.children.put(allocator, next.getFocus().id, .{ .widget = .{ .text_box = next }, .rect = null, .min_size = null }); 351 } 352 row.getFocus().child_id = row.children.keys()[0]; 353 try box.children.put(allocator, row.getFocus().id, .{ .widget = .{ .box = row }, .rect = null, .min_size = null }); 354 } 355 356 fn linkBox(allocator: std.mem.Allocator, session: *ui.Session, text: []const u8, route: ui.RoutablePage) !wgt.TextBox(ui.Widget) { 357 var box = try wgt.TextBox(ui.Widget).init(allocator, text, .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .none }); 358 errdefer box.deinit(allocator); 359 box.getFocus().focusable = true; 360 box.getFocus().kind = .{ .custom = try std.fmt.allocPrint(session.page_arena.allocator(), "a:{s}", .{try route.toUrl(session.page_arena)}) }; 361 return box; 362 } 363 364 pub const View = struct { 365 scroll: wgt.Scroll(ui.Widget), 366 data: *const Self, 367 session: *ui.Session, 368 369 pub fn init(allocator: std.mem.Allocator, data: *const Self, session: *ui.Session) !View { 370 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); 371 errdefer box.deinit(allocator); 372 373 if (data.show_thread) { 374 var thread = try linkBox(allocator, session, "show thread", ui.RoutablePage.repoIssueCommentsRoute(data.identity, data.thread_id, 0) orelse return error.RouteTooLong); 375 errdefer thread.deinit(allocator); 376 try box.children.put(allocator, thread.getFocus().id, .{ .widget = .{ .text_box = thread }, .rect = null, .min_size = null }); 377 } 378 379 try appendComment(allocator, &box, session, data.identity, data.selected); 380 try appendCount(allocator, &box, data.replies.count, "reply", "replies", session.page_arena.allocator()); 381 for (data.replies.comments) |comment| try appendComment(allocator, &box, session, data.identity, comment); 382 try appendWindowNav(allocator, &box, session, data.identity, data.thread_id, data.selected.id, data.replies); 383 384 box.getFocus().child_id = box.children.keys()[0]; 385 386 const scroll = try wgt.Scroll(ui.Widget).init(allocator, .{ .box = box }, .{ .direction = .vert, .web_native = !session.is_terminal }); 387 return .{ .scroll = scroll, .data = data, .session = session }; 388 } 389 390 pub fn deinit(self: *View, allocator: std.mem.Allocator) void { 391 self.scroll.deinit(allocator); 392 } 393 394 pub fn build(self: *View, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 395 self.clearGrid(); 396 self.session.data.current_page = ui.RoutablePage.repoCommentsRoute(self.data.identity, self.data.thread_id, self.data.selected.id, self.data.replies.start) orelse self.session.data.current_page; 397 try self.scroll.build(allocator, constraint, root_focus); 398 } 399 400 pub fn input(self: *View, allocator: std.mem.Allocator, raw_key: Key, root_focus: *Focus) !void { 401 _ = allocator; 402 const key: Key = if (raw_key == .mouse and raw_key.mouse.action == .scroll) 403 (if (raw_key.mouse.action.scroll == .up) .arrow_up else .arrow_down) 404 else 405 raw_key; 406 switch (key) { 407 .arrow_up => { 408 if (!self.moveVertical(root_focus, false)) self.scroll.y -= 1; 409 }, 410 .arrow_down => { 411 if (!self.moveVertical(root_focus, true)) self.scroll.y += 1; 412 }, 413 .arrow_left => _ = self.moveHorizontal(root_focus, false), 414 .arrow_right => _ = self.moveHorizontal(root_focus, true), 415 .page_up => self.scroll.y -= 10, 416 .page_down => self.scroll.y += 10, 417 .home => self.scroll.y = 0, 418 .end => self.scroll.y = std.math.maxInt(isize), 419 else => {}, 420 } 421 self.scroll.clampToContent(); 422 } 423 424 fn content(self: *View) *wgt.Box(ui.Widget) { 425 return &self.scroll.child.box; 426 } 427 428 fn focusedChild(self: *View, root_focus: *Focus) ?usize { 429 const box = self.content(); 430 var id = root_focus.grandchild_id orelse return null; 431 while (box.getFocus().children.get(id)) |child| { 432 if (child.parent_id == box.getFocus().id) return box.children.getIndex(id); 433 id = child.parent_id; 434 } 435 return null; 436 } 437 438 fn childFocusable(self: *View, child_index: usize) bool { 439 const child = &self.content().children.values()[child_index].widget; 440 return switch (child.*) { 441 .repo_comment => true, 442 .box => |*box| for (box.children.values()) |*item| { 443 if (item.widget.getFocus().focusable) break true; 444 } else false, 445 else => child.getFocus().focusable, 446 }; 447 } 448 449 fn focusChild(self: *View, child_index: usize, last: bool, root_focus: *Focus) void { 450 const box = self.content(); 451 const child = &box.children.values()[child_index]; 452 var target = box.children.keys()[child_index]; 453 var rect = child.rect orelse return; 454 switch (child.widget) { 455 .repo_comment => |*comment| { 456 if (comment.rowRect(last)) |row_rect| { 457 rect.x += row_rect.x; 458 rect.y += row_rect.y; 459 rect.size = row_rect.size; 460 } 461 if (last) comment.focusBody(root_focus) else comment.focusMetadata(root_focus); 462 }, 463 .box => |*row| if (row.children.count() > 0) { 464 target = row.children.keys()[if (last) row.children.count() - 1 else 0]; 465 root_focus.setFocus(target); 466 }, 467 else => root_focus.setFocus(target), 468 } 469 self.scroll.scrollToRect(rect); 470 } 471 472 fn moveVertical(self: *View, root_focus: *Focus, down: bool) bool { 473 const box = self.content(); 474 const current = self.focusedChild(root_focus) orelse return false; 475 if (box.children.values()[current].widget == .repo_comment) { 476 const comment = &box.children.values()[current].widget.repo_comment; 477 if (down and !comment.bodyFocused()) { 478 self.focusChild(current, true, root_focus); 479 return true; 480 } 481 if (!down and comment.bodyFocused()) { 482 self.focusChild(current, false, root_focus); 483 return true; 484 } 485 } 486 487 var next = current; 488 while (true) { 489 if (down) { 490 next += 1; 491 if (next >= box.children.count()) return false; 492 } else { 493 if (next == 0) return false; 494 next -= 1; 495 } 496 if (!self.childFocusable(next)) continue; 497 self.focusChild(next, !down, root_focus); 498 return true; 499 } 500 } 501 502 fn moveHorizontal(self: *View, root_focus: *Focus, right: bool) bool { 503 const box = self.content(); 504 const current = self.focusedChild(root_focus) orelse return false; 505 const child = &box.children.values()[current]; 506 if (child.widget == .repo_comment) { 507 const moved = child.widget.repo_comment.moveHorizontal(root_focus, right); 508 if (moved) if (child.rect) |rect| self.scroll.scrollToRect(rect); 509 return moved; 510 } 511 const row = switch (child.widget) { 512 .box => |*row| row, 513 else => return false, 514 }; 515 const selected = row.getFocus().child_id orelse return false; 516 const selected_index = row.children.getIndex(selected) orelse return false; 517 const target = if (right) selected_index + 1 else selected_index -| 1; 518 if (target == selected_index or target >= row.children.count()) return false; 519 root_focus.setFocus(row.children.keys()[target]); 520 if (child.rect) |rect| self.scroll.scrollToRect(rect); 521 return true; 522 } 523 524 pub fn clearGrid(self: *View) void { 525 self.scroll.clearGrid(); 526 } 527 528 pub fn getGrid(self: View) ?Grid { 529 return self.scroll.getGrid(); 530 } 531 532 pub fn getFocus(self: *View) *Focus { 533 return self.scroll.getFocus(); 534 } 535 536 pub fn getSelectedIndex(self: *View) ?usize { 537 const box = self.content(); 538 const selected = box.getFocus().child_id orelse return null; 539 for (box.children.keys(), 0..) |id, index| { 540 if (!self.childFocusable(index)) continue; 541 if (id != selected) return 1; 542 return switch (box.children.values()[index].widget) { 543 .repo_comment => |*comment| if (comment.bodyFocused()) 1 else 0, 544 else => 0, 545 }; 546 } 547 return null; 548 } 549 }; 550