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