╭─────────────╮ │scroll to top│ ╰─────────────╯ ╭ Refs.zig ─────────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
310 if (key == .enter) { 311 const text = try search_box.text(allocator); 312 defer allocator.free(text); 313 return self.submit(text); 314 } 315 return search_box.input(allocator, key, root_focus); 316 } 317 // arrows and the scroll wheel move the selection within the active 318 // flow. left from a row's first name and right from its last cross to 319 // the other column's same row. up from a first row reaches the search 320 // box. 321 const index = self.activeIndex() orelse return; 322 const flow = self.flowAt(index); 323 const cur = flow.indexOfFocusId(flow.focus.child_id orelse return) orelse return; 324 // a flow last laid out at zero width has names without positions 325 if (cur >= flow.rects.items.len) return; 326 const count = flow.text_boxes.items.len; 327 const cur_y = flow.rects.items[cur].y; 328 switch (inp.vertDirection(key)) { 329 .up => { 330 if (flow.rowStep(cur, false)) |i| self.focusName(index, i, root_focus) else _ = self.focusHeader(root_focus); 331 return; 332 }, 333 .down => { 334 if (flow.rowStep(cur, true)) |i| self.focusName(index, i, root_focus); 335 return; 336 }, 337 .none => {}, 338 } 339 switch (key) { 340 .arrow_left => if (index == right_col and rowStart(flow, cur)) { 341 self.crossTo(left_col, cur_y, root_focus); 342 } else if (cur > 0) self.focusName(index, cur - 1, root_focus), 343 .arrow_right => if (index == left_col and rowEnd(flow, cur)) { 344 self.crossTo(right_col, cur_y, root_focus); 345 } else if (cur + 1 < count) self.focusName(index, cur + 1, root_focus), 346 .home => self.focusName(index, 0, root_focus), 347 .end => self.focusName(index, count - 1, root_focus), 348 else => {}, 349 } 350 } 351 352 // both lists narrowed to `text`, each from its first window. an empty box 353 // on an unsearched page has nothing to clear. 354 fn submit(self: *View, text: []const u8) !void { 355 if (text.len == 0 and self.data.search == null) return; 356 const route = ui.RoutablePage.repoRefsRoute(self.data.identity, .branch, "") orelse return; 357 try self.session.navigate(route.withSearch(text) orelse return); 358 } 359 360 fn searchBox(self: *View) *ui.widget.SearchBox { 361 return &self.box.children.values()[header_index].widget.box.children.values()[0].widget.search_box; 362 } 363 364 fn headerActive(self: *View) bool { 365 return self.box.getFocus().child_id == self.box.children.keys()[header_index]; 366 } 367 368 fn columns(self: *View) *wgt.Box(ui.Widget) { 369 return &self.box.children.values()[columns_index].widget.box; 370 } 371 372 fn activeIndex(self: *View) ?usize { 373 const cols = self.columns(); 374 const id = cols.getFocus().child_id orelse return null; 375 return cols.children.getIndex(id); 376 } 377 378 // [0] = the fixed label, [1] = the scrolling flow 379 fn scrollAt(self: *View, index: usize) *wgt.Scroll(ui.Widget) { 380 return &self.columns().children.values()[index].widget.box.children.values()[1].widget.scroll; 381 } 382 383 fn flowAt(self: *View, index: usize) *ui.widget.WordFlow { 384 return &self.scrollAt(index).child.word_flow; 385 } 386 387 fn rowStart(flow: *ui.widget.WordFlow, item: usize) bool { 388 const rects = flow.rects.items; 389 return item == 0 or rects[item - 1].y != rects[item].y; 390 } 391 392 fn rowEnd(flow: *ui.widget.WordFlow, item: usize) bool { 393 const rects = flow.rects.items; 394 return item + 1 == rects.len or rects[item + 1].y != rects[item].y; 395 } 396 397 // land on the other column's row at or above the current one: its first 398 // name when moving right, its last when moving left. names are laid out in 399 // order, so that row ends at the last name no lower than the current one. 400 fn crossTo(self: *View, target: usize, from_y: isize, root_focus: *Focus) void { 401 // the other column may have no names at all 402 const rects = self.flowAt(target).rects.items; 403 if (rects.len == 0) return; 404 var last: usize = 0; 405 for (rects, 0..) |rect, i| { 406 if (rect.y <= from_y) last = i; 407 } 408 var first = last; 409 while (first > 0 and rects[first - 1].y == rects[last].y) first -= 1; 410 self.focusName(target, if (target == right_col) first else last, root_focus); 411 } 412 413 fn focusName(self: *View, index: usize, item: usize, root_focus: *Focus) void { 414 const flow = self.flowAt(index); 415 root_focus.setFocus(flow.text_boxes.items[item].getFocus().id); 416 // the browser scrolls natively; the terminal brings the name into view 417 if (self.session.is_terminal and item < flow.rects.items.len) self.scrollAt(index).scrollToRect(flow.rects.items[item]); 418 } 419 420 pub fn clearGrid(self: *View) void { 421 self.box.clearGrid(); 422 } 423 424 pub fn getGrid(self: View) ?Grid { 425 return self.box.getGrid(); 426 } 427 428 pub fn getFocus(self: *View) *Focus { 429 return self.box.getFocus(); 430 } 431 432 // up leaves the tab from the search box 433 pub fn atTop(self: *View) bool { 434 return self.headerActive(); 435 } 436 437 pub fn focusHeader(self: *View, root_focus: *Focus) bool { 438 root_focus.setFocus(self.searchBox().getFocus().id); 439 return true; 440 } 441 }; 442 443 // the "a:" link to `kind`'s column windowed from ref `name` (raw; "" = the 444 // first window) within `identity` ("owner:name"). the other column resets to 445 // its first window. 446 fn windowLink(page_arena: *std.heap.ArenaAllocator, identity: []const u8, kind: ui.RoutablePage.RefKind, name: []const u8, search: []const u8) ![]const u8 { 447 const encoded = try ui.urlEncodeRef(page_arena.allocator(), name); 448 const window = ui.RoutablePage.repoRefsRoute(identity, kind, encoded) orelse return error.RouteTooLong; 449 const route = window.withSearch(search) orelse return error.RouteTooLong; 450 const url = try route.toUrl(page_arena); 451 return page_arena.allocator().print("a:{s}", .{url}); 452 } 453 454 // the "a:" link to the files tab at ref `name` (a branch or tag) within 455 // `identity` ("owner:name"), at its root directory. the name is percent-encoded 456 // since it can contain a '/'. 457 fn refLink(page_arena: *std.heap.ArenaAllocator, identity: []const u8, kind: ui.RoutablePage.RefKind, name: []const u8) ![]const u8 { 458 const aa = page_arena.allocator(); 459 const ref_or_oid: ui.RoutablePage.RefOrOid = switch (kind) { 460 .branch => .branch, 461 .tag => .tag, 462 }; 463 const value = try ui.urlEncodeRef(aa, name); 464 const route = ui.RoutablePage.repoFilesRoute(identity, ref_or_oid, value, "", 0) orelse return error.RouteTooLong; 465 const url = try route.toUrl(page_arena); 466 return aa.print("a:{s}", .{url}); 467 } 468