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