╭ ui.zig ───────────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
1 const std = @import("std"); 2 const xit = @import("xit"); 3 const xitui = xit.xitui; 4 const term = xitui.terminal; 5 const wgt = xitui.widget; 6 const layout = xitui.layout; 7 const inp = xitui.input; 8 const Grid = xitui.grid.Grid; 9 const Focus = xitui.focus.Focus; 10 const evt = @import("./event.zig"); 11 const pg = @import("./page.zig"); 12 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 = null }, 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 pane_has_focus = root_focus.grandchild_id == self.getFocus().id; 212 const children = &self.scroll.child.box.children; 213 for (children.keys(), children.values()) |id, *item| { 214 item.widget.text_box.options.border_style = if (self.getFocus().child_id == id) 215 // pane_has_focus will be true if the widgets weren't yet in 216 // the focus tree, and so setFocus stopped at the pane itself. 217 // in this case, it should be treated as if it's selected. 218 (if (root_focus.grandchild_id == id or pane_has_focus) .double else .single) 219 else 220 .hidden; 221 } 222 try self.scroll.build(constraint, root_focus); 223 } 224 225 pub fn input(self: *SelectableList, key: inp.Key, root_focus: *Focus) !void { 226 if (self.getFocus().child_id) |child_id| { 227 const children = &self.scroll.child.box.children; 228 if (children.getIndex(child_id)) |current_index| { 229 var index = current_index; 230 231 switch (key) { 232 .arrow_up => { 233 index -|= 1; 234 }, 235 .arrow_down => { 236 if (index + 1 < children.count()) { 237 index += 1; 238 } 239 }, 240 .home => { 241 index = 0; 242 }, 243 .end => { 244 if (children.count() > 0) { 245 index = children.count() - 1; 246 } 247 }, 248 .page_up => { 249 if (self.getGrid()) |grid| { 250 const half_count = (grid.size.height / 3) / 2; 251 index -|= half_count; 252 } 253 }, 254 .page_down => { 255 if (self.getGrid()) |grid| { 256 if (children.count() > 0) { 257 const half_count = (grid.size.height / 3) / 2; 258 index = @min(index + half_count, children.count() - 1); 259 } 260 } 261 }, 262 else => {}, 263 } 264 265 if (index != current_index) { 266 try root_focus.setFocus(children.keys()[index]); 267 self.updateScroll(index); 268 } 269 } 270 } 271 } 272 273 pub fn clearGrid(self: *SelectableList) void { 274 self.scroll.clearGrid(); 275 } 276 277 pub fn getGrid(self: SelectableList) ?Grid { 278 return self.scroll.getGrid(); 279 } 280 281 pub fn getFocus(self: *SelectableList) *Focus { 282 return self.scroll.getFocus(); 283 } 284 285 pub fn getSelectedIndex(self: SelectableList) ?usize { 286 if (self.scroll.child.box.focus.child_id) |child_id| { 287 const children = &self.scroll.child.box.children; 288 return children.getIndex(child_id); 289 } else { 290 return null; 291 } 292 } 293 294 fn updateScroll(self: *SelectableList, index: usize) void { 295 const inner_box = &self.scroll.child.box; 296 if (inner_box.children.values()[index].rect) |rect| { 297 self.scroll.scrollToRect(rect); 298 } 299 } 300 }; 301 302 // horizontal two-pane view: users on the left, the selected user's 303 // repos on the right. selecting a user repopulates the repo pane. 304 const UserRepoView = struct { 305 allocator: std.mem.Allocator, 306 box: wgt.Box(Widget), 307 page: *const pg.UserRepoPage, 308 last_user_index: ?usize, 309 310 const user_list_index: usize = 0; 311 const repo_list_index: usize = 1; 312 313 pub fn init(allocator: std.mem.Allocator, page: *const pg.UserRepoPage) !UserRepoView { 314 var self = blk: { 315 var box = try wgt.Box(Widget).init(allocator, .{ .border_style = null, .rounded_corners = true, .direction = .horiz }); 316 errdefer box.deinit(); 317 318 { 319 var user_list = try SelectableList.init(allocator); 320 errdefer user_list.deinit(); 321 try box.children.put(allocator, user_list.getFocus().id, .{ .widget = .{ .selectable_list = user_list }, .rect = null, .min_size = .{ .width = 30, .height = null } }); 322 } 323 324 { 325 var repo_list = try SelectableList.init(allocator); 326 errdefer repo_list.deinit(); 327 try box.children.put(allocator, repo_list.getFocus().id, .{ .widget = .{ .selectable_list = repo_list }, .rect = null, .min_size = .{ .width = 40, .height = null } }); 328 } 329 330 break :blk UserRepoView{ 331 .allocator = allocator, 332 .box = box, 333 .page = page, 334 .last_user_index = null, 335 }; 336 }; 337 errdefer self.deinit(); 338 339 // populate the user list once at init using a temporary arena 340 // for the formatted lines (setItems dupes them). 341 var arena = std.heap.ArenaAllocator.init(allocator); 342 defer arena.deinit(); 343 const aa = arena.allocator(); 344 345 const user_lines = try aa.alloc([]const u8, page.users.len); 346 for (page.users, 0..) |uwr, i| { 347 user_lines[i] = try std.fmt.allocPrint(aa, "{s} ({s})", .{ uwr.user.name, uwr.user.display_name }); 348 } 349 const user_list = &self.box.children.values()[user_list_index].widget.selectable_list; 350 try user_list.setItems(user_lines); 351 352 self.getFocus().child_id = self.box.children.keys()[user_list_index]; 353 try self.updateRepos(); 354 355 return self; 356 } 357 358 pub fn deinit(self: *UserRepoView) void { 359 self.box.deinit(); 360 } 361 362 pub fn build(self: *UserRepoView, constraint: layout.Constraint, root_focus: *Focus) !void { 363 self.clearGrid(); 364 try self.box.build(constraint, root_focus); 365 } 366 367 pub fn input(self: *UserRepoView, key: inp.Key, root_focus: *Focus) !void { 368 if (self.getFocus().child_id) |child_id| { 369 if (self.box.children.getIndex(child_id)) |current_index| { 370 const child = &self.box.children.values()[current_index].widget; 371 372 const index = blk: { 373 switch (key) { 374 .arrow_left => { 375 if (current_index == repo_list_index) { 376 break :blk user_list_index; 377 } 378 }, 379 .arrow_right => { 380 if (current_index == user_list_index) { 381 break :blk repo_list_index; 382 } 383 }, 384 .codepoint => { 385 switch (key.codepoint) { 386 13 => { 387 if (current_index == user_list_index) { 388 break :blk repo_list_index; 389 } 390 }, 391 127, '\x1B' => { 392 if (current_index == repo_list_index) { 393 break :blk user_list_index; 394 } 395 }, 396 else => {}, 397 } 398 }, 399 else => {}, 400 } 401 try child.input(key, root_focus); 402 if (current_index == user_list_index) { 403 try self.updateRepos(); 404 } 405 break :blk current_index; 406 }; 407 408 if (index != current_index) { 409 try root_focus.setFocus(self.box.children.keys()[index]); 410 } 411 } 412 } 413 } 414 415 pub fn clearGrid(self: *UserRepoView) void { 416 self.box.clearGrid(); 417 } 418 419 pub fn getGrid(self: UserRepoView) ?Grid { 420 return self.box.getGrid(); 421 } 422 423 pub fn getFocus(self: *UserRepoView) *Focus { 424 return self.box.getFocus(); 425 } 426 427 fn updateRepos(self: *UserRepoView) !void { 428 const user_list = &self.box.children.values()[user_list_index].widget.selectable_list; 429 const repo_list = &self.box.children.values()[repo_list_index].widget.selectable_list; 430 431 const user_index = user_list.getSelectedIndex(); 432 if (user_index == self.last_user_index) return; 433 self.last_user_index = user_index; 434 435 const repos = if (user_index) |i| self.page.users[i].repos else &.{}; 436 437 var arena = std.heap.ArenaAllocator.init(self.allocator); 438 defer arena.deinit(); 439 const aa = arena.allocator(); 440 441 const repo_lines = try aa.alloc([]const u8, repos.len); 442 for (repos, 0..) |repo_event, i| { 443 repo_lines[i] = try std.fmt.allocPrint(aa, "{s} - {s}", .{ repo_event.name, repo_event.description }); 444 } 445 try repo_list.setItems(repo_lines); 446 } 447 }; 448