╭─────────────╮ │scroll to top│ ╰─────────────╯ ╭ ui.zig ───────────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
13 pub fn run(io: std.Io, allocator: std.mem.Allocator, page: *const pg.Page) !void { 14 var root = try initRoot(allocator, page); 15 defer root.deinit(); 16 17 var terminal = try term.Terminal.init(io, allocator); 18 defer terminal.deinit(io); 19 20 var last_size = layout.Size{ .width = 0, .height = 0 }; 21 var last_grid = try Grid.init(allocator, last_size); 22 defer last_grid.deinit(); 23 24 while (!term.quit.load(.monotonic)) { 25 const grid_changed = try terminal.render(&root, &last_grid, &last_size); 26 27 // process any inputs. 28 // 29 // if the grid didn't change, then first do a blocking 30 // read, so the thread will sleep until further input. 31 // after that, all remaining reads are non-blocking so 32 // we can process the rest of the queued inputs. 33 // 34 // if the grid *did* change, then only do non-blocking 35 // reads. we do not want to sleep the thread because 36 // there may be an animation that requires more looping. 37 var blocking = !grid_changed; 38 while (try terminal.readKey(io, blocking)) |key| { 39 switch (key) { 40 .codepoint => |cp| if (cp == 'q') return else try root.input(key, root.getFocus()), 41 else => try root.input(key, root.getFocus()), 42 } 43 blocking = false; 44 } 45 46 try root.build(.{ 47 .min_size = .{ .width = null, .height = null }, 48 .max_size = .{ .width = last_size.width, .height = last_size.height }, 49 }, root.getFocus()); 50 } 51 } 52 53 pub fn initRoot(allocator: std.mem.Allocator, page: *const pg.Page) !Widget { 54 var root: Widget = switch (page.*) { 55 .user_repo => |*p| .{ .user_repo_view = try UserRepoView.init(allocator, p) }, 56 }; 57 errdefer root.deinit(); 58 59 try root.build(.{ 60 .min_size = .{ .width = null, .height = null }, 61 .max_size = .{ .width = 80, .height = 24 }, 62 }, root.getFocus()); 63 if (root.getFocus().child_id) |child_id| { 64 try root.getFocus().setFocus(child_id); 65 } 66 67 return root; 68 } 69 70 pub fn generateHtml(allocator: std.mem.Allocator, root: *Widget) ![]const u8 { 71 const grid = root.getGrid() orelse return error.MissingGrid; 72 73 var out: std.ArrayList(u8) = .empty; 74 errdefer out.deinit(allocator); 75 76 for (0..grid.size.height) |y| { 77 for (0..grid.size.width) |x| { 78 const rune = grid.cells.items[try grid.cells.at(.{ y, x })].rune orelse " "; 79 try appendEscapedHtml(allocator, &out, rune); 80 } 81 try out.append(allocator, '\n'); 82 } 83 84 return try out.toOwnedSlice(allocator); 85 } 86 87 fn appendEscapedHtml(allocator: std.mem.Allocator, out: *std.ArrayList(u8), input: []const u8) !void { 88 for (input) |ch| { 89 switch (ch) { 90 '&' => try out.appendSlice(allocator, "&amp;"), 91 '<' => try out.appendSlice(allocator, "&lt;"), 92 '>' => try out.appendSlice(allocator, "&gt;"), 93 '"' => try out.appendSlice(allocator, "&quot;"), 94 '\'' => try out.appendSlice(allocator, "&#39;"), 95 else => try out.append(allocator, ch), 96 } 97 } 98 } 99 100 pub const Widget = union(enum) { 101 text: wgt.Text(Widget), 102 box: wgt.Box(Widget), 103 text_box: wgt.TextBox(Widget), 104 scroll: wgt.Scroll(Widget), 105 selectable_list: SelectableList, 106 user_repo_view: UserRepoView, 107 108 pub fn deinit(self: *Widget) void { 109 switch (self.*) { 110 inline else => |*case| case.deinit(), 111 } 112 } 113 114 pub fn build(self: *Widget, constraint: layout.Constraint, root_focus: *Focus) anyerror!void { 115 switch (self.*) { 116 inline else => |*case| try case.build(constraint, root_focus), 117 } 118 } 119 120 pub fn input(self: *Widget, key: inp.Key, root_focus: *Focus) anyerror!void { 121 switch (self.*) { 122 inline else => |*case| try case.input(key, root_focus), 123 } 124 } 125 126 pub fn clearGrid(self: *Widget) void { 127 switch (self.*) { 128 inline else => |*case| case.clearGrid(), 129 } 130 } 131 132 pub fn getGrid(self: Widget) ?Grid { 133 switch (self) { 134 inline else => |*case| return case.getGrid(), 135 } 136 } 137 138 pub fn getFocus(self: *Widget) *Focus { 139 switch (self.*) { 140 inline else => |*case| return case.getFocus(), 141 } 142 } 143 }; 144 145 // a scrollable list of selectable single-line items. content is replaced 146 // via setItems, which dupes the strings into the widget's own allocation. 147 const SelectableList = struct { 148 allocator: std.mem.Allocator, 149 scroll: wgt.Scroll(Widget), 150 lines: std.ArrayList([]const u8), 151 152 pub fn init(allocator: std.mem.Allocator) !SelectableList { 153 var self = blk: { 154 var inner_box = try wgt.Box(Widget).init(allocator, .{ .border_style = null, .rounded_corners = true, .direction = .vert }); 155 errdefer inner_box.deinit(); 156 157 var scroll = try wgt.Scroll(Widget).init(allocator, .{ .box = inner_box }, .vert); 158 errdefer scroll.deinit(); 159 160 break :blk SelectableList{ 161 .allocator = allocator, 162 .scroll = scroll, 163 .lines = .empty, 164 }; 165 }; 166 errdefer self.deinit(); 167 return self; 168 } 169 170 pub fn deinit(self: *SelectableList) void { 171 self.scroll.deinit(); 172 for (self.lines.items) |line| self.allocator.free(line); 173 self.lines.deinit(self.allocator); 174 } 175 176 pub fn setItems(self: *SelectableList, items: []const []const u8) !void { 177 const inner_box = &self.scroll.child.box; 178 179 for (inner_box.children.values()) |*child| { 180 child.widget.deinit(); 181 } 182 inner_box.children.clearAndFree(self.allocator); 183 184 for (self.lines.items) |line| self.allocator.free(line); 185 self.lines.clearAndFree(self.allocator); 186 187 self.scroll.x = 0; 188 self.scroll.y = 0; 189 self.scroll.getFocus().child_id = null; 190 191 for (items) |item| { 192 const line = try self.allocator.dupe(u8, item); 193 { 194 errdefer self.allocator.free(line); 195 try self.lines.append(self.allocator, line); 196 } 197 198 var text_box = try wgt.TextBox(Widget).init(self.allocator, line, .{ .border_style = .hidden, .rounded_corners = true, .wrap_kind = .none }); 199 errdefer text_box.deinit(); 200 text_box.getFocus().focusable = true; 201 try inner_box.children.put(self.allocator, text_box.getFocus().id, .{ .widget = .{ .text_box = text_box }, .rect = null, .min_size = null }); 202 } 203 204 if (inner_box.children.count() > 0) { 205 self.scroll.getFocus().child_id = inner_box.children.keys()[0]; 206 } 207 } 208 209 pub fn build(self: *SelectableList, constraint: layout.Constraint, root_focus: *Focus) !void { 210 self.clearGrid(); 211 const children = &self.scroll.child.box.children; 212 for (children.keys(), children.values()) |id, *item| { 213 item.widget.text_box.options.border_style = if (self.getFocus().child_id == id) 214 (if (root_focus.grandchild_id == id) .double else .single) 215 else 216 .hidden; 217 } 218 // force a minimum row of content so an empty list still produces a 219 // non-zero grid. without this, xitui's Scroll.build copies from a 220 // zero-cell child grid and traps with OOB in release builds because 221 // NDSlice.at's bounds check is gated on runtime_safety. 222 var adjusted = constraint; 223 adjusted.min_size.height = @max(adjusted.min_size.height orelse 0, 1); 224 try self.scroll.build(adjusted, root_focus); 225 } 226 227 pub fn input(self: *SelectableList, key: inp.Key, root_focus: *Focus) !void { 228 if (self.getFocus().child_id) |child_id| { 229 const children = &self.scroll.child.box.children; 230 if (children.getIndex(child_id)) |current_index| { 231 var index = current_index; 232 233 switch (key) { 234 .arrow_up => { 235 index -|= 1; 236 }, 237 .arrow_down => { 238 if (index + 1 < children.count()) { 239 index += 1; 240 } 241 }, 242 .home => { 243 index = 0; 244 }, 245 .end => { 246 if (children.count() > 0) { 247 index = children.count() - 1; 248 } 249 }, 250 .page_up => { 251 if (self.getGrid()) |grid| { 252 const half_count = (grid.size.height / 3) / 2; 253 index -|= half_count; 254 } 255 }, 256 .page_down => { 257 if (self.getGrid()) |grid| { 258 if (children.count() > 0) { 259 const half_count = (grid.size.height / 3) / 2; 260 index = @min(index + half_count, children.count() - 1); 261 } 262 } 263 }, 264 else => {}, 265 } 266 267 if (index != current_index) { 268 try root_focus.setFocus(children.keys()[index]); 269 self.updateScroll(index); 270 } 271 } 272 } 273 } 274 275 pub fn clearGrid(self: *SelectableList) void { 276 self.scroll.clearGrid(); 277 } 278 279 pub fn getGrid(self: SelectableList) ?Grid { 280 return self.scroll.getGrid(); 281 } 282 283 pub fn getFocus(self: *SelectableList) *Focus { 284 return self.scroll.getFocus(); 285 } 286 287 pub fn getSelectedIndex(self: SelectableList) ?usize { 288 if (self.scroll.child.box.focus.child_id) |child_id| { 289 const children = &self.scroll.child.box.children; 290 return children.getIndex(child_id); 291 } else { 292 return null; 293 } 294 } 295 296 fn updateScroll(self: *SelectableList, index: usize) void { 297 const inner_box = &self.scroll.child.box; 298 if (inner_box.children.values()[index].rect) |rect| { 299 self.scroll.scrollToRect(rect); 300 } 301 } 302 }; 303 304 // horizontal two-pane view: users on the left, the selected user's 305 // repos on the right. selecting a user repopulates the repo pane. 306 const UserRepoView = struct { 307 allocator: std.mem.Allocator, 308 box: wgt.Box(Widget), 309 page: *const pg.UserRepoPage, 310 last_user_index: ?usize, 311 312 const user_list_index: usize = 0; 313 const repo_list_index: usize = 1; 314 315 pub fn init(allocator: std.mem.Allocator, page: *const pg.UserRepoPage) !UserRepoView { 316 var self = blk: { 317 var box = try wgt.Box(Widget).init(allocator, .{ .border_style = null, .rounded_corners = true, .direction = .horiz }); 318 errdefer box.deinit(); 319 320 { 321 var user_list = try SelectableList.init(allocator); 322 errdefer user_list.deinit(); 323 try box.children.put(allocator, user_list.getFocus().id, .{ .widget = .{ .selectable_list = user_list }, .rect = null, .min_size = .{ .width = 30, .height = null } }); 324 } 325 326 { 327 var repo_list = try SelectableList.init(allocator); 328 errdefer repo_list.deinit(); 329 try box.children.put(allocator, repo_list.getFocus().id, .{ .widget = .{ .selectable_list = repo_list }, .rect = null, .min_size = .{ .width = 40, .height = null } }); 330 } 331 332 break :blk UserRepoView{ 333 .allocator = allocator, 334 .box = box, 335 .page = page, 336 .last_user_index = null, 337 }; 338 }; 339 errdefer self.deinit(); 340 341 // populate the user list once at init using a temporary arena 342 // for the formatted lines (setItems dupes them). 343 var arena = std.heap.ArenaAllocator.init(allocator); 344 defer arena.deinit(); 345 const aa = arena.allocator(); 346 347 const user_lines = try aa.alloc([]const u8, page.users.len); 348 for (page.users, 0..) |uwr, i| { 349 user_lines[i] = try std.fmt.allocPrint(aa, "{s} ({s})", .{ uwr.user.name, uwr.user.display_name }); 350 } 351 const user_list = &self.box.children.values()[user_list_index].widget.selectable_list; 352 try user_list.setItems(user_lines); 353 354 self.getFocus().child_id = self.box.children.keys()[user_list_index]; 355 try self.updateRepos(); 356 357 return self; 358 } 359 360 pub fn deinit(self: *UserRepoView) void { 361 self.box.deinit(); 362 } 363 364 pub fn build(self: *UserRepoView, constraint: layout.Constraint, root_focus: *Focus) !void { 365 self.clearGrid(); 366 try self.box.build(constraint, root_focus); 367 } 368 369 pub fn input(self: *UserRepoView, key: inp.Key, root_focus: *Focus) !void { 370 if (self.getFocus().child_id) |child_id| { 371 if (self.box.children.getIndex(child_id)) |current_index| { 372 const child = &self.box.children.values()[current_index].widget; 373 374 const index = blk: { 375 switch (key) { 376 .arrow_left => { 377 if (current_index == repo_list_index) { 378 break :blk user_list_index; 379 } 380 }, 381 .arrow_right => { 382 if (current_index == user_list_index) { 383 break :blk repo_list_index; 384 } 385 }, 386 .codepoint => { 387 switch (key.codepoint) { 388 13 => { 389 if (current_index == user_list_index) { 390 break :blk repo_list_index; 391 } 392 }, 393 127, '\x1B' => { 394 if (current_index == repo_list_index) { 395 break :blk user_list_index; 396 } 397 }, 398 else => {}, 399 } 400 }, 401 else => {}, 402 } 403 try child.input(key, root_focus); 404 if (current_index == user_list_index) { 405 try self.updateRepos(); 406 } 407 break :blk current_index; 408 }; 409 410 if (index != current_index) { 411 try root_focus.setFocus(self.box.children.keys()[index]); 412 } 413 } 414 } 415 } 416 417 pub fn clearGrid(self: *UserRepoView) void { 418 self.box.clearGrid(); 419 } 420 421 pub fn getGrid(self: UserRepoView) ?Grid { 422 return self.box.getGrid(); 423 } 424 425 pub fn getFocus(self: *UserRepoView) *Focus { 426 return self.box.getFocus(); 427 } 428 429 fn updateRepos(self: *UserRepoView) !void { 430 const user_list = &self.box.children.values()[user_list_index].widget.selectable_list; 431 const repo_list = &self.box.children.values()[repo_list_index].widget.selectable_list; 432 433 const user_index = user_list.getSelectedIndex(); 434 if (user_index == self.last_user_index) return; 435 self.last_user_index = user_index; 436 437 const repos = if (user_index) |i| self.page.users[i].repos else &.{}; 438 439 var arena = std.heap.ArenaAllocator.init(self.allocator); 440 defer arena.deinit(); 441 const aa = arena.allocator(); 442 443 const repo_lines = try aa.alloc([]const u8, repos.len); 444 for (repos, 0..) |repo_event, i| { 445 repo_lines[i] = try std.fmt.allocPrint(aa, "{s} - {s}", .{ repo_event.name, repo_event.description }); 446 } 447 try repo_list.setItems(repo_lines); 448 } 449 }; 450