╭─────────────╮ │scroll to top│ ╰─────────────╯ ╭ Refs.zig ─────────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
165 errdefer space.deinit(allocator); 166 try header_box.children.put(allocator, space.getFocus().id, .{ .widget = .{ .text = space }, .rect = null, .min_size = null }); 167 } 168 { 169 var header = try ui.SubTitle.View.init(allocator, label); 170 errdefer header.deinit(allocator); 171 try header_box.children.put(allocator, header.getFocus().id, .{ .widget = .{ .sub_title = header }, .rect = null, .min_size = null }); 172 } 173 try column.children.put(allocator, header_box.getFocus().id, .{ .widget = .{ .box = header_box }, .rect = null, .min_size = .{ .width = null, .height = 2 } }); 174 } 175 176 // a long ref list overflows the page height; the rows scroll on their 177 // own (the label above stays put) so they lay out under a fixed height. 178 { 179 var scroll = blk: { 180 var rows = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); 181 errdefer rows.deinit(allocator); 182 183 // window-navigation rows bracket the names: "← previous" off the 184 // first window, "next →" when more remain. each is a full reload. 185 if (after > 0) try addRow(allocator, &rows, "← previous", try windowLink(session.page_arena, identity, kind, after -| page_size)); 186 // each ref name links to the files tab at that ref's root. 187 for (names) |name| try addRow(allocator, &rows, name, try refLink(session.page_arena, identity, kind, name)); 188 if (next_after) |na| try addRow(allocator, &rows, "next →", try windowLink(session.page_arena, identity, kind, na)); 189 190 if (rows.children.count() > 0) rows.getFocus().child_id = rows.children.keys()[0]; 191 192 break :blk try wgt.Scroll(ui.Widget).init(allocator, .{ .box = rows }, .{ .direction = .vert, .web_native = !session.is_terminal }); 193 }; 194 errdefer scroll.deinit(allocator); 195 196 // the column's focus path skips the label and points at the scroll. 197 if (scroll.getFocus().child_id != null) column.getFocus().child_id = scroll.getFocus().id; 198 try column.children.put(allocator, scroll.getFocus().id, .{ .widget = .{ .scroll = scroll }, .rect = null, .min_size = null }); 199 } 200 201 try box.children.put(allocator, column.getFocus().id, .{ .widget = .{ .box = column }, .rect = null, .min_size = null }); 202 } 203 204 // [0] = the fixed label, [1] = the scrollable rows. 205 fn columnScroll(column: *wgt.Box(ui.Widget)) *wgt.Scroll(ui.Widget) { 206 return &column.children.values()[1].widget.scroll; 207 } 208 209 // a focusable row in a column. `link`, when present, is the row's `a:` 210 // navigation kind (the window-navigation rows); ref names pass null. 211 fn addRow(allocator: std.mem.Allocator, col: *wgt.Box(ui.Widget), label: []const u8, link: ?[]const u8) !void { 212 var row = try wgt.TextBox(ui.Widget).init(allocator, label, .{ .border_style = .hidden, .rounded_corners = true, .wrap_kind = .none }); 213 errdefer row.deinit(allocator); 214 row.getFocus().focusable = true; 215 if (link) |l| row.getFocus().kind = .{ .custom = l }; 216 try col.children.put(allocator, row.getFocus().id, .{ .widget = .{ .text_box = row }, .rect = null, .min_size = null }); 217 } 218 219 pub fn deinit(self: *View, allocator: std.mem.Allocator) void { 220 self.box.deinit(allocator); 221 } 222 223 pub fn build(self: *View, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 224 self.clearGrid(); 225 226 // the selected row (in the active column) shows a single border; the 227 // focused TextBox upgrades it to a double border itself. 228 const active_col_id = self.box.getFocus().child_id; 229 for (self.box.children.values()) |*child| { 230 const column = &child.widget.box; 231 const col = &columnScroll(column).child.box; 232 const active = column.getFocus().id == active_col_id; 233 for (col.children.keys(), col.children.values()) |id, *row| { 234 switch (row.widget) { 235 .text_box => |*tb| tb.options.border_style = if (active and col.getFocus().child_id == id) .single else .hidden, 236 else => {}, 237 } 238 } 239 } 240 241 // split the available width evenly between the two columns when it's 242 // known; otherwise let them size to their content. the scroll's own 243 // min width stretches it (and its rows/scroll bar) across the column 244 // rather than only the widest row; the Scroll passes that min down to 245 // its rows. (index 1 in each column box is the scroll; 0 is the label.) 246 if (constraint.max_size.width) |w| { 247 const half = w / 2; 248 for (self.box.children.values()) |*child| { 249 child.min_size = .{ .width = half, .height = null }; 250 child.max_size = .{ .width = half, .height = null }; 251 child.widget.box.children.values()[1].min_size = .{ .width = half, .height = null }; 252 } 253 } else { 254 for (self.box.children.values()) |*child| { 255 child.min_size = null; 256 child.max_size = null; 257 child.widget.box.children.values()[1].min_size = null; 258 } 259 } 260 261 // clear the incoming min height so each column sizes to its content and 262 // its Scroll clips to the viewport rather than stretching to fill. 263 try self.box.build(allocator, .{ 264 .min_size = .{ .width = null, .height = null }, 265 .max_size = constraint.max_size, 266 }, root_focus); 267 } 268 269 pub fn input(self: *View, allocator: std.mem.Allocator, key: inp.Key, root_focus: *Focus) !void { 270 _ = allocator; 271 // up/down (and the scroll wheel) move within the active column a row; 272 // page up/down and home/end jump; left/right switch columns, keeping the 273 // same row offset. the parent (Repo) intercepts up at the top row to move 274 // focus to the header. 275 switch (key) { 276 .arrow_up => try self.moveRow(root_focus, -1), 277 .arrow_down => try self.moveRow(root_focus, 1), 278 .arrow_left => try self.switchColumn(root_focus, left_col), 279 .arrow_right => try self.switchColumn(root_focus, right_col), 280 .page_down => try self.moveRow(root_focus, 10), 281 .page_up => try self.moveRow(root_focus, -10), 282 .end => try self.moveRow(root_focus, self.columnRowCount()), 283 .home => try self.moveRow(root_focus, -self.columnRowCount()), 284 .mouse => |mouse| switch (mouse.action) { 285 .scroll => |dir| try self.moveRow(root_focus, if (dir == .up) -1 else 1), 286 else => {}, 287 }, 288 else => {}, 289 } 290 } 291 292 // the active column's row count, used as a clamp-to-end delta for home/end. 293 fn columnRowCount(self: *View) isize { 294 const col = self.activeColumn() orelse return 0; 295 return @intCast(col.children.count()); 296 } 297 298 fn activeScroll(self: *View) ?*wgt.Scroll(ui.Widget) { 299 const id = self.box.getFocus().child_id orelse return null; 300 const index = self.box.children.getIndex(id) orelse return null; 301 return columnScroll(&self.box.children.values()[index].widget.box); 302 } 303 304 fn activeColumn(self: *View) ?*wgt.Box(ui.Widget) { 305 return &(self.activeScroll() orelse return null).child.box; 306 } 307 308 fn moveRow(self: *View, root_focus: *Focus, delta: isize) !void { 309 const scroll = self.activeScroll() orelse return; 310 const col = &scroll.child.box; 311 const keys = col.children.keys(); 312 if (keys.len == 0) return; 313 const cur_id = col.getFocus().child_id orelse return; 314 const cur: isize = @intCast(col.children.getIndex(cur_id) orelse return); 315 // clamp a delta past either end to the first/last row. 316 const last: isize = @intCast(keys.len - 1); 317 const next: usize = @intCast(std.math.clamp(cur + delta, 0, last)); 318 if (next == @as(usize, @intCast(cur))) return; 319 try root_focus.setFocus(keys[next]); 320 if (col.children.values()[next].rect) |rect| scroll.scrollToRect(rect); 321 } 322 323 fn switchColumn(self: *View, root_focus: *Focus, target: usize) !void { 324 if (target >= self.box.children.count()) return; 325 const cur_col = self.activeColumn() orelse return; 326 const target_scroll = columnScroll(&self.box.children.values()[target].widget.box); 327 const target_col = &target_scroll.child.box; 328 // an empty column has nothing to select; ignore. 329 if (target_col.children.count() == 0) return; 330 // keep the same row offset, clamped to the target column's last row. 331 const cur_id = cur_col.getFocus().child_id orelse return; 332 const cur = cur_col.children.getIndex(cur_id) orelse return; 333 const last = target_col.children.count() - 1; 334 const next = @min(cur, last); 335 try root_focus.setFocus(target_col.children.keys()[next]); 336 if (target_col.children.values()[next].rect) |rect| target_scroll.scrollToRect(rect); 337 } 338 339 pub fn clearGrid(self: *View) void { 340 self.box.clearGrid(); 341 } 342 343 pub fn getGrid(self: View) ?Grid { 344 return self.box.getGrid(); 345 } 346 347 pub fn getFocus(self: *View) *Focus { 348 return self.box.getFocus(); 349 } 350 351 // the selected row's offset within the active column (0 = first row), so the 352 // parent knows when up should escape to the header. 353 pub fn getSelectedIndex(self: View) ?usize { 354 var copy = self; 355 const col = copy.activeColumn() orelse return null; 356 const cur_id = col.getFocus().child_id orelse return null; 357 return col.children.getIndex(cur_id); 358 } 359 }; 360 361 // the "a:" link to `kind`'s column paginated to `after` within `identity` 362 // ("owner/name"). the other column resets to its first window. 363 fn windowLink(page_arena: *std.heap.ArenaAllocator, identity: []const u8, kind: ui.RoutablePage.RefKind, after: usize) ![]const u8 { 364 const route = ui.RoutablePage.repoRefsRoute(identity, kind, after) orelse return error.RouteTooLong; 365 const url = try route.urlAlloc(page_arena); 366 return std.fmt.allocPrint(page_arena.allocator(), "a:{s}", .{url}); 367 } 368 369 // the "a:" link to the files tab at ref `name` (a branch or tag) within 370 // `identity` ("owner/name"), at its root directory. the name is percent-encoded 371 // since it can contain a '/'. 372 fn refLink(page_arena: *std.heap.ArenaAllocator, identity: []const u8, kind: ui.RoutablePage.RefKind, name: []const u8) ![]const u8 { 373 const aa = page_arena.allocator(); 374 const ref_or_oid: ui.RoutablePage.RefOrOid = switch (kind) { 375 .branch => .branch, 376 .tag => .tag, 377 }; 378 const value = try ui.ResolvedRefOrOid.urlEncode(aa, name); 379 const route = ui.RoutablePage.repoFilesRoute(identity, ref_or_oid, value, "") orelse return error.RouteTooLong; 380 const url = try route.urlAlloc(page_arena); 381 return std.fmt.allocPrint(aa, "a:{s}", .{url}); 382 } 383