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