╭─────────────╮ │scroll to top│ ╰─────────────╯ ╭ Refs.zig ─────────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
245 var flow = try ui.widget.WordFlow.init(allocator); 246 errdefer flow.deinit(allocator); 247 try flow.setItems(allocator, items.items); 248 break :blk try wgt.Scroll(ui.Widget).init(allocator, .{ .word_flow = flow }, .{ .direction = .vert, .web_native = !session.is_terminal, .fill = true }); 249 }; 250 errdefer scroll.deinit(allocator); 251 252 // the column's focus path skips the label and points at the flow 253 if (scroll.getFocus().child_id != null) column.getFocus().child_id = scroll.getFocus().id; 254 try column.children.put(allocator, scroll.getFocus().id, .{ .widget = .{ .scroll = scroll }, .rect = null, .min_size = null }); 255 } 256 257 try box.children.put(allocator, column.getFocus().id, .{ .widget = .{ .box = column }, .rect = null, .min_size = null }); 258 } 259 260 pub fn deinit(self: *View, allocator: std.mem.Allocator) void { 261 self.box.deinit(allocator); 262 } 263 264 pub fn build(self: *View, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 265 self.clearGrid(); 266 267 // split the available width evenly between the two columns when it's 268 // known; otherwise let them size to their content. 269 if (constraint.max_size.width) |w| { 270 const half = w / 2; 271 for (self.columns().children.values()) |*child| { 272 child.min_size = .{ .width = half, .height = null }; 273 child.max_size = .{ .width = half, .height = null }; 274 } 275 } else { 276 for (self.columns().children.values()) |*child| { 277 child.min_size = null; 278 child.max_size = null; 279 } 280 } 281 282 // clear the incoming min height; each column's Scroll fills the viewport 283 // height itself (via `fill`), keeping its bar pinned to the edge. 284 try self.box.build(allocator, .{ 285 .min_size = .{ .width = null, .height = null }, 286 .max_size = constraint.max_size, 287 }, root_focus); 288 } 289 290 pub fn input(self: *View, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 291 if (self.headerActive()) { 292 const search_box = self.searchBox(); 293 if (inp.vertDirection(key) == .down) return root_focus.setFocus(self.columns().getFocus().id); 294 if (key == .enter) { 295 const text = try search_box.text(allocator); 296 defer allocator.free(text); 297 return self.submit(text); 298 } 299 return search_box.input(allocator, key, root_focus); 300 } 301 // arrows and the scroll wheel move the selection within the active 302 // flow. left from a row's first name and right from its last cross to 303 // the other column's same row. up from a first row reaches the search 304 // box. 305 const index = self.activeIndex() orelse return; 306 const flow = self.flowAt(index); 307 const cur = flow.indexOfFocusId(flow.focus.child_id orelse return) orelse return; 308 // a flow last laid out at zero width has names without positions 309 if (cur >= flow.rects.items.len) return; 310 const count = flow.text_boxes.items.len; 311 const cur_y = flow.rects.items[cur].y; 312 switch (inp.vertDirection(key)) { 313 .up => { 314 if (flow.rowStep(cur, false)) |i| self.focusName(index, i, root_focus) else _ = self.focusHeader(root_focus); 315 return; 316 }, 317 .down => { 318 if (flow.rowStep(cur, true)) |i| self.focusName(index, i, root_focus); 319 return; 320 }, 321 .none => {}, 322 } 323 switch (key) { 324 .arrow_left => if (index == right_col and rowStart(flow, cur)) { 325 self.crossTo(left_col, cur_y, root_focus); 326 } else if (cur > 0) self.focusName(index, cur - 1, root_focus), 327 .arrow_right => if (index == left_col and rowEnd(flow, cur)) { 328 self.crossTo(right_col, cur_y, root_focus); 329 } else if (cur + 1 < count) self.focusName(index, cur + 1, root_focus), 330 .home => self.focusName(index, 0, root_focus), 331 .end => self.focusName(index, count - 1, root_focus), 332 else => {}, 333 } 334 } 335 336 // both lists narrowed to `text`, each from its first window. an empty box 337 // on an unsearched page has nothing to clear. 338 fn submit(self: *View, text: []const u8) !void { 339 if (text.len == 0 and self.data.search == null) return; 340 const route = ui.RoutablePage.repoRefsRoute(self.data.identity, .branch, "") orelse return; 341 try self.session.navigate(route.withSearch(text) orelse return); 342 } 343 344 fn searchBox(self: *View) *ui.widget.SearchBox { 345 return &self.box.children.values()[header_index].widget.box.children.values()[0].widget.search_box; 346 } 347 348 fn headerActive(self: *View) bool { 349 return self.box.getFocus().child_id == self.box.children.keys()[header_index]; 350 } 351 352 fn columns(self: *View) *wgt.Box(ui.Widget) { 353 return &self.box.children.values()[columns_index].widget.box; 354 } 355 356 fn activeIndex(self: *View) ?usize { 357 const cols = self.columns(); 358 const id = cols.getFocus().child_id orelse return null; 359 return cols.children.getIndex(id); 360 } 361 362 // [0] = the fixed label, [1] = the scrolling flow 363 fn scrollAt(self: *View, index: usize) *wgt.Scroll(ui.Widget) { 364 return &self.columns().children.values()[index].widget.box.children.values()[1].widget.scroll; 365 } 366 367 fn flowAt(self: *View, index: usize) *ui.widget.WordFlow { 368 return &self.scrollAt(index).child.word_flow; 369 } 370 371 fn rowStart(flow: *ui.widget.WordFlow, item: usize) bool { 372 const rects = flow.rects.items; 373 return item == 0 or rects[item - 1].y != rects[item].y; 374 } 375 376 fn rowEnd(flow: *ui.widget.WordFlow, item: usize) bool { 377 const rects = flow.rects.items; 378 return item + 1 == rects.len or rects[item + 1].y != rects[item].y; 379 } 380 381 // land on the other column's row at or above the current one: its first 382 // name when moving right, its last when moving left. names are laid out in 383 // order, so that row ends at the last name no lower than the current one. 384 fn crossTo(self: *View, target: usize, from_y: isize, root_focus: *Focus) void { 385 // the other column may have no names at all 386 const rects = self.flowAt(target).rects.items; 387 if (rects.len == 0) return; 388 var last: usize = 0; 389 for (rects, 0..) |rect, i| { 390 if (rect.y <= from_y) last = i; 391 } 392 var first = last; 393 while (first > 0 and rects[first - 1].y == rects[last].y) first -= 1; 394 self.focusName(target, if (target == right_col) first else last, root_focus); 395 } 396 397 fn focusName(self: *View, index: usize, item: usize, root_focus: *Focus) void { 398 const flow = self.flowAt(index); 399 root_focus.setFocus(flow.text_boxes.items[item].getFocus().id); 400 // the browser scrolls natively; the terminal brings the name into view 401 if (self.session.is_terminal and item < flow.rects.items.len) self.scrollAt(index).scrollToRect(flow.rects.items[item]); 402 } 403 404 pub fn clearGrid(self: *View) void { 405 self.box.clearGrid(); 406 } 407 408 pub fn getGrid(self: View) ?Grid { 409 return self.box.getGrid(); 410 } 411 412 pub fn getFocus(self: *View) *Focus { 413 return self.box.getFocus(); 414 } 415 416 // up leaves the tab from the search box 417 pub fn atTop(self: *View) bool { 418 return self.headerActive(); 419 } 420 421 pub fn focusHeader(self: *View, root_focus: *Focus) bool { 422 root_focus.setFocus(self.searchBox().getFocus().id); 423 return true; 424 } 425 }; 426 427 // the "a:" link to `kind`'s column windowed from ref `name` (raw; "" = the 428 // first window) within `identity` ("owner:name"). the other column resets to 429 // its first window. 430 fn windowLink(page_arena: *std.heap.ArenaAllocator, identity: []const u8, kind: ui.RoutablePage.RefKind, name: []const u8, search: []const u8) ![]const u8 { 431 const encoded = try ui.urlEncodeRef(page_arena.allocator(), name); 432 const window = ui.RoutablePage.repoRefsRoute(identity, kind, encoded) orelse return error.RouteTooLong; 433 const route = window.withSearch(search) orelse return error.RouteTooLong; 434 const url = try route.toUrl(page_arena); 435 return page_arena.allocator().print("a:{s}", .{url}); 436 } 437 438 // the "a:" link to the files tab at ref `name` (a branch or tag) within 439 // `identity` ("owner:name"), at its root directory. the name is percent-encoded 440 // since it can contain a '/'. 441 fn refLink(page_arena: *std.heap.ArenaAllocator, identity: []const u8, kind: ui.RoutablePage.RefKind, name: []const u8) ![]const u8 { 442 const aa = page_arena.allocator(); 443 const ref_or_oid: ui.RoutablePage.RefOrOid = switch (kind) { 444 .branch => .branch, 445 .tag => .tag, 446 }; 447 const value = try ui.urlEncodeRef(aa, name); 448 const route = ui.RoutablePage.repoFilesRoute(identity, ref_or_oid, value, "", 0) orelse return error.RouteTooLong; 449 const url = try route.toUrl(page_arena); 450 return aa.print("a:{s}", .{url}); 451 } 452