╭──────────╮ │← previous│ ╰──────────╯ ╭ changed ────────────╮ │src/ui/Home/Users.zig│ ╰─────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────╮ │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),│ │24 - start: usize, │ │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 │ │29 - var users: std.ArrayList(evt.User.Public) = .empty; │ │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 │ │31 - // the active users ordered by creation (newest first); absent until the first user │ │32 - // exists. keyed by orderKey ([created-order][event-id]); the trailing bytes │ │33 - // of each key are the user event id. │ │34 - const user_id_set_cursor = try haxy_moment.getCursor(hash.hashInt(hash_kind, evt.User.active_id_set_key)) orelse │ │35 - return .{ .users = &.{}, .start = start, .next_start = null }; │ │36 - const user_id_set = try DB.SortedSet(.read_only).init(user_id_set_cursor); │ │37 - const count = try user_id_set.count(); │ │38 - │ │39 - const event_id_to_user_cursor = try haxy_moment.getCursor(hash.hashInt(hash_kind, "event-id->user")) orelse │ │40 - return .{ .users = &.{}, .start = start, .next_start = null }; │ │41 - const event_id_to_user = try DB.HashMap(.read_only).init(event_id_to_user_cursor); │ │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 │ │43 - // read the window [start, start+page_size) with one seek to the start rank, │ │44 - // then a sequential walk │ │45 - const end = @min(start +| page_size, count); │ │46 - var iter = try user_id_set.iteratorFromIndex(start); │ │47 - var i = start; │ │48 - while (i < end) : (i += 1) { │ │49 - var id_cursor = (try iter.next()) orelse break; │ │50 - const id_kv = try id_cursor.readKeyValuePair(); │ │51 - var order_key: [@sizeOf(u64) + evt.event_id_size]u8 = undefined; │ │52 - _ = try id_kv.key_cursor.readBytes(&order_key); │ │53 - const event_id = order_key[@sizeOf(u64)..]; │ │54 - const user_cursor = try event_id_to_user.getCursor(hash.hashInt(hash_kind, event_id)) orelse continue; │ │55 - const user_map = try DB.HashMap(.read_only).init(user_cursor); │ │56 - const user_event = try evt.read(evt.User.Record, DB, hash_kind, arena, user_map); │ │57 - try users.append(arena.allocator(), evt.project(evt.User.Public, user_event.event)); │ │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 } │ │59 - │ │60 - return .{ │ │61 - .users = users.items, │ │62 - .start = start, │ │63 - .next_start = if (end < count) end else null, │ │64 - }; │ │58 + result.users = users.items; │ │59 + return result; │ │60 } │ │61 │ │62 pub const View = struct { │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ 68 - list: ui.widget.FlowBox.Scroll, │ │ 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 │ │ 71 - pub fn init(allocator: std.mem.Allocator, data: *const Self, web_native: bool) !View { │ │ 72 - var self = blk: { │ │ 73 - var list = try ui.widget.FlowBox.Scroll.init(allocator, .{ .cell_height = 1 }, web_native); │ │ 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 │ │ 76 - break :blk View{ │ │ 77 - .list = list, │ │ 78 - .data = data, │ │ 79 - }; │ │ 80 - }; │ │ 81 - errdefer self.deinit(allocator); │ │ 98 + var arena = std.heap.ArenaAllocator.init(allocator); │ │ 99 + defer arena.deinit(); │ │100 + const aa = arena.allocator(); │ │101 │ │ 83 - var arena = std.heap.ArenaAllocator.init(allocator); │ │ 84 - defer arena.deinit(); │ │ 85 - const aa = arena.allocator(); │ │102 + const route = (ui.RoutablePage{ .home_users = .{} }).withSearch(data.search orelse "") orelse return error.RouteTooLong; │ │103 │ │ 87 - // a leading "previous" row off the first window, one row per user, then │ │ 88 - // a trailing "next" row when more remain. each window row navigates to the │ │ 89 - // adjacent window (full reload on web, Nav rebuild on the TUI). │ │ 90 - var items: std.ArrayList(ui.widget.FlowBox.Item) = .empty; │ │ 91 - if (data.start > 0) │ │ 92 - try items.append(aa, .{ .text = "← previous", .link = try std.fmt.allocPrint(aa, "a:/users/start:{d}", .{data.start -| page_size}) }); │ │ 93 - for (data.users) |user| │ │ 94 - // clicking a user opens their page; the "a:" prefix makes the web │ │ 95 - // renderer emit an <a href="/user/foo"> anchor. │ │ 96 - try items.append(aa, .{ │ │ 97 - .text = try aa.dupe(u8, user.name), │ │ 98 - .link = try std.fmt.allocPrint(aa, "a:/user/{s}", .{user.name}), │ │ 99 - }); │ │100 - if (data.next_start) |next_start| │ │101 - try items.append(aa, .{ .text = "next →", .link = try std.fmt.allocPrint(aa, "a:/users/start:{d}", .{next_start}) }); │ │102 - try self.list.setItems(allocator, items.items); │ │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 │ │104 - return self; │ │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 { │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │108 - self.list.deinit(allocator); │ │136 + self.box.deinit(allocator); │ │137 } │ │138 │ │139 pub fn build(self: *View, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void {│ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────╮ │112 - try self.list.build(allocator, constraint, root_focus); │ │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 {│ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │116 - try self.list.input(allocator, key, root_focus); │ │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 { │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────╮ │120 - self.list.clearGrid(); │ │185 + self.box.clearGrid(); │ │186 } │ │187 │ │188 pub fn getGrid(self: View) ?Grid {│ ╰───────────────────────────────────────────╯ ╭──────────────────────────────────────────────╮ │124 - return self.list.getGrid(); │ │189 + return self.box.getGrid(); │ │190 } │ │191 │ │192 pub fn getFocus(self: *View) *Focus {│ ╰──────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────╮ │128 - return self.list.getFocus(); │ │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 │ │131 - pub fn atTop(self: View) bool { │ │132 - return self.list.atTop(); │ │201 + pub fn focusHeader(self: *View, root_focus: *Focus) void {│ │202 + root_focus.setFocus(self.searchBox().getFocus().id); │ │203 } │ │204 }; │ │205 │ ╰───────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯