╭ 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 // keep the focus tree in sync with the widget tree so setFocus can 238 // descend even when the pane isn't laid out this build (e.g. narrow 239 // terminal where only the focused pane fits). build's clear+addChild 240 // would do this otherwise, but it gets skipped for unbuilt panes. 241 inner_box.getFocus().clear(); 242 inner_box.getFocus().child_id = null; 243 244 for (items) |item| { 245 const line = try self.allocator.dupe(u8, item); 246 { 247 errdefer self.allocator.free(line); 248 try self.lines.append(self.allocator, line); 249 } 250 251 var text_box = try wgt.TextBox(Widget).init(self.allocator, line, .{ .border_style = .hidden, .rounded_corners = true, .wrap_kind = .none }); 252 errdefer text_box.deinit(); 253 text_box.getFocus().focusable = true; 254 try inner_box.children.put(self.allocator, text_box.getFocus().id, .{ .widget = .{ .text_box = text_box }, .rect = null, .min_size = null }); 255 try inner_box.getFocus().addChild(text_box.getFocus(), .{ .width = 0, .height = 0 }, 0, 0); 256 } 257 258 if (inner_box.children.count() > 0) { 259 inner_box.getFocus().child_id = inner_box.children.keys()[0]; 260 } 261 } 262 263 pub fn build(self: *SelectableList, constraint: layout.Constraint, root_focus: *Focus) !void { 264 self.clearGrid(); 265 const children = &self.scroll.child.box.children; 266 for (children.keys(), children.values()) |id, *item| { 267 item.widget.text_box.options.border_style = if (self.getFocus().child_id == id) 268 (if (root_focus.grandchild_id == id) .double else .single) 269 else 270 .hidden; 271 } 272 try self.scroll.build(constraint, root_focus); 273 } 274 275 pub fn input(self: *SelectableList, key: inp.Key, root_focus: *Focus) !void { 276 if (self.getFocus().child_id) |child_id| { 277 const children = &self.scroll.child.box.children; 278 if (children.getIndex(child_id)) |current_index| { 279 var index = current_index; 280 281 switch (key) { 282 .arrow_up => { 283 index -|= 1; 284 }, 285 .arrow_down => { 286 if (index + 1 < children.count()) { 287 index += 1; 288 } 289 }, 290 .home => { 291 index = 0; 292 }, 293 .end => { 294 if (children.count() > 0) { 295 index = children.count() - 1; 296 } 297 }, 298 .page_up => { 299 if (self.getGrid()) |grid| { 300 const half_count = (grid.size.height / 3) / 2; 301 index -|= half_count; 302 } 303 }, 304 .page_down => { 305 if (self.getGrid()) |grid| { 306 if (children.count() > 0) { 307 const half_count = (grid.size.height / 3) / 2; 308 index = @min(index + half_count, children.count() - 1); 309 } 310 } 311 }, 312 .mouse => |mouse| switch (mouse.action) { 313 .scroll => |dir| switch (dir) { 314 .up => index -|= 1, 315 .down => if (index + 1 < children.count()) { 316 index += 1; 317 }, 318 }, 319 else => {}, 320 }, 321 else => {}, 322 } 323 324 if (index != current_index) { 325 try root_focus.setFocus(children.keys()[index]); 326 self.updateScroll(index); 327 } 328 } 329 } 330 } 331 332 pub fn clearGrid(self: *SelectableList) void { 333 self.scroll.clearGrid(); 334 } 335 336 pub fn getGrid(self: SelectableList) ?Grid { 337 return self.scroll.getGrid(); 338 } 339 340 pub fn getFocus(self: *SelectableList) *Focus { 341 return self.scroll.getFocus(); 342 } 343 344 pub fn getSelectedIndex(self: SelectableList) ?usize { 345 if (self.scroll.child.box.focus.child_id) |child_id| { 346 const children = &self.scroll.child.box.children; 347 return children.getIndex(child_id); 348 } else { 349 return null; 350 } 351 } 352 353 fn updateScroll(self: *SelectableList, index: usize) void { 354 const inner_box = &self.scroll.child.box; 355 if (inner_box.children.values()[index].rect) |rect| { 356 self.scroll.scrollToRect(rect); 357 } 358 } 359 }; 360 361 // horizontal two-pane view: users on the left, the selected user's 362 // repos on the right. selecting a user repopulates the repo pane. 363 const UserRepoView = struct { 364 allocator: std.mem.Allocator, 365 box: wgt.Box(Widget), 366 page: *const pg.UserRepoPage, 367 last_user_index: ?usize, 368 369 const user_list_index: usize = 0; 370 const repo_list_index: usize = 1; 371 372 pub fn init(allocator: std.mem.Allocator, page: *const pg.UserRepoPage) !UserRepoView { 373 var self = blk: { 374 var box = try wgt.Box(Widget).init(allocator, .{ .border_style = null, .rounded_corners = true, .direction = .horiz }); 375 errdefer box.deinit(); 376 377 { 378 var user_list = try SelectableList.init(allocator); 379 errdefer user_list.deinit(); 380 try box.children.put(allocator, user_list.getFocus().id, .{ .widget = .{ .selectable_list = user_list }, .rect = null, .min_size = .{ .width = 30, .height = null } }); 381 } 382 383 { 384 var repo_list = try SelectableList.init(allocator); 385 errdefer repo_list.deinit(); 386 try box.children.put(allocator, repo_list.getFocus().id, .{ .widget = .{ .selectable_list = repo_list }, .rect = null, .min_size = .{ .width = 40, .height = null } }); 387 } 388 389 break :blk UserRepoView{ 390 .allocator = allocator, 391 .box = box, 392 .page = page, 393 .last_user_index = null, 394 }; 395 }; 396 errdefer self.deinit(); 397 398 // populate the user list once at init using a temporary arena 399 // for the formatted lines (setItems dupes them). 400 var arena = std.heap.ArenaAllocator.init(allocator); 401 defer arena.deinit(); 402 const aa = arena.allocator(); 403 404 const user_lines = try aa.alloc([]const u8, page.users.len); 405 for (page.users, 0..) |uwr, i| { 406 user_lines[i] = try std.fmt.allocPrint(aa, "{s} ({s})", .{ uwr.user.name, uwr.user.display_name }); 407 } 408 const user_list = &self.box.children.values()[user_list_index].widget.selectable_list; 409 try user_list.setItems(user_lines); 410 411 self.getFocus().child_id = self.box.children.keys()[user_list_index]; 412 try self.updateRepos(); 413 414 return self; 415 } 416 417 pub fn deinit(self: *UserRepoView) void { 418 self.box.deinit(); 419 } 420 421 pub fn build(self: *UserRepoView, constraint: layout.Constraint, root_focus: *Focus) !void { 422 self.clearGrid(); 423 // refresh the repo list here (rather than in input) so it stays in 424 // sync regardless of how the user selection changed — including 425 // clicks that go straight through setFocus without touching input. 426 // updateRepos is a no-op when the selected user hasn't changed. 427 try self.updateRepos(); 428 try self.box.build(constraint, root_focus); 429 } 430 431 pub fn input(self: *UserRepoView, key: inp.Key, root_focus: *Focus) !void { 432 if (self.getFocus().child_id) |child_id| { 433 if (self.box.children.getIndex(child_id)) |current_index| { 434 const child = &self.box.children.values()[current_index].widget; 435 436 const index = blk: { 437 switch (key) { 438 .arrow_left => { 439 if (current_index == repo_list_index) { 440 break :blk user_list_index; 441 } 442 }, 443 .arrow_right => { 444 if (current_index == user_list_index) { 445 break :blk repo_list_index; 446 } 447 }, 448 .codepoint => { 449 switch (key.codepoint) { 450 13 => { 451 if (current_index == user_list_index) { 452 break :blk repo_list_index; 453 } 454 }, 455 127, '\x1B' => { 456 if (current_index == repo_list_index) { 457 break :blk user_list_index; 458 } 459 }, 460 else => {}, 461 } 462 }, 463 else => {}, 464 } 465 try child.input(key, root_focus); 466 break :blk current_index; 467 }; 468 469 if (index != current_index) { 470 try root_focus.setFocus(self.box.children.keys()[index]); 471 } 472 } 473 } 474 } 475 476 pub fn clearGrid(self: *UserRepoView) void { 477 self.box.clearGrid(); 478 } 479 480 pub fn getGrid(self: UserRepoView) ?Grid { 481 return self.box.getGrid(); 482 } 483 484 pub fn getFocus(self: *UserRepoView) *Focus { 485 return self.box.getFocus(); 486 } 487 488 fn updateRepos(self: *UserRepoView) !void { 489 const user_list = &self.box.children.values()[user_list_index].widget.selectable_list; 490 const repo_list = &self.box.children.values()[repo_list_index].widget.selectable_list; 491 492 const user_index = user_list.getSelectedIndex(); 493 if (user_index == self.last_user_index) return; 494 self.last_user_index = user_index; 495 496 const repos = if (user_index) |i| self.page.users[i].repos else &.{}; 497 498 var arena = std.heap.ArenaAllocator.init(self.allocator); 499 defer arena.deinit(); 500 const aa = arena.allocator(); 501 502 const repo_lines = try aa.alloc([]const u8, repos.len); 503 for (repos, 0..) |repo_event, i| { 504 repo_lines[i] = try std.fmt.allocPrint(aa, "{s} - {s}", .{ repo_event.name, repo_event.description }); 505 } 506 try repo_list.setItems(repo_lines); 507 } 508 }; 509