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