╭ ui.zig ───────────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
1 const std = @import("std"); 2 const xit = @import("xit"); 3 const rp = xit.repo; 4 const xitui = xit.xitui; 5 const term = xitui.terminal; 6 const wgt = xitui.widget; 7 const layout = xitui.layout; 8 const inp = xitui.input; 9 const Grid = xitui.grid.Grid; 10 const Focus = xitui.focus.Focus; 11 const evt = @import("./event.zig"); 12 13 pub const canonical_repo_opts: rp.RepoOpts(.xit) = .{}; 14 pub const DB = rp.Repo(.xit, canonical_repo_opts).DB; 15 16 pub const Home = @import("./ui/Home.zig"); 17 18 pub const Page = union(enum) { 19 home: Home, 20 }; 21 22 // per-connection mutable state. each SSH session / web session / local TUI 23 // run gets its own 24 pub const Session = struct { 25 user_id: ?[]const u8 = null, 26 arena: ?*std.heap.ArenaAllocator = null, 27 haxy_moment: ?DB.HashMap(.read_only) = null, 28 }; 29 30 pub fn run(io: std.Io, allocator: std.mem.Allocator, page: *const Page, session: *Session) !void { 31 var root = try initRoot(allocator, page, session); 32 defer root.deinit(allocator); 33 34 var terminal = try term.Terminal.init(io, allocator); 35 defer terminal.deinit(io); 36 37 var last_size = layout.Size{ .width = 0, .height = 0 }; 38 var last_grid = try Grid.init(allocator, last_size); 39 defer last_grid.deinit(); 40 41 while (!terminal.shouldQuit()) { 42 const grid_changed = try terminal.render(&root, &last_grid, &last_size); 43 44 // process any inputs. 45 // 46 // if the grid didn't change, then first do a blocking 47 // read, so the thread will sleep until further input. 48 // after that, all remaining reads are non-blocking so 49 // we can process the rest of the queued inputs. 50 // 51 // if the grid *did* change, then only do non-blocking 52 // reads. we do not want to sleep the thread because 53 // there may be an animation that requires more looping. 54 var blocking = !grid_changed; 55 while (try terminal.readKey(io, blocking)) |key| { 56 try inputKey(allocator, &root, key, &terminal); 57 blocking = false; 58 } 59 60 try root.build(allocator, .{ 61 .min_size = .{ .width = null, .height = null }, 62 .max_size = .{ .width = last_size.width, .height = last_size.height }, 63 }, root.getFocus()); 64 } 65 } 66 67 pub fn inputKey(allocator: std.mem.Allocator, root: *Widget, key: inp.Key, terminal: anytype) !void { 68 switch (key) { 69 .codepoint => |cp| if (cp == 'q') terminal.requestQuit() else try root.input(allocator, key, root.getFocus()), 70 .mouse => |mouse| { 71 if (mouse.action == .press and mouse.action.press == .left) { 72 const root_focus = root.getFocus(); 73 var iter = root_focus.children.iterator(); 74 while (iter.next()) |entry| { 75 const child = entry.value_ptr.*; 76 if (!child.focus.focusable) continue; 77 const r = child.rect; 78 if (mouse.x >= r.x and mouse.y >= r.y and 79 mouse.x < r.x + r.size.width and mouse.y < r.y + r.size.height) 80 { 81 try root_focus.setFocus(entry.key_ptr.*); 82 break; 83 } 84 } 85 // forward the press into the widget tree so buttons (and any 86 // future click-aware widgets) can react. widgets that don't 87 // care about presses ignore it. 88 try root.input(allocator, key, root.getFocus()); 89 } else { 90 try root.input(allocator, key, root.getFocus()); 91 } 92 }, 93 else => try root.input(allocator, key, root.getFocus()), 94 } 95 } 96 97 pub fn initRoot(allocator: std.mem.Allocator, page: *const Page, session: *Session) !Widget { 98 var root: Widget = switch (page.*) { 99 .home => |*p| .{ .home = try Home.View.init(allocator, p, session) }, 100 }; 101 errdefer root.deinit(allocator); 102 103 try root.build(allocator, .{ 104 .min_size = .{ .width = null, .height = null }, 105 .max_size = .{ .width = 80, .height = null }, 106 }, root.getFocus()); 107 if (root.getFocus().child_id) |child_id| { 108 try root.getFocus().setFocus(child_id); 109 } 110 111 return root; 112 } 113 114 pub const Widget = union(enum) { 115 text: wgt.Text(Widget), 116 box: wgt.Box(Widget), 117 text_box: wgt.TextBox(Widget), 118 text_input: wgt.TextInput(Widget), 119 scroll: wgt.Scroll(Widget), 120 stack: wgt.Stack(Widget), 121 selectable_list: SelectableList, 122 spacer: Spacer, 123 center: Center, 124 home: Home.View, 125 home_header: Home.Header.View, 126 home_users: Home.Users.View, 127 home_repos: Home.Repos.View, 128 home_auth_tab: Home.Header.AuthTab.View, 129 home_auth: Home.Auth.View, 130 131 pub fn deinit(self: *Widget, allocator: std.mem.Allocator) void { 132 switch (self.*) { 133 inline else => |*case| case.deinit(allocator), 134 } 135 } 136 137 pub fn build(self: *Widget, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) anyerror!void { 138 switch (self.*) { 139 inline else => |*case| try case.build(allocator, constraint, root_focus), 140 } 141 } 142 143 pub fn input(self: *Widget, allocator: std.mem.Allocator, key: inp.Key, root_focus: *Focus) anyerror!void { 144 switch (self.*) { 145 inline else => |*case| try case.input(allocator, key, root_focus), 146 } 147 } 148 149 pub fn clearGrid(self: *Widget) void { 150 switch (self.*) { 151 inline else => |*case| case.clearGrid(), 152 } 153 } 154 155 pub fn getGrid(self: Widget) ?Grid { 156 switch (self) { 157 inline else => |*case| return case.getGrid(), 158 } 159 } 160 161 pub fn getFocus(self: *Widget) *Focus { 162 switch (self.*) { 163 inline else => |*case| return case.getFocus(), 164 } 165 } 166 }; 167 168 // a scrollable list of selectable single-line items. content is replaced 169 // via setItems, which dupes the strings into the widget's own allocation. 170 pub const SelectableList = struct { 171 scroll: wgt.Scroll(Widget), 172 lines: std.ArrayList([]const u8), 173 174 pub fn init(allocator: std.mem.Allocator) !SelectableList { 175 var self = blk: { 176 var inner_box = try wgt.Box(Widget).init(allocator, .{ .border_style = null, .rounded_corners = true, .direction = .vert }); 177 errdefer inner_box.deinit(allocator); 178 179 var scroll = try wgt.Scroll(Widget).init(allocator, .{ .box = inner_box }, .vert); 180 errdefer scroll.deinit(allocator); 181 182 break :blk SelectableList{ 183 .scroll = scroll, 184 .lines = .empty, 185 }; 186 }; 187 errdefer self.deinit(allocator); 188 return self; 189 } 190 191 pub fn deinit(self: *SelectableList, allocator: std.mem.Allocator) void { 192 self.scroll.deinit(allocator); 193 for (self.lines.items) |line| allocator.free(line); 194 self.lines.deinit(allocator); 195 } 196 197 pub fn setItems(self: *SelectableList, allocator: std.mem.Allocator, items: []const []const u8) !void { 198 const inner_box = &self.scroll.child.box; 199 200 for (inner_box.children.values()) |*child| { 201 child.widget.deinit(allocator); 202 } 203 inner_box.children.clearAndFree(allocator); 204 205 for (self.lines.items) |line| allocator.free(line); 206 self.lines.clearAndFree(allocator); 207 208 self.scroll.x = 0; 209 self.scroll.y = 0; 210 // keep the focus tree in sync with the widget tree so setFocus can 211 // descend even when the pane isn't laid out this build (e.g. narrow 212 // terminal where only the focused pane fits). build's clear+addChild 213 // would do this otherwise, but it gets skipped for unbuilt panes. 214 inner_box.getFocus().clear(); 215 inner_box.getFocus().child_id = null; 216 217 for (items) |item| { 218 const line = try allocator.dupe(u8, item); 219 { 220 errdefer allocator.free(line); 221 try self.lines.append(allocator, line); 222 } 223 224 var text_box = try wgt.TextBox(Widget).init(allocator, line, .{ .border_style = .hidden, .rounded_corners = true, .wrap_kind = .none }); 225 errdefer text_box.deinit(allocator); 226 text_box.getFocus().focusable = true; 227 try inner_box.children.put(allocator, text_box.getFocus().id, .{ .widget = .{ .text_box = text_box }, .rect = null, .min_size = null }); 228 try inner_box.getFocus().addChild(text_box.getFocus(), .{ .width = 0, .height = 0 }, 0, 0); 229 } 230 231 if (inner_box.children.count() > 0) { 232 inner_box.getFocus().child_id = inner_box.children.keys()[0]; 233 } 234 } 235 236 pub fn build(self: *SelectableList, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 237 self.clearGrid(); 238 const children = &self.scroll.child.box.children; 239 for (children.keys(), children.values()) |id, *item| { 240 item.widget.text_box.options.border_style = if (self.getFocus().child_id == id) 241 (if (root_focus.grandchild_id == id) .double else .single) 242 else 243 .hidden; 244 } 245 try self.scroll.build(allocator, constraint, root_focus); 246 } 247 248 pub fn input(self: *SelectableList, allocator: std.mem.Allocator, key: inp.Key, root_focus: *Focus) !void { 249 _ = allocator; 250 if (self.getFocus().child_id) |child_id| { 251 const children = &self.scroll.child.box.children; 252 if (children.getIndex(child_id)) |current_index| { 253 var index = current_index; 254 255 switch (key) { 256 .arrow_up => { 257 index -|= 1; 258 }, 259 .arrow_down => { 260 if (index + 1 < children.count()) { 261 index += 1; 262 } 263 }, 264 .home => { 265 index = 0; 266 }, 267 .end => { 268 if (children.count() > 0) { 269 index = children.count() - 1; 270 } 271 }, 272 .page_up => { 273 if (self.getGrid()) |grid| { 274 const half_count = (grid.size.height / 3) / 2; 275 index -|= half_count; 276 } 277 }, 278 .page_down => { 279 if (self.getGrid()) |grid| { 280 if (children.count() > 0) { 281 const half_count = (grid.size.height / 3) / 2; 282 index = @min(index + half_count, children.count() - 1); 283 } 284 } 285 }, 286 .mouse => |mouse| switch (mouse.action) { 287 .scroll => |dir| switch (dir) { 288 .up => index -|= 1, 289 .down => if (index + 1 < children.count()) { 290 index += 1; 291 }, 292 }, 293 else => {}, 294 }, 295 else => {}, 296 } 297 298 if (index != current_index) { 299 try root_focus.setFocus(children.keys()[index]); 300 self.updateScroll(index); 301 } 302 } 303 } 304 } 305 306 pub fn clearGrid(self: *SelectableList) void { 307 self.scroll.clearGrid(); 308 } 309 310 pub fn getGrid(self: SelectableList) ?Grid { 311 return self.scroll.getGrid(); 312 } 313 314 pub fn getFocus(self: *SelectableList) *Focus { 315 return self.scroll.getFocus(); 316 } 317 318 pub fn getSelectedIndex(self: SelectableList) ?usize { 319 if (self.scroll.child.box.focus.child_id) |child_id| { 320 const children = &self.scroll.child.box.children; 321 return children.getIndex(child_id); 322 } else { 323 return null; 324 } 325 } 326 327 fn updateScroll(self: *SelectableList, index: usize) void { 328 const inner_box = &self.scroll.child.box; 329 if (inner_box.children.values()[index].rect) |rect| { 330 self.scroll.scrollToRect(rect); 331 } 332 } 333 }; 334 335 // an invisible widget that fills the horizontal space granted by its parent. 336 // used inside Box(horiz) with a min_size so the box reserves space for the 337 // children that follow, pushing them to the right. 338 pub const Spacer = struct { 339 focus: Focus, 340 grid: ?Grid, 341 342 pub fn init(allocator: std.mem.Allocator) Spacer { 343 return .{ 344 .focus = Focus.init(allocator, .container), 345 .grid = null, 346 }; 347 } 348 349 pub fn deinit(self: *Spacer, allocator: std.mem.Allocator) void { 350 _ = allocator; 351 self.focus.deinit(); 352 if (self.grid) |*grid| { 353 grid.deinit(); 354 self.grid = null; 355 } 356 } 357 358 pub fn build(self: *Spacer, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 359 _ = root_focus; 360 self.clearGrid(); 361 const width = constraint.max_size.width orelse return; 362 if (width == 0) return; 363 self.grid = try Grid.init(allocator, .{ .width = width, .height = 1 }); 364 } 365 366 pub fn input(self: *Spacer, allocator: std.mem.Allocator, key: inp.Key, root_focus: *Focus) !void { 367 _ = self; 368 _ = allocator; 369 _ = key; 370 _ = root_focus; 371 } 372 373 pub fn clearGrid(self: *Spacer) void { 374 if (self.grid) |*grid| { 375 grid.deinit(); 376 self.grid = null; 377 } 378 } 379 380 pub fn getGrid(self: Spacer) ?Grid { 381 return self.grid; 382 } 383 384 pub fn getFocus(self: *Spacer) *Focus { 385 return &self.focus; 386 } 387 }; 388 389 // a single-child wrapper that builds the child at its natural size and 390 // positions its grid in the middle of the area granted by the parent 391 pub const Center = struct { 392 focus: Focus, 393 grid: ?Grid, 394 child: *Widget, 395 direction: Direction, 396 397 pub const Direction = enum { both, horiz, vert }; 398 399 pub fn init(allocator: std.mem.Allocator, child_widget: Widget, direction: Direction) !Center { 400 const child = try allocator.create(Widget); 401 errdefer allocator.destroy(child); 402 child.* = child_widget; 403 return .{ 404 .focus = Focus.init(allocator, .container), 405 .grid = null, 406 .child = child, 407 .direction = direction, 408 }; 409 } 410 411 pub fn deinit(self: *Center, allocator: std.mem.Allocator) void { 412 self.focus.deinit(); 413 if (self.grid) |*grid| { 414 grid.deinit(); 415 self.grid = null; 416 } 417 self.child.deinit(allocator); 418 allocator.destroy(self.child); 419 } 420 421 pub fn build(self: *Center, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 422 self.clearGrid(); 423 self.getFocus().clear(); 424 425 // build the child without forcing it to fill min_size; it sizes 426 // itself to its content within the available max. 427 try self.child.build(allocator, .{ 428 .min_size = .{ .width = null, .height = null }, 429 .max_size = constraint.max_size, 430 }, root_focus); 431 432 const child_grid = self.child.getGrid() orelse return; 433 434 // prefer max when given; otherwise grow to max(min, child) so that 435 // when only a min is set (e.g. the wasm path passes viewport rows 436 // as min_height with no max), we can still vertically center while 437 // letting taller content extend past the min. 438 const width = if (constraint.max_size.width) |w| w 439 else if (constraint.min_size.width) |min_w| @max(min_w, child_grid.size.width) 440 else child_grid.size.width; 441 const height = if (constraint.max_size.height) |h| h 442 else if (constraint.min_size.height) |min_h| @max(min_h, child_grid.size.height) 443 else child_grid.size.height; 444 if (width == 0 or height == 0) return; 445 446 const offset_x: usize = if (self.direction == .horiz or self.direction == .both) 447 (width -| child_grid.size.width) / 2 448 else 449 0; 450 const offset_y: usize = if (self.direction == .vert or self.direction == .both) 451 (height -| child_grid.size.height) / 2 452 else 453 0; 454 455 var grid = try Grid.init(allocator, .{ .width = width, .height = height }); 456 errdefer grid.deinit(); 457 try grid.drawGrid(child_grid, offset_x, offset_y); 458 try self.getFocus().addChild(self.child.getFocus(), child_grid.size, offset_x, offset_y); 459 self.getFocus().child_id = self.child.getFocus().id; 460 461 self.grid = grid; 462 } 463 464 pub fn input(self: *Center, allocator: std.mem.Allocator, key: inp.Key, root_focus: *Focus) !void { 465 try self.child.input(allocator, key, root_focus); 466 } 467 468 pub fn clearGrid(self: *Center) void { 469 if (self.grid) |*grid| { 470 grid.deinit(); 471 self.grid = null; 472 } 473 self.child.clearGrid(); 474 } 475 476 pub fn getGrid(self: Center) ?Grid { 477 return self.grid; 478 } 479 480 pub fn getFocus(self: *Center) *Focus { 481 return &self.focus; 482 } 483 }; 484