╭──────────╮ │← previous│ ╰──────────╯ ╭ changed ─╮ │src/ui.zig│ ╰──────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │167 + return self; │ │168 + } │ │169 │ │170 + pub fn deinit(self: *SelectableList) void { │ │171 + self.scroll.deinit(); │ │172 + for (self.lines.items) |line| self.allocator.free(line); │ │173 + self.lines.deinit(self.allocator); │ │174 + } │ │175 + │ │176 + pub fn setItems(self: *SelectableList, items: []const []const u8) !void { │ │177 const inner_box = &self.scroll.child.box; │ │178 │ │117 - { │ │118 - var text_box = try wgt.TextBox(Widget).init(allocator, "this is a TextBox", .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .none }); │ │119 - errdefer text_box.deinit(); │ │120 - text_box.getFocus().focusable = true; │ │121 - try inner_box.children.put(allocator, text_box.getFocus().id, .{ .widget = .{ .text_box = text_box }, .rect = null, .min_size = null }); │ │179 + for (inner_box.children.values()) |*child| { │ │180 + child.widget.deinit(); │ │181 } │ │182 + inner_box.children.clearAndFree(self.allocator); │ │183 │ │124 - { │ │125 - var text_box = try wgt.TextBox(Widget).init(allocator, "this is a\nmulti-line TextBox", .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .none });│ │184 + for (self.lines.items) |line| self.allocator.free(line); │ │185 + self.lines.clearAndFree(self.allocator); │ │186 + │ │187 + self.scroll.x = 0; │ │188 + self.scroll.y = 0; │ │189 + self.scroll.getFocus().child_id = null; │ │190 + │ │191 + for (items) |item| { │ │192 + const line = try self.allocator.dupe(u8, item); │ │193 + { │ │194 + errdefer self.allocator.free(line); │ │195 + try self.lines.append(self.allocator, line); │ │196 + } │ │197 + │ │198 + var text_box = try wgt.TextBox(Widget).init(self.allocator, line, .{ .border_style = .hidden, .rounded_corners = true, .wrap_kind = .none }); │ │199 errdefer text_box.deinit(); │ │200 text_box.getFocus().focusable = true; │ │128 - try inner_box.children.put(allocator, text_box.getFocus().id, .{ .widget = .{ .text_box = text_box }, .rect = null, .min_size = null }); │ │201 + try inner_box.children.put(self.allocator, text_box.getFocus().id, .{ .widget = .{ .text_box = text_box }, .rect = null, .min_size = null }); │ │202 } │ │203 │ │204 if (inner_box.children.count() > 0) { │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────╮ │205 self.scroll.getFocus().child_id = inner_box.children.keys()[0]; │ │206 } │ │134 - │ │135 - return self; │ │207 } │ │208 │ │138 - pub fn deinit(self: *WidgetList) void { │ │139 - self.scroll.deinit(); │ │140 - } │ │141 - │ │142 - pub fn build(self: *WidgetList, constraint: layout.Constraint, root_focus: *Focus) !void { │ │209 + pub fn build(self: *SelectableList, constraint: layout.Constraint, root_focus: *Focus) !void {│ │210 self.clearGrid(); │ │211 const children = &self.scroll.child.box.children; │ │145 - for (children.keys(), children.values()) |id, *commit| { │ │146 - commit.widget.text_box.options.border_style = if (self.getFocus().child_id == id) │ │212 + for (children.keys(), children.values()) |id, *item| { │ │213 + item.widget.text_box.options.border_style = if (self.getFocus().child_id == id) │ │214 (if (root_focus.grandchild_id == id) .double else .single) │ │215 else │ │149 - .single; │ │216 + .hidden; │ │217 } │ │151 - try self.scroll.build(constraint, root_focus); │ │218 + // force a minimum row of content so an empty list still produces a │ │219 + // non-zero grid. without this, xitui's Scroll.build copies from a │ │220 + // zero-cell child grid and traps with OOB in release builds because │ │221 + // NDSlice.at's bounds check is gated on runtime_safety. │ │222 + var adjusted = constraint; │ │223 + adjusted.min_size.height = @max(adjusted.min_size.height orelse 0, 1); │ │224 + try self.scroll.build(adjusted, root_focus); │ │225 } │ │226 │ │154 - pub fn input(self: *WidgetList, key: inp.Key, root_focus: *Focus) !void { │ │227 + pub fn input(self: *SelectableList, key: inp.Key, root_focus: *Focus) !void { │ │228 if (self.getFocus().child_id) |child_id| { │ │229 const children = &self.scroll.child.box.children; │ │230 if (children.getIndex(child_id)) |current_index| { │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────╮ │272 } │ │273 } │ │274 │ │202 - pub fn clearGrid(self: *WidgetList) void { │ │275 + pub fn clearGrid(self: *SelectableList) void {│ │276 self.scroll.clearGrid(); │ │277 } │ │278 │ ╰───────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────╮ │206 - pub fn getGrid(self: WidgetList) ?Grid { │ │279 + pub fn getGrid(self: SelectableList) ?Grid {│ │280 return self.scroll.getGrid(); │ │281 } │ │282 │ ╰─────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────╮ │210 - pub fn getFocus(self: *WidgetList) *Focus { │ │283 + pub fn getFocus(self: *SelectableList) *Focus {│ │284 return self.scroll.getFocus(); │ │285 } │ │286 │ ╰────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────╮ │214 - pub fn getSelectedIndex(self: WidgetList) ?usize { │ │287 + pub fn getSelectedIndex(self: SelectableList) ?usize { │ │288 if (self.scroll.child.box.focus.child_id) |child_id| {│ │289 const children = &self.scroll.child.box.children; │ │290 return children.getIndex(child_id); │ ╰───────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────╮ │293 } │ │294 } │ │295 │ │223 - fn updateScroll(self: *WidgetList, index: usize) void { │ │224 - const left_box = &self.scroll.child.box; │ │225 - if (left_box.children.values()[index].rect) |rect| { │ │296 + fn updateScroll(self: *SelectableList, index: usize) void {│ │297 + const inner_box = &self.scroll.child.box; │ │298 + if (inner_box.children.values()[index].rect) |rect| { │ │299 self.scroll.scrollToRect(rect); │ │300 } │ │301 } │ ╰────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │302 }; │ │303 │ │231 -pub const Widget = union(enum) { │ │232 - text: wgt.Text(Widget), │ │304 +// horizontal two-pane view: users on the left, the selected user's │ │305 +// repos on the right. selecting a user repopulates the repo pane. │ │306 +const UserRepoView = struct { │ │307 + allocator: std.mem.Allocator, │ │308 box: wgt.Box(Widget), │ │234 - text_box: wgt.TextBox(Widget), │ │235 - scroll: wgt.Scroll(Widget), │ │236 - widget_list: WidgetList, │ │309 + page: *const pg.UserRepoPage, │ │310 + last_user_index: ?usize, │ │311 + │ │312 + const user_list_index: usize = 0; │ │313 + const repo_list_index: usize = 1; │ │314 + │ │315 + pub fn init(allocator: std.mem.Allocator, page: *const pg.UserRepoPage) !UserRepoView { │ │316 + var self = blk: { │ │317 + var box = try wgt.Box(Widget).init(allocator, .{ .border_style = null, .rounded_corners = true, .direction = .horiz }); │ │318 + errdefer box.deinit(); │ │319 + │ │320 + { │ │321 + var user_list = try SelectableList.init(allocator); │ │322 + errdefer user_list.deinit(); │ │323 + try box.children.put(allocator, user_list.getFocus().id, .{ .widget = .{ .selectable_list = user_list }, .rect = null, .min_size = .{ .width = 30, .height = null } });│ │324 + } │ │325 + │ │326 + { │ │327 + var repo_list = try SelectableList.init(allocator); │ │328 + errdefer repo_list.deinit(); │ │329 + try box.children.put(allocator, repo_list.getFocus().id, .{ .widget = .{ .selectable_list = repo_list }, .rect = null, .min_size = .{ .width = 40, .height = null } });│ │330 + } │ │331 + │ │332 + break :blk UserRepoView{ │ │333 + .allocator = allocator, │ │334 + .box = box, │ │335 + .page = page, │ │336 + .last_user_index = null, │ │337 + }; │ │338 + }; │ │339 + errdefer self.deinit(); │ │340 + │ │341 + // populate the user list once at init using a temporary arena │ │342 + // for the formatted lines (setItems dupes them). │ │343 + var arena = std.heap.ArenaAllocator.init(allocator); │ │344 + defer arena.deinit(); │ │345 + const aa = arena.allocator(); │ │346 │ │238 - pub fn deinit(self: *Widget) void { │ │239 - switch (self.*) { │ │240 - inline else => |*case| case.deinit(), │ │347 + const user_lines = try aa.alloc([]const u8, page.users.len); │ │348 + for (page.users, 0..) |uwr, i| { │ │349 + user_lines[i] = try std.fmt.allocPrint(aa, "{s} ({s})", .{ uwr.user.name, uwr.user.display_name }); │ │350 } │ │351 + const user_list = &self.box.children.values()[user_list_index].widget.selectable_list; │ │352 + try user_list.setItems(user_lines); │ │353 + │ │354 + self.getFocus().child_id = self.box.children.keys()[user_list_index]; │ │355 + try self.updateRepos(); │ │356 + │ │357 + return self; │ │358 } │ │359 │ │244 - pub fn build(self: *Widget, constraint: layout.Constraint, root_focus: *Focus) anyerror!void { │ │245 - switch (self.*) { │ │246 - inline else => |*case| try case.build(constraint, root_focus), │ │360 + pub fn deinit(self: *UserRepoView) void { │ │361 + self.box.deinit(); │ │362 + } │ │363 + │ │364 + pub fn build(self: *UserRepoView, constraint: layout.Constraint, root_focus: *Focus) !void { │ │365 + self.clearGrid(); │ │366 + try self.box.build(constraint, root_focus); │ │367 + } │ │368 + │ │369 + pub fn input(self: *UserRepoView, key: inp.Key, root_focus: *Focus) !void { │ │370 + if (self.getFocus().child_id) |child_id| { │ │371 + if (self.box.children.getIndex(child_id)) |current_index| { │ │372 + const child = &self.box.children.values()[current_index].widget; │ │373 + │ │374 + const index = blk: { │ │375 + switch (key) { │ │376 + .arrow_left => { │ │377 + if (current_index == repo_list_index) { │ │378 + break :blk user_list_index; │ │379 + } │ │380 + }, │ │381 + .arrow_right => { │ │382 + if (current_index == user_list_index) { │ │383 + break :blk repo_list_index; │ │384 + } │ │385 + }, │ │386 + .codepoint => { │ │387 + switch (key.codepoint) { │ │388 + 13 => { │ │389 + if (current_index == user_list_index) { │ │390 + break :blk repo_list_index; │ │391 + } │ │392 + }, │ │393 + 127, '\x1B' => { │ │394 + if (current_index == repo_list_index) { │ │395 + break :blk user_list_index; │ │396 + } │ │397 + }, │ │398 + else => {}, │ │399 + } │ │400 + }, │ │401 + else => {}, │ │402 + } │ │403 + try child.input(key, root_focus); │ │404 + if (current_index == user_list_index) { │ │405 + try self.updateRepos(); │ │406 + } │ │407 + break :blk current_index; │ │408 + }; │ │409 + │ │410 + if (index != current_index) { │ │411 + try root_focus.setFocus(self.box.children.keys()[index]); │ │412 + } │ │413 + } │ │414 } │ │415 } │ │416 │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │250 - pub fn input(self: *Widget, key: inp.Key, root_focus: *Focus) anyerror!void { │ │251 - switch (self.*) { │ │252 - inline else => |*case| try case.input(key, root_focus), │ │253 - } │ │417 + pub fn clearGrid(self: *UserRepoView) void { │ │418 + self.box.clearGrid(); │ │419 } │ │420 │ │256 - pub fn clearGrid(self: *Widget) void { │ │257 - switch (self.*) { │ │258 - inline else => |*case| case.clearGrid(), │ │259 - } │ │421 + pub fn getGrid(self: UserRepoView) ?Grid { │ │422 + return self.box.getGrid(); │ │423 } │ │424 │ │262 - pub fn getGrid(self: Widget) ?Grid { │ │263 - switch (self) { │ │264 - inline else => |*case| return case.getGrid(), │ │265 - } │ │425 + pub fn getFocus(self: *UserRepoView) *Focus { │ │426 + return self.box.getFocus(); │ │427 } │ │428 │ │268 - pub fn getFocus(self: *Widget) *Focus { │ │269 - switch (self.*) { │ │270 - inline else => |*case| return case.getFocus(), │ │429 + fn updateRepos(self: *UserRepoView) !void { │ │430 + const user_list = &self.box.children.values()[user_list_index].widget.selectable_list; │ │431 + const repo_list = &self.box.children.values()[repo_list_index].widget.selectable_list; │ │432 + │ │433 + const user_index = user_list.getSelectedIndex(); │ │434 + if (user_index == self.last_user_index) return; │ │435 + self.last_user_index = user_index; │ │436 + │ │437 + const repos = if (user_index) |i| self.page.users[i].repos else &.{}; │ │438 + │ │439 + var arena = std.heap.ArenaAllocator.init(self.allocator); │ │440 + defer arena.deinit(); │ │441 + const aa = arena.allocator(); │ │442 + │ │443 + const repo_lines = try aa.alloc([]const u8, repos.len); │ │444 + for (repos, 0..) |repo_event, i| { │ │445 + repo_lines[i] = try std.fmt.allocPrint(aa, "{s} - {s}", .{ repo_event.name, repo_event.description });│ │446 } │ │447 + try repo_list.setItems(repo_lines); │ │448 } │ │449 }; │ │450 │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ──╮ │src/web.zig│ ╰───────────╯ ╭────────────────────────────────────╮ │1 const std = @import("std"); │ │2 +const xit = @import("xit"); │ │3 +const rp = xit.repo; │ │4 const ui = @import("./ui.zig"); │ │5 +const pg = @import("./page.zig");│ │6 │ │7 const Asset = struct { │ │8 path: []const u8, │ ╰────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯