╭──────────╮ │← previous│ ╰──────────╯ ╭ changed ───────────────╮ │src/ui/widget/thread.zig│ ╰────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │278 }; │ │279 } │ │280 │ │280 -// the common tab strip used by each thread page's sub-header │ │281 +// how many matches one search counts before the count is marked as capped │ │282 +const max_search_results = 1000; │ │283 + │ │284 +// `set`'s entries matching `query_text`, windowed like `loadWindow`. one pass │ │285 +// yields the window, its neighbours' roots and the match count, which stops at │ │286 +// the cap. │ │287 +fn loadSearchWindow( │ │288 + comptime Data: type, │ │289 + comptime kind: evt.EventKind, │ │290 + comptime hash_kind: hash.HashKind, │ │291 + arena: *std.heap.ArenaAllocator, │ │292 + admin_moment: ?evt.AdminDB.HashMap(.read_only), │ │293 + haxy_moment: evt.EventDB(hash_kind).HashMap(.read_only), │ │294 + records: evt.EventDB(hash_kind).HashMap(.read_only), │ │295 + set_maybe: ?evt.EventDB(hash_kind).SortedSet(.read_only), │ │296 + root_key: ?[]const u8, │ │297 + conflict_set: ?evt.EventDB(hash_kind).SortedSet(.read_only), │ │298 + selected_id: []const u8, │ │299 + comments_start: usize, │ │300 + query_text: []const u8, │ │301 +) !Data.Window { │ │302 + const set = set_maybe orelse return .empty; │ │303 + const DB = evt.EventDB(hash_kind); │ │304 + const aa = arena.allocator(); │ │305 + const root = root_key orelse ""; │ │306 + │ │307 + // the matching list keys, in list order. the cap bounds the count, not the │ │308 + // paging: past it a pass goes on only until the window and its "next" link │ │309 + // are known. │ │310 + var keys: std.ArrayList(OrderKey) = .empty; │ │311 + if (comptime kind == .discuss) { │ │312 + // discussions list by activity but are indexed by creation, so the │ │313 + // matching ids are gathered and the listed set is walked in its order │ │314 + var matches: std.AutoHashMapUnmanaged([evt.event_id_size]u8, void) = .empty; │ │315 + var results = try srch.query(DB, hash_kind, kind, haxy_moment, aa, query_text, null); │ │316 + while (try results.next()) |key| try matches.put(aa, (try resultKey(key))[@sizeOf(u64)..].*, {}); │ │317 + │ │318 + var iter = try set.iteratorFromIndex(0); │ │319 + while (matches.count() > 0) { │ │320 + const pair = try ((try iter.next()) orelse break).readKeyValuePair(); │ │321 + var order_key: OrderKey = undefined; │ │322 + _ = try pair.key_cursor.readBytes(&order_key); │ │323 + if (!matches.contains(order_key[@sizeOf(u64)..].*)) continue; │ │324 + try keys.append(aa, order_key); │ │325 + if (windowKnown(Data, keys.items, root)) break; │ │326 + } │ │327 + } else { │ │328 + // issues and patches list in the index's own order, so the listed set │ │329 + // narrows the query and the results stream out already sorted │ │330 + var results = try srch.query(DB, hash_kind, kind, haxy_moment, aa, query_text, set); │ │331 + while (try results.next()) |key| { │ │332 + try keys.append(aa, try resultKey(key)); │ │333 + if (windowKnown(Data, keys.items, root)) break; │ │334 + } │ │335 + } │ │336 + │ │337 + // the window starts at the first key at or after the root │ │338 + var first: usize = 0; │ │339 + while (first < keys.items.len and std.mem.order(u8, &keys.items[first], root) == .lt) first += 1; │ │340 + │ │341 + var items: std.ArrayList(Data.Entry) = .empty; │ │342 + const last = @min(first + Data.page_size, keys.items.len); │ │343 + for (keys.items[first..last]) |*order_key| { │ │344 + const entry = (try loadEntry(Data, hash_kind, arena, admin_moment, haxy_moment, records, order_key, conflict_set, selected_id, comments_start)) orelse continue;│ │345 + try items.append(aa, entry); │ │346 + } │ │347 + │ │348 + return .{ │ │349 + .items = items.items, │ │350 + // the page a window back, which is the first one when it holds the root │ │351 + .prev_id = if (first == 0) null else if (first <= Data.page_size) "" else try aa.dupe(u8, &keyIdHex(&keys.items[first - Data.page_size])), │ │352 + .next_id = if (last < keys.items.len) try aa.dupe(u8, &keyIdHex(&keys.items[last])) else null, │ │353 + .count = @min(keys.items.len, max_search_results), │ │354 + .more = keys.items.len > max_search_results, │ │355 + }; │ │356 +} │ │357 + │ │358 +// whether a search pass can stop: the count is capped, and the window rooted at │ │359 +// `root` and the key after it have been collected │ │360 +fn windowKnown(comptime Data: type, keys: []const OrderKey, root: []const u8) bool { │ │361 + if (keys.len <= max_search_results) return false; │ │362 + return std.mem.order(u8, &keys[keys.len - 1 - Data.page_size], root) != .lt; │ │363 +} │ │364 + │ │365 +// one list entry: the record `order_key` names, with its author, comments and │ │366 +// attachments. null when the record is gone. │ │367 +fn loadEntry( │ │368 + comptime Data: type, │ │369 + comptime hash_kind: hash.HashKind, │ │370 + arena: *std.heap.ArenaAllocator, │ │371 + admin_moment: ?evt.AdminDB.HashMap(.read_only), │ │372 + haxy_moment: evt.EventDB(hash_kind).HashMap(.read_only), │ │373 + records: evt.EventDB(hash_kind).HashMap(.read_only), │ │374 + order_key: *const OrderKey, │ │375 + conflict_set: ?evt.EventDB(hash_kind).SortedSet(.read_only), │ │376 + selected_id: []const u8, │ │377 + comments_start: usize, │ │378 +) !?Data.Entry { │ │379 + const DB = evt.EventDB(hash_kind); │ │380 + const aa = arena.allocator(); │ │381 + const id_hex = keyIdHex(order_key); │ │382 + const record_cursor = try records.getCursor(hash.hashInt(hash_kind, order_key[@sizeOf(u64)..])) orelse return null; │ │383 + const record = try evt.read(Data.Event.Record, DB, hash_kind, arena, try DB.HashMap(.read_only).init(record_cursor)); │ │384 + var entry: Data.Entry = .{ │ │385 + .id = try aa.dupe(u8, &id_hex), │ │386 + .record = record, │ │387 + .author = try ui.Author.initFromEmail(admin_moment, arena, record.author_email), │ │388 + .comments = try Comment.loadWindow( │ │389 + hash_kind, │ │390 + arena, │ │391 + admin_moment, │ │392 + haxy_moment, │ │393 + evt.Comment.thread_id_to_comment_id_set_key, │ │394 + &id_hex, │ │395 + if (std.mem.eql(u8, &id_hex, selected_id)) comments_start else 0, │ │396 + ), │ │397 + .attachments = try Attachment.load(hash_kind, arena, haxy_moment, &id_hex), │ │398 + }; │ │399 + if (comptime @hasField(Data.Entry, "conflicted")) { │ │400 + entry.conflicted = if (conflict_set) |conflicts| try conflicts.contains(order_key) else false; │ │401 + } │ │402 + return entry; │ │403 +} │ │404 + │ │405 +// a tab link's route, carrying the page's query when it has one │ │406 +pub fn searchRoute(route_maybe: ?ui.RoutablePage, search: ?[]const u8) !ui.RoutablePage { │ │407 + const route = route_maybe orelse return error.RouteTooLong; │ │408 + const query = search orelse return route; │ │409 + return route.withSearch(query) orelse error.RouteTooLong; │ │410 +} │ │411 + │ │412 +// a status tab's label: its listing's count, marked when a search capped it │ │413 +pub fn countLabel(buffer: []u8, name: []const u8, win: anytype) ![]const u8 { │ │414 + return std.fmt.bufPrint(buffer, "{s} ({d}{s})", .{ name, win.count, if (win.more) "+" else "" }); │ │415 +} │ │416 + │ │417 +// the common sub-header used by each thread page: the search box, then the │ │418 +// tab strip │ │419 pub const Header = struct { │ │420 box: wgt.Box(Widget), │ │421 // focus id -> semantic stack index; some pages omit unavailable tabs │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │422 tab_ids: std.AutoArrayHashMapUnmanaged(usize, usize), │ │423 + // the tab whose view the stack shows. focus lands on the search box too, │ │424 + // so the selection is remembered rather than read off the focus chain. │ │425 + selected_tab: ?usize = null, │ │426 │ │286 - pub fn init(allocator: std.mem.Allocator) !Header { │ │427 + // the search box's place in the box, ahead of the tabs │ │428 + const search_index: usize = 0; │ │429 + │ │430 + pub fn init(allocator: std.mem.Allocator, session: *ui.Session, search: ?[]const u8) !Header { │ │431 var box = try wgt.Box(Widget).init(allocator, .{ .border_style = null, .direction = .horiz }); │ │432 errdefer box.deinit(allocator); │ │433 + │ │434 + { │ │435 + var search_box = try widget.SearchBox.init(allocator, session, " search ", "search", search); │ │436 + errdefer search_box.deinit(allocator); │ │437 + box.getFocus().child_id = search_box.getFocus().id; │ │438 + try box.children.put(allocator, search_box.getFocus().id, .{ .widget = .{ .search_box = search_box }, .rect = null, .min_size = widget.SearchBox.min_size });│ │439 + } │ │440 + │ │441 return .{ .box = box, .tab_ids = .empty }; │ │442 } │ │443 │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────╮ │455 } │ │456 │ │457 pub fn select(self: *Header, view_index: usize) void { │ │306 - for (self.tab_ids.keys(), self.tab_ids.values()) |id, index| {│ │458 + for (self.tab_ids.values(), 0..) |index, tab| { │ │459 if (index == view_index) { │ │308 - self.getFocus().child_id = id; │ │460 + self.selected_tab = tab; │ │461 return; │ │462 } │ │463 } │ ╰───────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │470 │ │471 pub fn build(self: *Header, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void {│ │472 self.clearGrid(); │ │473 + // the search box takes focus without changing the view, so the tab it │ │474 + // was last on stays selected │ │475 + if (self.currentTabIndex()) |tab| self.selected_tab = tab; │ │476 // only the selected tab shows its border │ │477 + const selected_id = if (self.selectedTab()) |tab| self.tab_ids.keys()[tab] else null; │ │478 for (self.box.children.keys(), self.box.children.values()) |id, *child| { │ │479 switch (child.widget) { │ │324 - .text_box => |*tb| tb.options.border_style = if (self.getFocus().child_id == id) .single else .hidden, │ │480 + .text_box => |*tb| tb.options.border_style = if (selected_id == id) .single else .hidden, │ │481 else => {}, │ │482 } │ │483 } │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │485 } │ │486 │ │487 pub fn input(self: *Header, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void {│ │332 - _ = allocator; │ │488 + const search_box = self.searchBox(); │ │489 + if (self.box.getFocus().child_id == search_box.getFocus().id) { │ │490 + // tab, and right-arrow past the last character, step to the tabs │ │491 + if (key == .tab or (key == .arrow_right and search_box.atEnd())) { │ │492 + root_focus.setFocus(self.tab_ids.keys()[0]); │ │493 + return; │ │494 + } │ │495 + return search_box.input(allocator, key, root_focus); │ │496 + } │ │497 const current_tab = self.currentTabIndex() orelse return; │ │498 + if (key == .back_tab or (key == .arrow_left and current_tab == 0)) { │ │499 + // remembered here as well as in build, which may not run between keys │ │500 + self.selected_tab = current_tab; │ │501 + root_focus.setFocus(search_box.getFocus().id); │ │502 + return; │ │503 + } │ │504 if (inp.moveTab(key, current_tab, self.tab_ids.count())) |new_tab| { │ │505 root_focus.setFocus(self.tab_ids.keys()[new_tab]); │ │506 } │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────╮ │507 + } │ │508 + │ │509 + // the typed query when enter should submit it: the box holds focus. the │ │510 + // caller owns the returned text. │ │511 + pub fn submittedText(self: *Header, allocator: std.mem.Allocator) !?[]u8 { │ │512 + const search_box = self.searchBox(); │ │513 + if (self.box.getFocus().child_id != search_box.getFocus().id) return null;│ │514 + return try search_box.text(allocator); │ │515 + } │ │516 + │ │517 + fn searchBox(self: *Header) *widget.SearchBox { │ │518 + return &self.box.children.values()[search_index].widget.search_box; │ │519 } │ │520 │ │521 pub fn clearGrid(self: *Header) void { │ ╰───────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────╮ │531 } │ │532 │ │533 pub fn getSelectedIndex(self: Header) ?usize { │ │352 - const index = self.currentTabIndex() orelse return null;│ │353 - return self.tab_ids.values()[index]; │ │534 + const tab = self.selectedTab() orelse return null; │ │535 + return self.tab_ids.values()[tab]; │ │536 + } │ │537 + │ │538 + // the focused tab, or the one focus last left behind │ │539 + fn selectedTab(self: Header) ?usize { │ │540 + return self.currentTabIndex() orelse self.selected_tab; │ │541 } │ │542 │ │543 fn currentTabIndex(self: Header) ?usize { │ ╰─────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │680 return if (supports_conflicts) Data.resolveRoute(identity, selected, picks) else null; │ │681 } │ │682 │ │496 - fn listLink(page_arena: *std.heap.ArenaAllocator, identity: []const u8, status: Status, tag: []const u8, id: []const u8) ![]const u8 { │ │497 - const route = listRoute(identity, status, tag, id) orelse return error.RouteTooLong; │ │683 + // a list link keeps the filters the page carries: the tag, and the │ │684 + // search query when one is set │ │685 + fn listLink(page_arena: *std.heap.ArenaAllocator, identity: []const u8, status: Status, tag: []const u8, id: []const u8, search: []const u8) ![]const u8 {│ │686 + var route = listRoute(identity, status, tag, id) orelse return error.RouteTooLong; │ │687 + if (search.len != 0) route = route.withSearch(search) orelse return error.RouteTooLong; │ │688 return std.fmt.allocPrint(page_arena.allocator(), "a:{s}", .{try route.toUrl(page_arena)}); │ │689 } │ │690 │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │501 - fn tagLink(page_arena: *std.heap.ArenaAllocator, identity: []const u8, status: Status, tag: []const u8) ![]const u8 { │ │691 + fn tagLink(page_arena: *std.heap.ArenaAllocator, identity: []const u8, status: Status, tag: []const u8, search: []const u8) ![]const u8 { │ │692 const encoded = try ui.urlEncodeRef(page_arena.allocator(), tag); │ │503 - return listLink(page_arena, identity, status, encoded, ""); │ │693 + return listLink(page_arena, identity, status, encoded, "", search); │ │694 } │ │695 │ │696 fn addToolButton(allocator: std.mem.Allocator, row: *wgt.Box(Widget), label: []const u8, bottom_label: []const u8, action: []const u8) !void {│ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │813 var text_box = try wgt.TextBox.init(allocator, back_label, .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .none }); │ │814 errdefer text_box.deinit(allocator); │ │815 text_box.getFocus().mode = .all; │ │626 - text_box.getFocus().kind = .{ .custom = try listLink(self.session.page_arena, self.data.identity, entryStatus(entry), "", entry.id) }; │ │816 + text_box.getFocus().kind = .{ .custom = try listLink(self.session.page_arena, self.data.identity, entryStatus(entry), "", entry.id, self.data.search orelse "") };│ │817 try inner_box.children.put(allocator, text_box.getFocus().id, .{ .widget = .{ .text_box = text_box }, .rect = null, .min_size = null }); │ │818 self.title_id = text_box.getFocus().id; │ │819 } else { │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯