//! Query: the view is a query string in an address bar, such as //! `in:inbox`, `label:work is:unread`, or `lisbon`. Labels are just query //! terms: chips complete them while typing, `g` and a label's key rewrites //! the query, and rows show only the labels the query does not name. const std = @import("std"); const siv = @import("siv"); const kit = @import("kit.zig"); const sample = @import("sample.zig"); const Prototype = @import("Prototype.zig"); const Settings = @import("Settings.zig"); const Store = @import("Store.zig"); const ascii = std.ascii; const enums = std.enums; const math = std.math; const mem = std.mem; const Allocator = std.mem.Allocator; const ArrayList = std.ArrayList; const EnumSet = std.EnumSet; const Key = kit.Key; const Label = sample.Label; const Query = @This(); pub const prototype: Prototype = .{ .name = "query", .title = "Query", .idea = "The view is a query in an address bar; labels are query terms that chips complete as you type.", .build = build, .shots = &.{ .{ .name = "moved", .keys = &kit.keys("jj") }, .{ .name = "completing", .keys = &kit.keys("/label:w") }, .{ .name = "completed", .keys = &([_]Key{.named(.tab)} ++ kit.keys("is:unread")) }, .{ .name = "labeling", .keys = &([_]Key{.named(.@"return")} ++ kit.keys("l")) }, .{ .name = "labeled", .keys = &(kit.keys("f") ++ [_]Key{.named(.escape)}) }, .{ .name = "goto", .keys = &kit.keys("gv") }, .{ .name = "text", .keys = &(kit.keys("/lisbon") ++ [_]Key{.named(.@"return")}) }, .{ .name = "reading", .keys = &.{.named(.@"return")} }, }, }; /// What keys mean when the query field does not have focus. const Mode = enum { list, goto, label, reading }; const row_height = 58; const max_words = 8; const max_query = 256; /// A query parsed into what the Store and the row filter need. Terms are /// ANDed: every label named, every `is:` flag, every free word. const Parsed = struct { const Flag = enum { unread, read }; labels: Label.Set, flags: EnumSet(Flag), words: [max_words][]const u8, word_count: u32, /// The first term that names no label or flag; it matches nothing. unknown: []const u8, /// `editing` skips an unfinished label term at the end, so results do /// not go blank while a label's name is half typed. fn parse(query: []const u8, editing: bool) Parsed { var p: Parsed = .{ .labels = .empty, .flags = .empty, .words = undefined, .word_count = 0, .unknown = "" }; var it = mem.tokenizeScalar(u8, query, ' '); while (it.next()) |term| { const last = it.peek() == null and query[query.len - 1] != ' '; if (labelValue(term)) |value| { if (labelNamed(value)) |label| { p.labels.insert(label); } else if (!(editing and last)) p.miss(term); } else if (ascii.startsWithIgnoreCase(term, "is:")) { const value = term["is:".len..]; if (ascii.eqlIgnoreCase(value, "unread")) { p.flags.insert(.unread); } else if (ascii.eqlIgnoreCase(value, "read")) { p.flags.insert(.read); } else if (ascii.eqlIgnoreCase(value, "starred")) { p.labels.insert(.starred); } else if (!(editing and last)) p.miss(term); } else if (p.word_count < max_words) { p.words[p.word_count] = term; p.word_count += 1; } } return p; } fn miss(self: *Parsed, term: []const u8) void { if (self.unknown.len == 0) self.unknown = term; } /// The Store's filter: the first label and the first word; `keeps` /// checks the rest. fn filter(self: *const Parsed) Store.Filter { var it = self.labels.iterator(); return .{ .label = it.next(), .text = if (self.word_count > 0) self.words[0] else "" }; } fn keeps(self: *const Parsed, thread: *const Store.Thread) bool { if (self.unknown.len > 0) return false; if (!self.labels.subsetOf(thread.labels)) return false; if (self.flags.contains(.unread) and !thread.unread) return false; if (self.flags.contains(.read) and thread.unread) return false; for (self.words[0..self.word_count]) |word| { const found = ascii.findIgnoreCase(thread.from, word) != null or ascii.findIgnoreCase(thread.subject, word) != null or ascii.findIgnoreCase(thread.snippet, word) != null; if (!found) return false; } return true; } }; /// The name after `in:` or `label:`, if `term` is a label term. fn labelValue(term: []const u8) ?[]const u8 { for ([_][]const u8{ "in:", "label:" }) |prefix| { if (ascii.startsWithIgnoreCase(term, prefix)) return term[prefix.len..]; } return null; } /// The label a query names: its whole name, ignoring case. fn labelNamed(value: []const u8) ?Label { for (enums.values(Label)) |label| { if (ascii.eqlIgnoreCase(label.info().name, value)) return label; } return null; } /// Whether typing `prefix` could be heading for `label`: the start of its /// name, or of its last part ("hir" for Work/Hiring). fn completes(label: Label, prefix: []const u8) bool { const name = label.info().name; const leaf = if (mem.findScalarLast(u8, name, '/')) |i| name[i + 1 ..] else name; return ascii.startsWithIgnoreCase(name, prefix) or ascii.startsWithIgnoreCase(leaf, prefix); } /// The term `g` writes for a label: `in:` for system labels, as Gmail. fn termFor(buf: []u8, label: Label) []const u8 { const info = label.info(); const prefix = if (info.kind == .system) "in:" else "label:"; if (buf.len < prefix.len + info.name.len) return ""; @memcpy(buf[0..prefix.len], prefix); const name = ascii.lowerString(buf[prefix.len..][0..info.name.len], info.name); return buf[0 .. prefix.len + name.len]; } /// A completion chip's click: completes the label term with `label`. const ChipClick = struct { query: *Query, label: Label, fn onPointer(self: *ChipClick, event: *const siv.Event) !bool { if (event.phase != .up or event.button != .primary) return false; return self.query.complete(self.label); } }; arena: Allocator, window: siv.Window, store: *Store, mode: Mode, /// The mode Escape returns to from labeling: the list or the reader. label_from: Mode, /// Whether the query field has keyboard focus. editing: bool, query: []const u8, query_buf: [max_query]u8, /// The query before editing began, which Escape restores. saved: []const u8, saved_buf: [max_query]u8, parsed: Parsed, ids: []Store.Id, ids_buf: [Store.capacity]Store.Id, current: ?u32, /// The labels a `label:` or `in:` term being typed could become. candidates: Label.Set, field: siv.Node, counts: siv.Node, chips_row: siv.Node, chips: [enums.values(Label).len]siv.Node, list: siv.Node, empty: siv.Node, reader: siv.Node, reader_subject: siv.Node, reader_from: siv.Node, reader_body: siv.Node, reader_labels_slot: siv.Node, reader_labels: siv.Node, bar_slot: siv.Node, bar: siv.Node, fn build(arena: Allocator, window: siv.Window, store: *Store, _: ?Settings) anyerror!void { const self = try arena.create(Query); self.* = .{ .arena = arena, .window = window, .store = store, .mode = .list, .label_from = .list, .editing = false, .query = "", .query_buf = undefined, .saved = "", .saved_buf = undefined, .parsed = undefined, .ids = &.{}, .ids_buf = undefined, .current = 0, .candidates = .empty, .field = .null, .counts = .null, .chips_row = .null, .chips = undefined, .list = .null, .empty = .null, .reader = .null, .reader_subject = .null, .reader_from = .null, .reader_body = .null, .reader_labels_slot = .null, .reader_labels = .null, .bar_slot = .null, .bar = .null, }; self.setQuery("in:inbox"); self.save(); const txn = try siv.txnBegin(window); errdefer siv.txnAbort(txn); const root = siv.windowGetRoot(window); siv.setVisual(txn, root, .{ .background_token = .view_bg }); const column = siv.flex(txn, root, .{ .axis = .column, .align_items = .stretch }); siv.setLayout(txn, column, .{ .width = kit.whole, .height = kit.whole }); try self.buildTop(txn, column); const body = siv.stack(txn, column, .{}); siv.setLayout(txn, body, .{ .grow = 1, .basis = kit.px(0) }); self.list = siv.listView(txn, body, .{ .label = "Threads", .user_data = self, .bind = bindRow, .count = 0, .item_height = row_height, .selection = .single, }); siv.setLayout(txn, self.list, .{ .width = kit.whole, .height = kit.whole }); kit.on(Query, txn, self.list, .select, self, onSelect); kit.on(Query, txn, self.list, .activate, self, onActivate); self.empty = kit.text(txn, body, "", .{ .color_token = .muted_fg }); siv.setLayout(txn, self.empty, .{ .align_x = .center, .align_y = .center }); self.buildReader(txn, body); self.bar_slot = siv.flex(txn, column, .{ .axis = .column, .align_items = .stretch }); siv.setVisual(txn, self.bar_slot, .{ .background_token = .window_bg }); kit.keymap(Query, txn, root, self); try siv.txnCommit(txn); self.requery(); try self.refresh(); try siv.nodeFocus(self.list); } /// The address bar, and under it the completion chips, one per label, /// shown while a label term is being typed. fn buildTop(self: *Query, txn: siv.Txn, parent: siv.Node) !void { const top = siv.flex(txn, parent, .{ .axis = .column, .gap = 8, .align_items = .stretch }); siv.setLayout(txn, top, .{ .padding = .{ .start = 16, .end = 16, .top = 12, .bottom = 10 } }); siv.setVisual(txn, top, .{ .background_token = .window_bg }); const bar = siv.flex(txn, top, .{ .gap = 12, .align_items = .center }); self.field = siv.textField(txn, bar, .{ .text = self.query, .label = "Query", .placeholder = "in:inbox" }); siv.setLayout(txn, self.field, .{ .grow = 1, .shrink = 1, .basis = kit.px(0) }); kit.on(Query, txn, self.field, .change, self, onChange); kit.on(Query, txn, self.field, .focus, self, onFocus); self.counts = kit.text(txn, bar, "", .{ .size_role = .caption, .color_token = .muted_fg }); self.chips_row = siv.flex(txn, top, .{ .gap = 6, .align_items = .center, .flags = .{ .wrap = true } }); _ = kit.keycap(txn, self.chips_row, "Tab"); _ = kit.text(txn, self.chips_row, "completes", .{ .size_role = .caption, .color_token = .muted_fg }); for (enums.values(Label), &self.chips) |label, *chip| { chip.* = kit.chip(txn, self.chips_row, label); siv.setSemantics(txn, chip.*, .{ .role = .button, .label = label.info().name }); siv.setCursor(txn, chip.*, .pointer); const click = try self.arena.create(ChipClick); click.* = .{ .query = self, .label = label }; kit.on(ChipClick, txn, chip.*, .pointer, click, ChipClick.onPointer); } } fn buildReader(self: *Query, txn: siv.Txn, parent: siv.Node) void { self.reader = siv.flex(txn, parent, .{ .axis = .column, .gap = 10, .align_items = .start }); siv.setLayout(txn, self.reader, .{ .width = kit.whole, .height = kit.whole, .padding = .{ .start = 48, .end = 48, .top = 24, .bottom = 16 }, }); siv.setVisual(txn, self.reader, .{ .background_token = .view_bg }); const heading: siv.TextStyleDesc.Options = .{ .size_role = .heading, .weight = .bold, .color_token = .view_fg }; self.reader_subject = kit.text(txn, self.reader, "", heading); self.reader_from = kit.text(txn, self.reader, "", .{ .color_token = .muted_fg }); self.reader_labels_slot = siv.box(txn, self.reader); self.reader_body = kit.text(txn, self.reader, "", .{ .color_token = .view_fg }); siv.setLayout(txn, self.reader_body, .{ .max_width = 640 }); siv.setHidden(txn, self.reader, true); } pub fn onKey(self: *Query, key: Key) !bool { if (self.editing) return self.editKey(key); return switch (self.mode) { .list => self.listKey(key), .goto => self.gotoKey(key), .label => self.labelKey(key), .reading => self.readingKey(key), }; } /// In the query field; keys it declines type. fn editKey(self: *Query, key: Key) !bool { if (key.isNamed(.escape)) { self.setQuery(self.saved); try self.setField(); return self.finishEditing(); } if (key.isNamed(.@"return") or key.isNamed(.down)) return self.finishEditing(); if (key.isNamed(.tab)) { var it = self.candidates.iterator(); if (it.next()) |label| _ = try self.complete(label); return true; } return false; } fn listKey(self: *Query, key: Key) !bool { if (key.is('/')) return self.startEditing(); if (key.is('j') or key.isNamed(.down)) return self.move(1); if (key.is('k') or key.isNamed(.up)) return self.move(-1); if (key.isNamed(.@"return") or key.is('o')) return self.open(); if (key.is('g')) return self.setMode(.goto); if (key.is('l')) return self.startLabeling(); if (key.is('e')) return self.removeCurrent(.archive); if (key.is('#')) return self.removeCurrent(.trash); if (key.is('s')) return self.toggleOnCurrent(.starred); if (key.is('u')) return self.toggleUnread(); return false; } fn gotoKey(self: *Query, key: Key) !bool { if (key.isNamed(.escape)) return self.setMode(.list); const c = key.printable() orelse return true; const label = Label.fromKey(c) orelse return true; var buf: [64]u8 = undefined; self.setQuery(termFor(&buf, label)); self.save(); try self.setField(); self.mode = .list; self.current = 0; self.requery(); try self.refresh(); return true; } fn labelKey(self: *Query, key: Key) !bool { if (key.isNamed(.escape) or key.isNamed(.@"return")) { // The thread may have left the query; it stays in view while // labeling and goes once labeling ends. self.mode = self.label_from; self.requery(); try self.refresh(); return true; } const c = key.printable() orelse return true; const label = Label.fromKey(c) orelse return true; if (label.info().kind != .user) return true; return self.toggleOnCurrent(label); } fn readingKey(self: *Query, key: Key) !bool { if (key.isNamed(.escape) or key.is('q')) return self.setMode(.list); if (key.is('/')) { self.mode = .list; return self.startEditing(); } if (key.is('j')) return self.move(1); if (key.is('k')) return self.move(-1); if (key.is('e')) return self.removeCurrent(.archive); if (key.is('#')) return self.removeCurrent(.trash); if (key.is('l')) return self.startLabeling(); if (key.is('s')) return self.toggleOnCurrent(.starred); if (key.is('u')) return self.toggleUnread(); return true; } fn startEditing(self: *Query) !bool { // Like an address bar: the whole query is selected, so typing replaces // it and an arrow key keeps it. const txn = try siv.txnBegin(self.window); errdefer siv.txnAbort(txn); siv.setSelection(txn, self.field, @intCast(self.query.len), 0); try siv.txnCommit(txn); try siv.nodeFocus(self.field); return true; } fn finishEditing(self: *Query) !bool { try siv.nodeFocus(self.list); return true; } fn startLabeling(self: *Query) !bool { if (self.current == null) return true; self.label_from = self.mode; return self.setMode(.label); } fn open(self: *Query) !bool { if (self.current == null) return true; return self.setMode(.reading); } fn setMode(self: *Query, mode: Mode) !bool { self.mode = mode; try self.refresh(); return true; } fn move(self: *Query, delta: i32) !bool { const current = self.current orelse return true; const last: i32 = @intCast(self.ids.len - 1); self.current = @intCast(math.clamp(@as(i32, @intCast(current)) + delta, 0, last)); try self.refresh(); return true; } const Removal = enum { archive, trash }; fn removeCurrent(self: *Query, removal: Removal) !bool { const current = self.current orelse return true; const id = self.ids[current]; switch (removal) { .archive => self.store.archive(id), .trash => self.store.trash(id), } self.requery(); try self.refresh(); return true; } /// Starring requeries at once; a label toggled while labeling waits for /// labeling to end, so the thread does not vanish mid-way. fn toggleOnCurrent(self: *Query, label: Label) !bool { const current = self.current orelse return true; self.store.toggleLabel(self.ids[current], label); if (self.mode != .label) self.requery(); try self.refresh(); return true; } fn toggleUnread(self: *Query) !bool { const current = self.current orelse return true; const id = self.ids[current]; self.store.setUnread(id, !self.store.get(id).unread); self.requery(); try self.refresh(); return true; } /// Replaces the label term being typed with `label`'s whole name. fn complete(self: *Query, label: Label) !bool { const start = if (mem.findScalarLast(u8, self.query, ' ')) |i| i + 1 else 0; const term = self.query[start..]; const value = labelValue(term) orelse return true; const keyword = term[0 .. term.len - value.len]; var buf: [max_query]u8 = undefined; var name_buf: [64]u8 = undefined; const name = ascii.lowerString(&name_buf, label.info().name); const query = mem.print(&buf, "{s}{s}{s} ", .{ self.query[0..start], keyword, name }) catch return true; self.setQuery(query); try self.setField(); if (!self.editing) try siv.nodeFocus(self.field); self.current = 0; self.requery(); try self.refresh(); return true; } fn setQuery(self: *Query, query: []const u8) void { const n = @min(query.len, self.query_buf.len); @memmove(self.query_buf[0..n], query[0..n]); self.query = self.query_buf[0..n]; } fn save(self: *Query) void { @memcpy(self.saved_buf[0..self.query.len], self.query); self.saved = self.saved_buf[0..self.query.len]; } /// Writes the query into the field, the caret at its end. fn setField(self: *Query) !void { const txn = try siv.txnBegin(self.window); errdefer siv.txnAbort(txn); siv.setText(txn, self.field, self.query); const end: u32 = @intCast(self.query.len); siv.setSelection(txn, self.field, end, end); try siv.txnCommit(txn); } /// Parses the query and runs it, keeping the selection near where it was. fn requery(self: *Query) void { self.parsed = .parse(self.query, self.editing); var buf: [Store.capacity]Store.Id = undefined; var n: u32 = 0; for (self.store.query(self.parsed.filter(), &buf)) |id| { if (!self.parsed.keeps(self.store.get(id))) continue; self.ids_buf[n] = id; n += 1; } self.ids = self.ids_buf[0..n]; self.current = if (n == 0) null else @min(self.current orelse 0, n - 1); if (self.current == null and self.mode != .goto) self.mode = .list; self.candidates = self.completions(); } /// The labels the last term could complete to, while it is a label term /// being typed and not yet one label's whole name. fn completions(self: *const Query) Label.Set { if (!self.editing) return .empty; const start = if (mem.findScalarLast(u8, self.query, ' ')) |i| i + 1 else 0; const value = labelValue(self.query[start..]) orelse return .empty; var set: Label.Set = .empty; for (enums.values(Label)) |label| { if (completes(label, value)) set.insert(label); } if (set.count() == 1 and labelNamed(value) != null) return .empty; return set; } fn refresh(self: *Query) !void { if (self.mode == .reading) { if (self.current) |current| self.store.setUnread(self.ids[current], false); } const txn = try siv.txnBegin(self.window); errdefer siv.txnAbort(txn); siv.viewSetCount(txn, self.list, @intCast(self.ids.len)); siv.viewRefresh(txn, self.list); if (self.current) |current| { siv.viewSetSelected(txn, self.list, current, true); siv.viewScrollTo(txn, self.list, current); } var buf: [160]u8 = undefined; siv.setHidden(txn, self.empty, self.ids.len > 0); const empty = if (self.parsed.unknown.len > 0) mem.print(&buf, "Nothing matches: “{s}” is not a label or filter", .{self.parsed.unknown}) catch "" else "Nothing matches"; siv.setText(txn, self.empty, empty); var total: u32 = 0; var unread: u32 = 0; for (self.ids) |id| { total += 1; if (self.store.get(id).unread) unread += 1; } var counts_buf: [64]u8 = undefined; siv.setText(txn, self.counts, mem.print(&counts_buf, "{d} threads, {d} unread", .{ total, unread }) catch ""); self.showChips(txn); self.showReader(txn); try self.showBar(txn); try siv.txnCommit(txn); } fn showChips(self: *Query, txn: siv.Txn) void { siv.setHidden(txn, self.chips_row, self.candidates.count() == 0); var first = true; for (enums.values(Label), self.chips) |label, chip| { const shown = self.candidates.contains(label); siv.setHidden(txn, chip, !shown); // The first is what Tab takes: outline it. const color = label.info().color; siv.setVisual(txn, chip, .{ .background = kit.tint(color, 0.16), .border_color = if (shown and first) kit.color(color) else .{}, .border_width = if (shown and first) 1 else 0, .radius = kit.corners(9), }); if (shown) first = false; } } fn showReader(self: *Query, txn: siv.Txn) void { const reading = self.mode == .reading or (self.mode == .label and self.label_from == .reading); siv.setHidden(txn, self.reader, !reading or self.current == null); const current = self.current orelse return; const thread = self.store.get(self.ids[current]); siv.setText(txn, self.reader_subject, thread.subject); var buf: [128]u8 = undefined; const from = mem.print(&buf, "{s} · {d} messages", .{ thread.from, thread.messages }) catch thread.from; siv.setText(txn, self.reader_from, from); siv.setText(txn, self.reader_body, thread.snippet); if (self.reader_labels != .null) siv.remove(txn, self.reader_labels); self.reader_labels = siv.flex(txn, self.reader_labels_slot, .{ .gap = 6 }); var it = thread.labels.iterator(); while (it.next()) |label| { if (!self.parsed.labels.contains(label)) _ = kit.chip(txn, self.reader_labels, label); } } /// The bottom bar: the keys of what has focus now, every one clickable. fn showBar(self: *Query, txn: siv.Txn) !void { if (self.bar != .null) siv.remove(txn, self.bar); var hints: ArrayList(kit.Hint) = .empty; if (self.editing) { try hints.appendSlice(self.arena, &.{ .{ .key = .named(.@"return"), .cap = "Enter", .action = "done" }, .{ .key = .named(.escape), .cap = "Esc", .action = "undo changes" }, .{ .key = .named(.tab), .cap = "Tab", .action = "complete label" }, .{ .key = .named(.down), .cap = "↓", .action = "to the list" }, }); } else switch (self.mode) { .list => try hints.appendSlice(self.arena, &.{ .{ .key = .char('/'), .cap = "/", .action = "edit query" }, .{ .key = .char('j'), .cap = "j", .action = "next" }, .{ .key = .char('k'), .cap = "k", .action = "previous" }, .{ .key = .named(.@"return"), .cap = "Enter", .action = "open" }, .{ .key = .char('e'), .cap = "e", .action = "archive" }, .{ .key = .char('#'), .cap = "#", .action = "trash" }, .{ .key = .char('s'), .cap = "s", .action = "star" }, .{ .key = .char('u'), .cap = "u", .action = "unread" }, .{ .key = .char('l'), .cap = "l", .action = "label" }, .{ .key = .char('g'), .cap = "g", .action = "go to label" }, }), .reading => try hints.appendSlice(self.arena, &.{ .{ .key = .named(.escape), .cap = "Esc", .action = "back" }, .{ .key = .char('j'), .cap = "j", .action = "next thread" }, .{ .key = .char('k'), .cap = "k", .action = "previous thread" }, .{ .key = .char('e'), .cap = "e", .action = "archive" }, .{ .key = .char('s'), .cap = "s", .action = "star" }, .{ .key = .char('l'), .cap = "l", .action = "label" }, .{ .key = .char('/'), .cap = "/", .action = "edit query" }, }), .goto, .label => try self.labelHints(&hints), } self.bar = try kit.hintBar(Query, self.arena, txn, self.bar_slot, self, hints.items); if (self.editing) { const syntax = "in:inbox label:work is:unread is:starred words (all must match)"; const style: siv.TextStyleDesc.Options = .{ .size_role = .caption, .family = "monospace", .color_token = .muted_fg, }; _ = kit.text(txn, self.bar, syntax, style); } } fn labelHints(self: *Query, hints: *ArrayList(kit.Hint)) !void { const thread = if (self.current) |c| self.store.get(self.ids[c]) else null; for (enums.values(Label)) |label| { const info = label.info(); if (self.mode == .label and info.kind != .user) continue; const on = self.mode == .label and thread != null and thread.?.labels.contains(label); var buf: [64]u8 = undefined; const action = if (self.mode == .label) try self.arena.print("{s}{s}", .{ info.name, if (on) " ✓" else "" }) else try self.arena.dupe(u8, termFor(&buf, label)); try hints.append(self.arena, .{ .key = .char(info.key), .cap = label.keyCap(), .action = action }); } const done = if (self.mode == .label) "done" else "cancel"; try hints.append(self.arena, .{ .key = .named(.escape), .cap = "Esc", .action = done }); } fn onChange(self: *Query, event: *const siv.Event) !bool { const typed: []const u8 = if (event.text) |t| t[0..event.text_len] else ""; if (mem.eql(u8, typed, self.query)) return true; self.setQuery(typed); self.current = 0; self.requery(); try self.refresh(); return true; } fn onFocus(self: *Query, event: *const siv.Event) !bool { // Gaining focus saves the query Escape restores; losing it commits. self.editing = event.flags.gained; self.save(); if (self.editing) self.mode = .list; self.requery(); try self.refresh(); return false; } fn onSelect(self: *Query, event: *const siv.Event) !bool { if (event.count > 0 and event.index != self.current) { self.current = event.index; if (self.mode == .reading) try self.refresh(); } return true; } fn onActivate(self: *Query, event: *const siv.Event) !bool { self.current = event.index; return self.setMode(.reading); } fn bindRow( user_data: ?*anyopaque, txn: siv.Txn, row: siv.Node, item: u64, _: u32, _: u32, recycled: bool, ) callconv(.c) void { const self: *Query = @ptrCast(@alignCast(user_data)); if (!recycled) makeRow(txn, row); const thread = self.store.get(self.ids[@intCast(item)]); const content = siv.nodeGetRelated(row, .first_child); const top = siv.nodeGetRelated(content, .first_child); const unread_dot = siv.nodeGetRelated(top, .first_child); const from = siv.nodeGetRelated(unread_dot, .next); const chips = siv.nodeGetRelated(from, .next); const age = siv.nodeGetRelated(chips, .next); const bottom = siv.nodeGetRelated(top, .next); const dot_color: siv.ColorToken = if (thread.unread) .accent else .none; siv.setVisual(txn, unread_dot, .{ .background_token = dot_color, .radius = kit.corners(4) }); siv.setText(txn, from, thread.from); siv.setTextStyle(txn, from, .{ .weight = if (thread.unread) .bold else .regular, .color_token = .view_fg, .flags = kit.one_line, }); // One chip per label, made in label order: show the thread's labels // that the query does not already name. var chip = siv.nodeGetRelated(chips, .first_child); for (enums.values(Label)) |label| { siv.setHidden(txn, chip, !thread.labels.contains(label) or self.parsed.labels.contains(label)); chip = siv.nodeGetRelated(chip, .next); } var age_buf: [16]u8 = undefined; siv.setText(txn, age, kit.age(&age_buf, thread.minutes_ago)); var line_buf: [256]u8 = undefined; const line = mem.print(&line_buf, "{s} — {s}", .{ thread.subject, thread.snippet }) catch thread.subject; siv.setText(txn, bottom, line); } fn makeRow(txn: siv.Txn, row: siv.Node) void { const content = siv.flex(txn, row, .{ .axis = .column, .gap = 2, .justify = .center, .align_items = .stretch }); siv.setLayout(txn, content, .{ .width = kit.whole, .height = kit.whole, .padding = .{ .start = 16, .end = 24 } }); const top = siv.flex(txn, content, .{ .gap = 8, .align_items = .center }); const unread_dot = siv.box(txn, top); siv.setLayout(txn, unread_dot, .{ .width = kit.px(8), .height = kit.px(8) }); const from = kit.text(txn, top, "", .{}); siv.setLayout(txn, from, .{ .grow = 1, .shrink = 1, .basis = kit.px(0) }); const chips = siv.flex(txn, top, .{ .gap = 4, .align_items = .center }); for (enums.values(Label)) |label| _ = kit.chip(txn, chips, label); _ = kit.text(txn, top, "", .{ .size_role = .caption, .color_token = .muted_fg }); const bottom = kit.text(txn, content, "", .{ .color_token = .muted_fg, .flags = kit.one_line }); siv.setLayout(txn, bottom, .{ .padding = .{ .start = 16 } }); }