╭─────────────╮ │scroll to top│ ╰─────────────╯ ╭ Users.zig ────────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭ find file ─ ─ ─ ─ ─ ─ ─ ─ ─ ─╮ ╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─╮ │ │ │http│ ssh │ │ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─╯ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─╯
7 const wgt = xitui.widget; 8 const layout = xitui.layout; 9 const Key = xitui.input.Key; 10 const Grid = xitui.grid.Grid; 11 const Focus = xitui.focus.Focus; 12 const inp = @import("../input.zig"); 13 14 pub const page_size = 20; // how many users one window shows 15 16 users: []const evt.User.Public, 17 start: usize, // the window start this page was built with, mirrored into the url 18 next_start: ?usize, // the `start` for the "next" row, or null when this is the last window 19 search: ?[]const u8, // the name prefix the list is narrowed to (decoded; null = no search) 20 21 const Self = @This(); 22 23 pub fn init( 24 arena: *std.heap.ArenaAllocator, 25 haxy_moment: evt.AdminDB.HashMap(.read_only), 26 route: ui.RoutablePage.HomeUsersRoute, 27 ) !Self { 28 const DB = evt.AdminDB; 29 const hash_kind = evt.admin_repo_opts.hash; 30 const aa = arena.allocator(); 31 32 var result: Self = .{ 33 .users = &.{}, 34 .start = route.start, 35 .next_start = null, 36 .search = if (route.search.len == 0) null else std.Uri.percentDecodeInPlace(try aa.dupe(u8, route.search.slice())), 37 }; 38 const prefix = result.search orelse ""; 39 40 // the active users sorted by name; absent until the first user exists 41 const index_cursor = try haxy_moment.getCursor(hash.hashInt(hash_kind, evt.User.name_index_key)) orelse return result; 42 const index = try DB.SortedMap(.read_only).init(index_cursor); 43 44 // the names with the prefix are contiguous from its rank, so the window 45 // is one seek then a walk that stops at the first name without it 46 var users: std.ArrayList(evt.User.Public) = .empty; 47 var iter = try index.iteratorFromIndex(try index.rank(prefix) +| route.start); 48 while (try iter.next()) |cursor| { 49 const pair = try cursor.readKeyValuePair(); 50 const name = try pair.key_cursor.readBytesAlloc(aa, null); 51 if (!std.mem.startsWith(u8, name, prefix)) break; 52 if (users.items.len == page_size) { 53 result.next_start = route.start + page_size; 54 break; 55 } 56 try users.append(aa, .{ .name = name }); 57 } 58 result.users = users.items; 59 return result; 60 } 61 62 // a first window without users, which shows the empty message 63 fn isEmpty(self: *const Self) bool { 64 return self.users.len == 0 and self.start == 0; 65 } 66 67 pub const View = struct { 68 // a vertical box: the search sub-header above the scrolling list. focus 69 // points at the header's box or the list's selected row. 70 box: wgt.Box(ui.Widget), 71 data: *const Self, 72 session: *ui.Session, 73 74 const header_index = 0; 75 const list_index = 1; 76 77 pub fn init(allocator: std.mem.Allocator, data: *const Self, session: *ui.Session) !View { 78 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border = null, .direction = .vert }); 79 errdefer box.deinit(allocator); 80 81 { 82 var header_scroll = try ui.widget.HeaderScroll.init(allocator, session); 83 errdefer header_scroll.deinit(allocator); 84 const header = header_scroll.row(); 85 { 86 var search_box = try ui.widget.SearchBox.init(allocator, session, " search ", "search", data.search); 87 errdefer search_box.deinit(allocator); 88 header.getFocus().child_id = search_box.getFocus().id; 89 try header.children.put(allocator, search_box.getFocus().id, .{ .widget = .{ .search_box = search_box }, .rect = null, .min_size = ui.widget.SearchBox.min_size }); 90 } 91 // the spacer keeps the box at its own width 92 { 93 var spacer = try ui.widget.Spacer.init(allocator); 94 errdefer spacer.deinit(allocator); 95 try header.children.put(allocator, spacer.getFocus().id, .{ .widget = .{ .spacer = spacer }, .rect = null, .min_size = null }); 96 } 97 try box.children.put(allocator, header_scroll.getFocus().id, .{ .widget = .{ .header_scroll = header_scroll }, .rect = null, .min_size = .{ .width = null, .height = ui.widget.SearchBox.min_size.height } }); 98 } 99 100 // an empty first window has no rows, not even paging ones 101 if (data.isEmpty()) { 102 var center = try ui.widget.initEmpty(allocator); 103 errdefer center.deinit(allocator); 104 try box.children.put(allocator, center.getFocus().id, .{ .widget = .{ .center = center }, .rect = null, .min_size = null }); 105 } else { 106 var list = try ui.widget.FlowBox.Scroll.init(allocator, .{ .cell_height = 1 }, !session.is_terminal); 107 errdefer list.deinit(allocator); 108 109 var arena = std.heap.ArenaAllocator.init(allocator); 110 defer arena.deinit(); 111 const aa = arena.allocator(); 112 113 const route = (ui.RoutablePage{ .home_users = .{} }).withSearch(data.search orelse "") orelse return error.RouteTooLong; 114 115 // a leading "previous" row off the first window, one row per user, then 116 // a trailing "next" row when more remain. each window row navigates to the 117 // adjacent window (full reload on web, Nav rebuild on the TUI). 118 var items: std.ArrayList(ui.widget.FlowBox.Item) = .empty; 119 if (data.start > 0) { 120 var prev = route; 121 prev.home_users.start = data.start -| page_size; 122 try items.append(aa, .{ .text = "← previous", .link = try aa.print("a:{s}", .{try prev.toUrl(&arena)}) }); 123 } 124 for (data.users) |user| 125 // clicking a user opens their page; the "a:" prefix makes the web 126 // renderer emit an <a href="/foo"> anchor. 127 try items.append(aa, .{ 128 .text = user.name, 129 .link = try aa.print("a:/{s}", .{user.name}), 130 }); 131 if (data.next_start) |next_start| { 132 var next = route; 133 next.home_users.start = next_start; 134 try items.append(aa, .{ .text = "next →", .link = try aa.print("a:{s}", .{try next.toUrl(&arena)}) }); 135 } 136 try list.setItems(allocator, items.items); 137 138 try box.children.put(allocator, list.getFocus().id, .{ .widget = .{ .flow_box_scroll = list }, .rect = null, .min_size = null }); 139 } 140 141 // search results start in the box so the term can be refined right away 142 box.getFocus().child_id = box.children.keys()[if (data.search != null) header_index else list_index]; 143 return .{ .box = box, .data = data, .session = session }; 144 } 145 146 pub fn deinit(self: *View, allocator: std.mem.Allocator) void { 147 self.box.deinit(allocator); 148 } 149 150 pub fn build(self: *View, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 151 self.clearGrid(); 152 // clear the incoming min height so the list scrolls within what's left 153 try self.box.build(allocator, .{ 154 .min_size = .{ .width = null, .height = null }, 155 .max_size = constraint.max_size, 156 }, root_focus); 157 } 158 159 pub fn input(self: *View, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 160 const direction = inp.vertDirection(key); 161 if (self.headerActive()) { 162 const search_box = self.searchBox(); 163 if (direction == .down) { 164 // the empty message has nothing to focus 165 if (!self.data.isEmpty()) root_focus.setFocus(self.listScroll().getFocus().id); 166 return; 167 } 168 if (key == .enter) { 169 const text = try search_box.text(allocator); 170 defer allocator.free(text); 171 return self.submit(text); 172 } 173 return search_box.input(allocator, key, root_focus); 174 } 175 // up from the first row reaches the search box 176 if (direction == .up and self.listScroll().atTop()) return self.focusHeader(root_focus); 177 try self.listScroll().input(allocator, key, root_focus); 178 } 179 180 // the users narrowed to `text` from the first window. an empty box on an 181 // unsearched page has nothing to clear. 182 fn submit(self: *View, text: []const u8) !void { 183 if (text.len == 0 and self.data.search == null) return; 184 try self.session.navigate((ui.RoutablePage{ .home_users = .{} }).withSearch(text) orelse return); 185 } 186 187 fn searchBox(self: *View) *ui.widget.SearchBox { 188 return &self.box.children.values()[header_index].widget.header_scroll.row().children.values()[0].widget.search_box; 189 } 190 191 fn listScroll(self: *View) *ui.widget.FlowBox.Scroll { 192 return &self.box.children.values()[list_index].widget.flow_box_scroll; 193 } 194 195 fn headerActive(self: *View) bool { 196 return self.box.getFocus().child_id == self.box.children.keys()[header_index]; 197 } 198 199 pub fn clearGrid(self: *View) void { 200 self.box.clearGrid(); 201 } 202 203 pub fn getGrid(self: View) ?Grid { 204 return self.box.getGrid(); 205 } 206 207 pub fn getFocus(self: *View) *Focus { 208 return self.box.getFocus(); 209 } 210 211 // up leaves the tab from the search box 212 pub fn atTop(self: *View) bool { 213 return self.headerActive(); 214 } 215 216 pub fn focusHeader(self: *View, root_focus: *Focus) void { 217 root_focus.setFocus(self.searchBox().getFocus().id); 218 } 219 }; 220