╭─────────────╮ │scroll to top│ ╰─────────────╯ ╭ User.zig ─────────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
261 } 262 263 var self = View{ .box = box }; 264 // search results open in the tab's own search box 265 self.getFocus().child_id = box.children.keys()[if (data.repos_search != null) stack_index else header_index]; 266 return self; 267 } 268 269 pub fn deinit(self: *View, allocator: std.mem.Allocator) void { 270 self.box.deinit(allocator); 271 } 272 273 pub fn build(self: *View, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 274 self.clearGrid(); 275 const header = &self.box.children.values()[header_index].widget.user_header; 276 const stack = &self.box.children.values()[stack_index].widget.stack; 277 278 // each header tab maps 1:1 to a stack child by position 279 if (header.getSelectedIndex()) |index| 280 stack.getFocus().child_id = stack.children.keys()[index]; 281 try self.box.build(allocator, constraint, root_focus); 282 } 283 284 pub fn input(self: *View, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 285 const stack = &self.box.children.values()[stack_index].widget.stack; 286 if (self.getFocus().child_id) |child_id| { 287 if (self.box.children.getIndex(child_id)) |current_index| { 288 const child = &self.box.children.values()[current_index].widget; 289 var index = current_index; 290 291 const direction = inp.vertDirection(key); 292 293 switch (direction) { 294 .up => { 295 switch (child.*) { 296 .user_header => { 297 try child.input(allocator, key, root_focus); 298 }, 299 .stack => { 300 if (child.stack.getSelected()) |selected_widget| { 301 if (selected_widget.atTop(root_focus)) { 302 index = header_index; 303 } else { 304 try child.input(allocator, key, root_focus); 305 } 306 } 307 }, 308 else => {}, 309 } 310 }, 311 .down => { 312 switch (child.*) { 313 .user_header => { 314 if (stack.getSelected()) |selected_widget| switch (selected_widget.*) { 315 .user_repos => |*v| return v.focusHeader(root_focus), 316 // an empty list has nothing to focus 317 .flow_box_scroll => |*v| if (v.isEmpty()) return, 318 else => {}, 319 }; 320 index = stack_index; 321 }, 322 .stack => { 323 try child.input(allocator, key, root_focus); 324 }, 325 else => {}, 326 } 327 }, 328 .none => { 329 try child.input(allocator, key, root_focus); 330 }, 331 } 332 333 if (index != current_index) { 334 root_focus.setFocus(self.box.children.keys()[index]); 335 } 336 } 337 } 338 } 339 340 pub fn clearGrid(self: *View) void { 341 self.box.clearGrid(); 342 } 343 344 pub fn getGrid(self: View) ?Grid { 345 return self.box.getGrid(); 346 } 347 348 pub fn getFocus(self: *View) *Focus { 349 return self.box.getFocus(); 350 } 351 }; 352 353 pub const ReposView = struct { 354 // a vertical box: the search sub-header above the scrolling list. focus 355 // points at the header's box or the list's selected row. 356 box: wgt.Box(ui.Widget), 357 data: *const Self, 358 session: *ui.Session, 359 360 const header_index = 0; 361 const list_index = 1; 362 363 pub fn init(allocator: std.mem.Allocator, data: *const Self, session: *ui.Session) !ReposView { 364 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border = null, .direction = .vert }); 365 errdefer box.deinit(allocator); 366 367 { 368 var header = try wgt.Box(ui.Widget).init(allocator, .{ .border = null, .direction = .horiz }); 369 errdefer header.deinit(allocator); 370 { 371 var search_box = try ui.widget.SearchBox.init(allocator, session, " search ", "search", data.repos_search); 372 errdefer search_box.deinit(allocator); 373 header.getFocus().child_id = search_box.getFocus().id; 374 try header.children.put(allocator, search_box.getFocus().id, .{ .widget = .{ .search_box = search_box }, .rect = null, .min_size = ui.widget.SearchBox.min_size }); 375 } 376 // the spacer keeps the box at its own width 377 { 378 var spacer = try ui.widget.Spacer.init(allocator); 379 errdefer spacer.deinit(allocator); 380 try header.children.put(allocator, spacer.getFocus().id, .{ .widget = .{ .spacer = spacer }, .rect = null, .min_size = null }); 381 } 382 try box.children.put(allocator, header.getFocus().id, .{ .widget = .{ .box = header }, .rect = null, .min_size = .{ .width = null, .height = ui.widget.SearchBox.min_size.height } }); 383 } 384 385 { 386 var list = try ui.widget.FlowBox.Scroll.init(allocator, .{}, !session.is_terminal); 387 errdefer list.deinit(allocator); 388 389 var arena = std.heap.ArenaAllocator.init(allocator); 390 defer arena.deinit(); 391 const aa = arena.allocator(); 392 393 const route = (reposRoute(data.user.name) orelse return error.RouteTooLong).withSearch(data.repos_search orelse "") orelse return error.RouteTooLong; 394 395 // a leading "previous" row off the first window, one row per repo, 396 // then a trailing "next" row when more remain. each window row 397 // navigates to the adjacent window of this user's repos. 398 var items: std.ArrayList(ui.widget.FlowBox.Item) = .empty; 399 if (data.repos_start > 0) { 400 var prev = route; 401 prev.user_repos.start = data.repos_start -| page_size; 402 try items.append(aa, .{ .text = "← previous", .link = try aa.print("a:{s}", .{try prev.toUrl(&arena)}) }); 403 } 404 for (data.repos) |repo| 405 // clicking a repo opens its page; the "a:" prefix makes the web 406 // renderer emit an <a href="/alice:foo"> anchor. 407 try items.append(aa, .{ 408 .text = if (repo.event.description.len == 0) repo.event.name else try aa.print("{s} - {s}", .{ repo.event.name, repo.event.description }), 409 .link = try aa.print("a:/{s}:{s}", .{ data.user.name, repo.event.name }), 410 .bottom_label = if (repo.event.read_access == .private) "(private)" else "", 411 }); 412 if (data.repos_next_start) |next_start| { 413 var next = route; 414 next.user_repos.start = next_start; 415 try items.append(aa, .{ .text = "next →", .link = try aa.print("a:{s}", .{try next.toUrl(&arena)}) }); 416 } 417 try list.setItems(allocator, items.items); 418 419 try box.children.put(allocator, list.getFocus().id, .{ .widget = .{ .flow_box_scroll = list }, .rect = null, .min_size = null }); 420 } 421 422 // search results start in the box so the term can be refined right away 423 box.getFocus().child_id = box.children.keys()[if (data.repos_search != null) header_index else list_index]; 424 return .{ .box = box, .data = data, .session = session }; 425 } 426 427 pub fn deinit(self: *ReposView, allocator: std.mem.Allocator) void { 428 self.box.deinit(allocator); 429 } 430 431 pub fn build(self: *ReposView, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 432 self.clearGrid(); 433 // clear the incoming min height so the list scrolls within what's left 434 try self.box.build(allocator, .{ 435 .min_size = .{ .width = null, .height = null }, 436 .max_size = constraint.max_size, 437 }, root_focus); 438 } 439 440 pub fn input(self: *ReposView, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 441 const direction = inp.vertDirection(key); 442 if (self.headerActive()) { 443 const search_box = self.searchBox(); 444 if (direction == .down) return root_focus.setFocus(self.listScroll().getFocus().id); 445 if (key == .enter) { 446 const text = try search_box.text(allocator); 447 defer allocator.free(text); 448 return self.submit(text); 449 } 450 return search_box.input(allocator, key, root_focus); 451 } 452 // up from the first row reaches the search box 453 if (direction == .up and self.listScroll().atTop()) return self.focusHeader(root_focus); 454 try self.listScroll().input(allocator, key, root_focus); 455 } 456 457 // the repos narrowed to `text` from the first window. an empty box on an 458 // unsearched page has nothing to clear. 459 fn submit(self: *ReposView, text: []const u8) !void { 460 if (text.len == 0 and self.data.repos_search == null) return; 461 const route = reposRoute(self.data.user.name) orelse return; 462 try self.session.navigate(route.withSearch(text) orelse return); 463 } 464 465 fn searchBox(self: *ReposView) *ui.widget.SearchBox { 466 return &self.box.children.values()[header_index].widget.box.children.values()[0].widget.search_box; 467 } 468 469 fn listScroll(self: *ReposView) *ui.widget.FlowBox.Scroll { 470 return &self.box.children.values()[list_index].widget.flow_box_scroll; 471 } 472 473 fn headerActive(self: *ReposView) bool { 474 return self.box.getFocus().child_id == self.box.children.keys()[header_index]; 475 } 476 477 pub fn clearGrid(self: *ReposView) void { 478 self.box.clearGrid(); 479 } 480 481 pub fn getGrid(self: ReposView) ?Grid { 482 return self.box.getGrid(); 483 } 484 485 pub fn getFocus(self: *ReposView) *Focus { 486 return self.box.getFocus(); 487 } 488 489 // up leaves the tab from the search box 490 pub fn atTop(self: *ReposView) bool { 491 return self.headerActive(); 492 } 493 494 pub fn focusHeader(self: *ReposView, root_focus: *Focus) void { 495 root_focus.setFocus(self.searchBox().getFocus().id); 496 } 497 }; 498 499 // the first window of `name`'s repos 500 fn reposRoute(name: []const u8) ?ui.RoutablePage { 501 return .{ .user_repos = .{ .name = ui.RoutablePage.Array(evt.User.name_max_len).from(name) orelse return null } }; 502 } 503