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