╭ 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 .mouse => |mouse| { 42 if (mouse.action == .press and mouse.action.press == .left) { 43 const root_focus = root.getFocus(); 44 var iter = root_focus.children.iterator(); 45 while (iter.next()) |entry| { 46 const child = entry.value_ptr.*; 47 if (!child.focus.focusable) continue; 48 const r = child.rect; 49 if (mouse.x >= r.x and mouse.y >= r.y and 50 mouse.x < r.x + r.size.width and mouse.y < r.y + r.size.height) 51 { 52 try root_focus.setFocus(entry.key_ptr.*); 53 break; 54 } 55 } 56 } else { 57 try root.input(key, root.getFocus()); 58 } 59 }, 60 else => try root.input(key, root.getFocus()), 61 } 62 blocking = false; 63 } 64 65 try root.build(.{ 66 .min_size = .{ .width = null, .height = null }, 67 .max_size = .{ .width = last_size.width, .height = last_size.height }, 68 }, root.getFocus()); 69 } 70 } 71 72 pub fn initRoot(allocator: std.mem.Allocator, page: *const pg.Page) !Widget { 73 var root: Widget = switch (page.*) { 74 .user_repo => |*p| .{ .user_repo_view = try UserRepoView.init(allocator, p) }, 75 }; 76 errdefer root.deinit(); 77 78 try root.build(.{ 79 .min_size = .{ .width = null, .height = null }, 80 .max_size = .{ .width = 80, .height = null }, 81 }, root.getFocus()); 82 if (root.getFocus().child_id) |child_id| { 83 try root.getFocus().setFocus(child_id); 84 } 85 86 return root; 87 } 88 89 pub fn generateHtml(allocator: std.mem.Allocator, root: *Widget) ![]const u8 { 90 const grid = root.getGrid() orelse return error.MissingGrid; 91 92 var out: std.ArrayList(u8) = .empty; 93 errdefer out.deinit(allocator); 94 95 for (0..grid.size.height) |y| { 96 for (0..grid.size.width) |x| { 97 const rune = grid.cells.items[try grid.cells.at(.{ y, x })].rune orelse " "; 98 try appendEscapedHtml(allocator, &out, rune); 99 } 100 try out.append(allocator, '\n'); 101 } 102 103 return try out.toOwnedSlice(allocator); 104 } 105 106 fn appendEscapedHtml(allocator: std.mem.Allocator, out: *std.ArrayList(u8), input: []const u8) !void { 107 for (input) |ch| { 108 switch (ch) { 109 '&' => try out.appendSlice(allocator, "&amp;"), 110 '<' => try out.appendSlice(allocator, "&lt;"), 111 '>' => try out.appendSlice(allocator, "&gt;"), 112 '"' => try out.appendSlice(allocator, "&quot;"), 113 '\'' => try out.appendSlice(allocator, "&#39;"), 114 else => try out.append(allocator, ch), 115 } 116 } 117 } 118 119 pub const Widget = union(enum) { 120 text: wgt.Text(Widget), 121 box: wgt.Box(Widget), 122 text_box: wgt.TextBox(Widget), 123 scroll: wgt.Scroll(Widget), 124 selectable_list: SelectableList, 125 user_repo_view: UserRepoView, 126 127 pub fn deinit(self: *Widget) void { 128 switch (self.*) { 129 inline else => |*case| case.deinit(), 130 } 131 } 132 133 pub fn build(self: *Widget, constraint: layout.Constraint, root_focus: *Focus) anyerror!void { 134 switch (self.*) { 135 inline else => |*case| try case.build(constraint, root_focus), 136 } 137 } 138 139 pub fn input(self: *Widget, key: inp.Key, root_focus: *Focus) anyerror!void { 140 switch (self.*) { 141 inline else => |*case| try case.input(key, root_focus), 142 } 143 } 144 145 pub fn clearGrid(self: *Widget) void { 146 switch (self.*) { 147 inline else => |*case| case.clearGrid(), 148 } 149 } 150 151 pub fn getGrid(self: Widget) ?Grid { 152 switch (self) { 153 inline else => |*case| return case.getGrid(), 154 } 155 } 156 157 pub fn getFocus(self: *Widget) *Focus { 158 switch (self.*) { 159 inline else => |*case| return case.getFocus(), 160 } 161 } 162 }; 163 164 // a scrollable list of selectable single-line items. content is replaced 165 // via setItems, which dupes the strings into the widget's own allocation. 166 const SelectableList = struct { 167 allocator: std.mem.Allocator, 168 scroll: wgt.Scroll(Widget), 169 lines: std.ArrayList([]const u8), 170 171 pub fn init(allocator: std.mem.Allocator) !SelectableList { 172 var self = blk: { 173 var inner_box = try wgt.Box(Widget).init(allocator, .{ .border_style = null, .rounded_corners = true, .direction = .vert }); 174 errdefer inner_box.deinit(); 175 176 var scroll = try wgt.Scroll(Widget).init(allocator, .{ .box = inner_box }, .vert); 177 errdefer scroll.deinit(); 178 179 break :blk SelectableList{ 180 .allocator = allocator, 181 .scroll = scroll, 182 .lines = .empty, 183 }; 184 }; 185 errdefer self.deinit(); 186 return self; 187 } 188 189 pub fn deinit(self: *SelectableList) void { 190 self.scroll.deinit(); 191 for (self.lines.items) |line| self.allocator.free(line); 192 self.lines.deinit(self.allocator); 193 } 194 195 pub fn setItems(self: *SelectableList, items: []const []const u8) !void { 196 const inner_box = &self.scroll.child.box; 197 198 for (inner_box.children.values()) |*child| { 199 child.widget.deinit(); 200 } 201 inner_box.children.clearAndFree(self.allocator); 202 203 for (self.lines.items) |line| self.allocator.free(line); 204 self.lines.clearAndFree(self.allocator); 205 206 self.scroll.x = 0; 207 self.scroll.y = 0; 208 self.scroll.getFocus().child_id = null; 209 210 for (items) |item| { 211 const line = try self.allocator.dupe(u8, item); 212 { 213 errdefer self.allocator.free(line); 214 try self.lines.append(self.allocator, line); 215 } 216 217 var text_box = try wgt.TextBox(Widget).init(self.allocator, line, .{ .border_style = .hidden, .rounded_corners = true, .wrap_kind = .none }); 218 errdefer text_box.deinit(); 219 text_box.getFocus().focusable = true; 220 try inner_box.children.put(self.allocator, text_box.getFocus().id, .{ .widget = .{ .text_box = text_box }, .rect = null, .min_size = null }); 221 } 222 223 if (inner_box.children.count() > 0) { 224 self.scroll.getFocus().child_id = inner_box.children.keys()[0]; 225 } 226 } 227 228 pub fn build(self: *SelectableList, constraint: layout.Constraint, root_focus: *Focus) !void { 229 self.clearGrid(); 230 const pane_has_focus = root_focus.grandchild_id == self.getFocus().id; 231 const children = &self.scroll.child.box.children; 232 for (children.keys(), children.values()) |id, *item| { 233 item.widget.text_box.options.border_style = if (self.getFocus().child_id == id) 234 // pane_has_focus will be true if the widgets weren't yet in 235 // the focus tree, and so setFocus stopped at the pane itself. 236 // in this case, it should be treated as if it's selected. 237 (if (root_focus.grandchild_id == id or pane_has_focus) .double else .single) 238 else 239 .hidden; 240 } 241 try self.scroll.build(constraint, root_focus); 242 } 243 244 pub fn input(self: *SelectableList, key: inp.Key, root_focus: *Focus) !void { 245 if (self.getFocus().child_id) |child_id| { 246 const children = &self.scroll.child.box.children; 247 if (children.getIndex(child_id)) |current_index| { 248 var index = current_index; 249 250 switch (key) { 251 .arrow_up => { 252 index -|= 1; 253 }, 254 .arrow_down => { 255 if (index + 1 < children.count()) { 256 index += 1; 257 } 258 }, 259 .home => { 260 index = 0; 261 }, 262 .end => { 263 if (children.count() > 0) { 264 index = children.count() - 1; 265 } 266 }, 267 .page_up => { 268 if (self.getGrid()) |grid| { 269 const half_count = (grid.size.height / 3) / 2; 270 index -|= half_count; 271 } 272 }, 273 .page_down => { 274 if (self.getGrid()) |grid| { 275 if (children.count() > 0) { 276 const half_count = (grid.size.height / 3) / 2; 277 index = @min(index + half_count, children.count() - 1); 278 } 279 } 280 }, 281 .mouse => |mouse| switch (mouse.action) { 282 .scroll => |dir| switch (dir) { 283 .up => index -|= 1, 284 .down => if (index + 1 < children.count()) { 285 index += 1; 286 }, 287 }, 288 else => {}, 289 }, 290 else => {}, 291 } 292 293 if (index != current_index) { 294 try root_focus.setFocus(children.keys()[index]); 295 self.updateScroll(index); 296 } 297 } 298 } 299 } 300 301 pub fn clearGrid(self: *SelectableList) void { 302 self.scroll.clearGrid(); 303 } 304 305 pub fn getGrid(self: SelectableList) ?Grid { 306 return self.scroll.getGrid(); 307 } 308 309 pub fn getFocus(self: *SelectableList) *Focus { 310 return self.scroll.getFocus(); 311 } 312 313 pub fn getSelectedIndex(self: SelectableList) ?usize { 314 if (self.scroll.child.box.focus.child_id) |child_id| { 315 const children = &self.scroll.child.box.children; 316 return children.getIndex(child_id); 317 } else { 318 return null; 319 } 320 } 321 322 fn updateScroll(self: *SelectableList, index: usize) void { 323 const inner_box = &self.scroll.child.box; 324 if (inner_box.children.values()[index].rect) |rect| { 325 self.scroll.scrollToRect(rect); 326 } 327 } 328 }; 329 330 // horizontal two-pane view: users on the left, the selected user's 331 // repos on the right. selecting a user repopulates the repo pane. 332 const UserRepoView = struct { 333 allocator: std.mem.Allocator, 334 box: wgt.Box(Widget), 335 page: *const pg.UserRepoPage, 336 last_user_index: ?usize, 337 338 const user_list_index: usize = 0; 339 const repo_list_index: usize = 1; 340 341 pub fn init(allocator: std.mem.Allocator, page: *const pg.UserRepoPage) !UserRepoView { 342 var self = blk: { 343 var box = try wgt.Box(Widget).init(allocator, .{ .border_style = null, .rounded_corners = true, .direction = .horiz }); 344 errdefer box.deinit(); 345 346 { 347 var user_list = try SelectableList.init(allocator); 348 errdefer user_list.deinit(); 349 try box.children.put(allocator, user_list.getFocus().id, .{ .widget = .{ .selectable_list = user_list }, .rect = null, .min_size = .{ .width = 30, .height = null } }); 350 } 351 352 { 353 var repo_list = try SelectableList.init(allocator); 354 errdefer repo_list.deinit(); 355 try box.children.put(allocator, repo_list.getFocus().id, .{ .widget = .{ .selectable_list = repo_list }, .rect = null, .min_size = .{ .width = 40, .height = null } }); 356 } 357 358 break :blk UserRepoView{ 359 .allocator = allocator, 360 .box = box, 361 .page = page, 362 .last_user_index = null, 363 }; 364 }; 365 errdefer self.deinit(); 366 367 // populate the user list once at init using a temporary arena 368 // for the formatted lines (setItems dupes them). 369 var arena = std.heap.ArenaAllocator.init(allocator); 370 defer arena.deinit(); 371 const aa = arena.allocator(); 372 373 const user_lines = try aa.alloc([]const u8, page.users.len); 374 for (page.users, 0..) |uwr, i| { 375 user_lines[i] = try std.fmt.allocPrint(aa, "{s} ({s})", .{ uwr.user.name, uwr.user.display_name }); 376 } 377 const user_list = &self.box.children.values()[user_list_index].widget.selectable_list; 378 try user_list.setItems(user_lines); 379 380 self.getFocus().child_id = self.box.children.keys()[user_list_index]; 381 try self.updateRepos(); 382 383 return self; 384 } 385 386 pub fn deinit(self: *UserRepoView) void { 387 self.box.deinit(); 388 } 389 390 pub fn build(self: *UserRepoView, constraint: layout.Constraint, root_focus: *Focus) !void { 391 self.clearGrid(); 392 try self.box.build(constraint, root_focus); 393 } 394 395 pub fn input(self: *UserRepoView, key: inp.Key, root_focus: *Focus) !void { 396 if (self.getFocus().child_id) |child_id| { 397 if (self.box.children.getIndex(child_id)) |current_index| { 398 const child = &self.box.children.values()[current_index].widget; 399 400 const index = blk: { 401 switch (key) { 402 .arrow_left => { 403 if (current_index == repo_list_index) { 404 break :blk user_list_index; 405 } 406 }, 407 .arrow_right => { 408 if (current_index == user_list_index) { 409 break :blk repo_list_index; 410 } 411 }, 412 .codepoint => { 413 switch (key.codepoint) { 414 13 => { 415 if (current_index == user_list_index) { 416 break :blk repo_list_index; 417 } 418 }, 419 127, '\x1B' => { 420 if (current_index == repo_list_index) { 421 break :blk user_list_index; 422 } 423 }, 424 else => {}, 425 } 426 }, 427 else => {}, 428 } 429 try child.input(key, root_focus); 430 if (current_index == user_list_index) { 431 try self.updateRepos(); 432 } 433 break :blk current_index; 434 }; 435 436 if (index != current_index) { 437 try root_focus.setFocus(self.box.children.keys()[index]); 438 } 439 } 440 } 441 } 442 443 pub fn clearGrid(self: *UserRepoView) void { 444 self.box.clearGrid(); 445 } 446 447 pub fn getGrid(self: UserRepoView) ?Grid { 448 return self.box.getGrid(); 449 } 450 451 pub fn getFocus(self: *UserRepoView) *Focus { 452 return self.box.getFocus(); 453 } 454 455 fn updateRepos(self: *UserRepoView) !void { 456 const user_list = &self.box.children.values()[user_list_index].widget.selectable_list; 457 const repo_list = &self.box.children.values()[repo_list_index].widget.selectable_list; 458 459 const user_index = user_list.getSelectedIndex(); 460 if (user_index == self.last_user_index) return; 461 self.last_user_index = user_index; 462 463 const repos = if (user_index) |i| self.page.users[i].repos else &.{}; 464 465 var arena = std.heap.ArenaAllocator.init(self.allocator); 466 defer arena.deinit(); 467 const aa = arena.allocator(); 468 469 const repo_lines = try aa.alloc([]const u8, repos.len); 470 for (repos, 0..) |repo_event, i| { 471 repo_lines[i] = try std.fmt.allocPrint(aa, "{s} - {s}", .{ repo_event.name, repo_event.description }); 472 } 473 try repo_list.setItems(repo_lines); 474 } 475 }; 476