╭ changed ─╮ │src/ui.zig│ ╰──────────╯ ╭───────────╮ │← all files│ ╰───────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────╮ │ 8 const Grid = xitui.grid.Grid; │ │ 9 const Focus = xitui.focus.Focus; │ │10 const evt = @import("./event.zig"); │ │11 -const pg = @import("./page.zig"); │ │11 + │ │12 +pub const UsersAndRepos = @import("./ui/UsersAndRepos.zig"); │ │13 + │ │14 +pub const Page = union(enum) { │ │15 + users_and_repos: UsersAndRepos, │ │16 +}; │ │17 │ │13 -pub fn run(io: std.Io, allocator: std.mem.Allocator, page: *const pg.Page) !void {│ │18 +pub fn run(io: std.Io, allocator: std.mem.Allocator, page: *const Page) !void { │ │19 var root = try initRoot(allocator, page); │ │20 defer root.deinit(); │ │21 │ ╰──────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────╮ │74 } │ │75 } │ │76 │ │72 -pub fn initRoot(allocator: std.mem.Allocator, page: *const pg.Page) !Widget { │ │77 +pub fn initRoot(allocator: std.mem.Allocator, page: *const Page) !Widget { │ │78 var root: Widget = switch (page.*) { │ │74 - .user_repo => |*p| .{ .user_repo_view = try UserRepoView.init(allocator, p) }, │ │79 + .users_and_repos => |*p| .{ .users_and_repos = try UsersAndRepos.View.init(allocator, p) },│ │80 }; │ │81 errdefer root.deinit(); │ │82 │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ 91 return root; │ │ 92 } │ │ 93 │ │ 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 - │ │ 94 pub const Widget = union(enum) { │ │ 95 text: wgt.Text(Widget), │ │ 96 box: wgt.Box(Widget), │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────╮ │ 97 text_box: wgt.TextBox(Widget), │ │ 98 scroll: wgt.Scroll(Widget), │ │ 99 selectable_list: SelectableList, │ │154 - user_repo_view: UserRepoView, │ │100 + users_and_repos: UsersAndRepos.View,│ │101 │ │102 pub fn deinit(self: *Widget) void { │ │103 switch (self.*) { │ ╰─────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────╮ │138 │ │139 // a scrollable list of selectable single-line items. content is replaced │ │140 // via setItems, which dupes the strings into the widget's own allocation.│ │195 -const SelectableList = struct { │ │141 +pub const SelectableList = struct { │ │142 allocator: std.mem.Allocator, │ │143 scroll: wgt.Scroll(Widget), │ │144 lines: std.ArrayList([]const u8), │ ╰───────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │303 } │ │304 } │ │305 }; │ │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(); │ │306 │ │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 - │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯