╭─────────────╮ │scroll to top│ ╰─────────────╯ ╭ Users.zig ────────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
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 pub const View = struct { 63 // a vertical box: the search sub-header above the scrolling list. focus 64 // points at the header's box or the list's selected row. 65 box: wgt.Box(ui.Widget), 66 data: *const Self, 67 session: *ui.Session, 68 69 const header_index = 0; 70 const list_index = 1; 71 72 pub fn init(allocator: std.mem.Allocator, data: *const Self, session: *ui.Session) !View { 73 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); 74 errdefer box.deinit(allocator); 75 76 { 77 var header = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .horiz }); 78 errdefer header.deinit(allocator); 79 { 80 var search_box = try ui.widget.SearchBox.init(allocator, session, " search ", "search", data.search); 81 errdefer search_box.deinit(allocator); 82 header.getFocus().child_id = search_box.getFocus().id; 83 try header.children.put(allocator, search_box.getFocus().id, .{ .widget = .{ .search_box = search_box }, .rect = null, .min_size = ui.widget.SearchBox.min_size }); 84 } 85 // the spacer keeps the box at its own width 86 { 87 var spacer = try ui.widget.Spacer.init(allocator); 88 errdefer spacer.deinit(allocator); 89 try header.children.put(allocator, spacer.getFocus().id, .{ .widget = .{ .spacer = spacer }, .rect = null, .min_size = null }); 90 } 91 try box.children.put(allocator, header.getFocus().id, .{ .widget = .{ .box = header }, .rect = null, .min_size = .{ .width = null, .height = ui.widget.SearchBox.min_size.height } }); 92 } 93 94 { 95 var list = try ui.widget.FlowBox.Scroll.init(allocator, .{ .cell_height = 1 }, !session.is_terminal); 96 errdefer list.deinit(allocator); 97 98 var arena = std.heap.ArenaAllocator.init(allocator); 99 defer arena.deinit(); 100 const aa = arena.allocator(); 101 102 const route = (ui.RoutablePage{ .home_users = .{} }).withSearch(data.search orelse "") orelse return error.RouteTooLong; 103 104 // a leading "previous" row off the first window, one row per user, then 105 // a trailing "next" row when more remain. each window row navigates to the 106 // adjacent window (full reload on web, Nav rebuild on the TUI). 107 var items: std.ArrayList(ui.widget.FlowBox.Item) = .empty; 108 if (data.start > 0) { 109 var prev = route; 110 prev.home_users.start = data.start -| page_size; 111 try items.append(aa, .{ .text = "← previous", .link = try std.fmt.allocPrint(aa, "a:{s}", .{try prev.toUrl(&arena)}) }); 112 } 113 for (data.users) |user| 114 // clicking a user opens their page; the "a:" prefix makes the web 115 // renderer emit an <a href="/user/foo"> anchor. 116 try items.append(aa, .{ 117 .text = user.name, 118 .link = try std.fmt.allocPrint(aa, "a:/user/{s}", .{user.name}), 119 }); 120 if (data.next_start) |next_start| { 121 var next = route; 122 next.home_users.start = next_start; 123 try items.append(aa, .{ .text = "next →", .link = try std.fmt.allocPrint(aa, "a:{s}", .{try next.toUrl(&arena)}) }); 124 } 125 try list.setItems(allocator, items.items); 126 127 try box.children.put(allocator, list.getFocus().id, .{ .widget = .{ .flow_box_scroll = list }, .rect = null, .min_size = null }); 128 } 129 130 // search results start in the box so the term can be refined right away 131 box.getFocus().child_id = box.children.keys()[if (data.search != null) header_index else list_index]; 132 return .{ .box = box, .data = data, .session = session }; 133 } 134 135 pub fn deinit(self: *View, allocator: std.mem.Allocator) void { 136 self.box.deinit(allocator); 137 } 138 139 pub fn build(self: *View, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 140 self.clearGrid(); 141 // clear the incoming min height so the list scrolls within what's left 142 try self.box.build(allocator, .{ 143 .min_size = .{ .width = null, .height = null }, 144 .max_size = constraint.max_size, 145 }, root_focus); 146 } 147 148 pub fn input(self: *View, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 149 const direction = inp.vertDirection(key); 150 if (self.headerActive()) { 151 const search_box = self.searchBox(); 152 if (direction == .down) return root_focus.setFocus(self.listScroll().getFocus().id); 153 if (key == .enter) { 154 const text = try search_box.text(allocator); 155 defer allocator.free(text); 156 return self.submit(text); 157 } 158 return search_box.input(allocator, key, root_focus); 159 } 160 // up from the first row reaches the search box 161 if (direction == .up and self.listScroll().atTop()) return self.focusHeader(root_focus); 162 try self.listScroll().input(allocator, key, root_focus); 163 } 164 165 // the users narrowed to `text` from the first window. an empty box on an 166 // unsearched page has nothing to clear. 167 fn submit(self: *View, text: []const u8) !void { 168 if (text.len == 0 and self.data.search == null) return; 169 try self.session.navigate((ui.RoutablePage{ .home_users = .{} }).withSearch(text) orelse return); 170 } 171 172 fn searchBox(self: *View) *ui.widget.SearchBox { 173 return &self.box.children.values()[header_index].widget.box.children.values()[0].widget.search_box; 174 } 175 176 fn listScroll(self: *View) *ui.widget.FlowBox.Scroll { 177 return &self.box.children.values()[list_index].widget.flow_box_scroll; 178 } 179 180 fn headerActive(self: *View) bool { 181 return self.box.getFocus().child_id == self.box.children.keys()[header_index]; 182 } 183 184 pub fn clearGrid(self: *View) void { 185 self.box.clearGrid(); 186 } 187 188 pub fn getGrid(self: View) ?Grid { 189 return self.box.getGrid(); 190 } 191 192 pub fn getFocus(self: *View) *Focus { 193 return self.box.getFocus(); 194 } 195 196 // up leaves the tab from the search box 197 pub fn atTop(self: *View) bool { 198 return self.headerActive(); 199 } 200 201 pub fn focusHeader(self: *View, root_focus: *Focus) void { 202 root_focus.setFocus(self.searchBox().getFocus().id); 203 } 204 }; 205