Something went wrong. Try again.
Something went wrong. Try again.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154//! List: one calm list, chosen by label chips. The title names what is//! shown; a filter bar of chips (AND) picks it, and a click on any row's//! chip adds that label as a filter. Ctrl+K or `:` opens a palette of//! every command with its keys, and one quiet line at the bottom says what//! is shown. Plain's look, Chips' filters, and Palette's commands.
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 EnumArray = std.EnumArray;const Io = std.Io;const Key = kit.Key;const Label = sample.Label;const log = std.log.scoped(.list);
const List = @This();
pub const prototype: Prototype = .{ .name = "list", .title = "List", .idea = "Plain's calm list, chosen by stacking label chips, with a Ctrl+K palette that teaches the keys.", .build = build, .shots = &.{ .{ .name = "moved", .keys = &kit.keys("jj") }, .{ .name = "palette", .keys = &.{.ctrl('k')} }, .{ .name = "palette-work", .keys = &kit.keys("work") }, .{ .name = "work", .keys = &.{.named(.@"return")} }, .{ .name = "second-filter", .keys = &(kit.keys("Lhir") ++ [_]Key{.named(.@"return")}) }, .{ .name = "filter-removed", .keys = &(kit.keys(":filter work") ++ [_]Key{.named(.@"return")}) }, .{ .name = "label-picker", .keys = &kit.keys("jl") }, .{ .name = "reading", .keys = &.{ .named(.escape), .named(.@"return") } }, .{ .name = "done", .keys = &kit.keys("e") }, },};
/// What the window shows under any overlay.const View = enum { list, reading };
/// What takes keys before the list: nothing, the palette, or a picker.const Overlay = enum { none, palette, filter_picker, label_picker };
const Action = enum { open, close, done, trash, star, unread, filter, unfilter, clear, toggle };
/// One thing the app can do; `label` matters for filter, unfilter, and toggle.const Command = struct { action: Action, label: Label,
fn of(action: Action) Command { return .{ .action = action, .label = .inbox }; }};
/// What clicking a label does, by where it is.const Spot = enum { /// A chip in a row or the reader: add it as a filter. chip, /// A filter chip in the header: remove it. filter, /// A line in a picker: add it as a filter, or toggle it on the thread. pick,};
/// Made once per spot and label, so rows can be rebound without allocating.const LabelClick = struct { owner: *List, spot: Spot, label: Label,
fn call(user_data: ?*anyopaque, _: siv.Node, event: *const siv.Event) callconv(.c) siv.EventResult { if (event.button != .primary) return .pass; if (event.phase == .down) return .handled; if (event.phase != .up) return .pass; const click: *LabelClick = @ptrCast(@alignCast(user_data)); click.owner.clicked(click.spot, click.label) catch |err| { log.err("label click: {t}", .{err}); return .pass; }; return .handled; }};
/// The nodes of one palette entry, made once and filled as the filter changes.const Entry = struct { node: siv.Node, dot: siv.Node, title: siv.Node, state: siv.Node, cap1_box: siv.Node, cap1: siv.Node, cap2_box: siv.Node, cap2: siv.Node,};
/// What a click on palette entry `index` runs.const EntryClick = struct { owner: *List, index: u32,
fn onPointer(self: *EntryClick, event: *const siv.Event) !bool { if (event.phase != .up or event.button != .primary) return false; const owner = self.owner; if (self.index >= owner.shown.len) return true; return owner.run(owner.shown[self.index]); }};
const row_height = 58;const entry_height = 36;const entries_shown = 10;const label_count = enums.values(Label).len;/// Back or open, four thread actions, clear, a filter or a remove per/// label, and at most a toggle per label.const max_commands = 6 + label_count * 2;const card_shadows = [_]siv.Shadow{ .{ .offset_y = 1, .blur = 3, .color_token = .shadow }, .{ .offset_y = 12, .blur = 32, .color_token = .shadow },};
arena: Allocator,window: siv.Window,store: *Store,view: View,overlay: Overlay,/// Active filters, in the order added; a thread must have every one.filters: ArrayList(Label),ids: []Store.Id,ids_buf: [Store.capacity]Store.Id,current: ?u32,/// What the last action did, shown in place of the counts until the next.notice: []const u8,notice_buf: [128]u8,clicks: EnumArray(Spot, EnumArray(Label, LabelClick)),/// "Remove filter Work": each filter chip's accessible name.remove_names: EnumArray(Label, []const u8),
/// The palette's filter text and the commands it matches.typed: []const u8,typed_buf: [128]u8,shown: []Command,shown_buf: [max_commands]Command,selected: u32,/// The first entry scrolled into view.top: u32,
/// The open picker's field text and highlighted line.pick_typed: []const u8,pick_typed_buf: [32]u8,pick_index: u32,
title: siv.Node,counts: siv.Node,filter_chips: siv.Node,list: siv.Node,empty: siv.Node,reader: siv.Node,reader_subject: siv.Node,reader_from: siv.Node,reader_chips_slot: siv.Node,reader_chips: siv.Node,reader_body: siv.Node,status_label: siv.Node,status_counts: siv.Node,overlay_node: siv.Node,palette_card: siv.Node,field: siv.Node,context: siv.Node,entries_scroll: siv.Node,nothing: siv.Node,entries: [max_commands]Entry,picker_card: siv.Node,picker_title: siv.Node,picker_field: siv.Node,picker_lines_slot: siv.Node,picker_lines: siv.Node,picker_hints_slot: siv.Node,picker_hints: siv.Node,
fn build(arena: Allocator, window: siv.Window, store: *Store, _: ?Settings) anyerror!void { const self = try arena.create(List); self.* = .{ .arena = arena, .window = window, .store = store, .view = .list, .overlay = .none, .filters = try .initCapacity(arena, label_count), .ids = &.{}, .ids_buf = undefined, .current = null, .notice = "", .notice_buf = undefined, .clicks = undefined, .remove_names = undefined, .typed = "", .typed_buf = undefined, .shown = &.{}, .shown_buf = undefined, .selected = 0, .top = 0, .pick_typed = "", .pick_typed_buf = undefined, .pick_index = 0, .title = .null, .counts = .null, .filter_chips = .null, .list = .null, .empty = .null, .reader = .null, .reader_subject = .null, .reader_from = .null, .reader_chips_slot = .null, .reader_chips = .null, .reader_body = .null, .status_label = .null, .status_counts = .null, .overlay_node = .null, .palette_card = .null, .field = .null, .context = .null, .entries_scroll = .null, .nothing = .null, .entries = undefined, .picker_card = .null, .picker_title = .null, .picker_field = .null, .picker_lines_slot = .null, .picker_lines = .null, .picker_hints_slot = .null, .picker_hints = .null, }; for (enums.values(Label)) |label| { for (enums.values(Spot)) |spot| { self.clicks.getPtr(spot).set(label, .{ .owner = self, .spot = spot, .label = label }); } self.remove_names.set(label, try arena.print("Remove filter {s}", .{label.info().name})); } self.filters.appendAssumeCapacity(.inbox); self.requery(); try self.buildTree();}
fn buildTree(self: *List) !void { const txn = try siv.txnBegin(self.window); errdefer siv.txnAbort(txn); const root = siv.windowGetRoot(self.window); siv.setVisual(txn, root, .{ .background_token = .view_bg }); const layers = siv.stack(txn, root, .{}); siv.setLayout(txn, layers, .{ .width = kit.whole, .height = kit.whole }); const column = siv.flex(txn, layers, .{ .axis = .column, .align_items = .stretch }); siv.setLayout(txn, column, .{ .width = kit.whole, .height = kit.whole }); try self.buildHeader(txn, column); try self.buildBody(txn, column); try self.buildStatus(txn, column); try self.buildOverlay(txn, layers); try self.refresh(txn); kit.keymap(List, txn, root, self); try siv.txnCommit(txn); try siv.nodeFocus(self.list);}
/// Plain's header: the large title and its counts, with the filter chips/// that choose it on the right.fn buildHeader(self: *List, txn: siv.Txn, column: siv.Node) !void { const header = siv.flex(txn, column, .{ .gap = 16, .align_items = .center }); siv.setLayout(txn, header, .{ .padding = .{ .start = 24, .end = 24, .top = 20, .bottom = 12 } }); const heading = siv.flex(txn, header, .{ .gap = 12, .align_items = .end }); siv.setLayout(txn, heading, .{ .grow = 1, .shrink = 1, .basis = kit.px(0) }); self.title = kit.text(txn, heading, "", .{ .size_role = .title, .weight = .bold, .color_token = .view_fg }); siv.setSemantics(txn, self.title, .{ .role = .heading, .level = 1 }); self.counts = kit.text(txn, heading, "", .{ .color_token = .muted_fg, .flags = kit.one_line }); self.filter_chips = siv.flex(txn, header, .{ .gap = 8, .align_items = .center }); const add = siv.flex(txn, header, .{ .gap = 6, .align_items = .center }); siv.setLayout(txn, add, .{ .padding = .{ .start = 10, .end = 8, .top = 3, .bottom = 3 } }); siv.setVisual(txn, add, .{ .border_token = .border, .border_width = 1, .radius = kit.corners(12) }); try kit.clickable(List, Key, kit.press(List), self.arena, txn, add, self, .char('L'), "Add filter"); _ = kit.text(txn, add, "+ Filter", .{ .size_role = .caption, .color_token = .muted_fg }); _ = kit.keycap(txn, add, "L");}
/// The list, and over it the reader.fn buildBody(self: *List, txn: siv.Txn, column: siv.Node) !void { 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 = @intCast(self.ids.len), .item_height = row_height, .selection = .single, }); siv.setLayout(txn, self.list, .{ .width = kit.whole, .height = kit.whole }); kit.on(List, txn, self.list, .select, self, onSelect); kit.on(List, txn, self.list, .activate, self, onActivate); kit.on(List, txn, self.list, .pointer, self, onListPointer); self.empty = kit.text(txn, body, "No thread has all of these labels", .{ .color_token = .muted_fg }); siv.setLayout(txn, self.empty, .{ .align_x = .center, .align_y = .center });
self.reader = siv.flex(txn, body, .{ .axis = .column, .gap = 10, .align_items = .start }); siv.setLayout(txn, self.reader, .{ .width = kit.whole, .height = kit.whole, .padding = .{ .start = 48, .end = 48, .top = 16, .bottom = 16 }, }); siv.setVisual(txn, self.reader, .{ .background_token = .view_bg }); const back = try kit.hintBar(List, self.arena, txn, self.reader, self, &.{ .{ .key = .named(.escape), .cap = "Esc", .action = "back to the list" }, }); siv.setLayout(txn, back, .{ .padding = .{ .bottom = 8 } }); 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_chips_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 });}
/// The one line of chrome: what is shown, its counts or the last action's/// notice, and the keys that open the palette, each one clickable.fn buildStatus(self: *List, txn: siv.Txn, column: siv.Node) !void { const status = siv.flex(txn, column, .{ .gap = 8, .align_items = .center }); siv.setLayout(txn, status, .{ .padding = .{ .start = 16, .end = 16, .top = 6, .bottom = 6 } }); siv.setVisual(txn, status, .{ .background_token = .window_bg }); const quiet: siv.TextStyleDesc.Options = .{ .size_role = .caption, .color_token = .muted_fg }; const strong: siv.TextStyleDesc.Options = .{ .size_role = .caption, .weight = .semibold, .flags = kit.one_line }; self.status_label = kit.text(txn, status, "", strong); self.status_counts = kit.text(txn, status, "", .{ .size_role = .caption, .color_token = .muted_fg, .flags = kit.one_line, }); siv.setLayout(txn, self.status_counts, .{ .grow = 1, .shrink = 1, .basis = kit.px(0) }); const ctrl_k = kit.keycap(txn, status, "Ctrl+K"); try kit.clickable(List, Key, kit.press(List), self.arena, txn, ctrl_k, self, .ctrl('k'), "Ctrl+K for commands"); _ = kit.text(txn, status, "or", quiet); const colon = kit.keycap(txn, status, ":"); try kit.clickable(List, Key, kit.press(List), self.arena, txn, colon, self, .char(':'), ": for commands"); _ = kit.text(txn, status, "for commands", quiet);}
/// A scrim over the whole window, and over it the palette or a picker.fn buildOverlay(self: *List, txn: siv.Txn, layers: siv.Node) !void { self.overlay_node = siv.stack(txn, layers, .{}); siv.setLayout(txn, self.overlay_node, .{ .width = kit.whole, .height = kit.whole }); const scrim = siv.box(txn, self.overlay_node); siv.setLayout(txn, scrim, .{ .width = kit.whole, .height = kit.whole }); // Its own compositor layer, as the list view has: without one, the // list draws over the scrim once an overlay is shown a second time. siv.setVisual(txn, scrim, .{ .background_token = .scrim, .flags = .{ .layer = true } }); siv.setSemantics(txn, scrim, .{ .role = .button, .label = "Close" }); kit.on(List, txn, scrim, .pointer, self, onScrim); try self.buildPalette(txn); self.buildPicker(txn);}
/// The palette card: the filter field and every entry, made once;/// filtering only fills and hides rows.fn buildPalette(self: *List, txn: siv.Txn) !void { const card = siv.flex(txn, self.overlay_node, .{ .axis = .column, .gap = 8, .align_items = .stretch }); self.palette_card = card; siv.setLayout(txn, card, .{ .width = kit.px(580), .align_x = .center, .align_y = .center, .padding = kit.edges(12), }); siv.setVisual(txn, card, .{ .background_token = .card_bg, .radius = kit.corners(12), .shadows = &card_shadows }); self.field = siv.textField(txn, card, .{ .placeholder = "Type a command or a label", .label = "Command" }); kit.on(List, txn, self.field, .change, self, onPaletteTyped); self.context = kit.text(txn, card, "", .{ .size_role = .caption, .color_token = .muted_fg, .flags = kit.one_line }); siv.setLayout(txn, self.context, .{ .padding = .{ .start = 4 } });
// A fixed height, so the card stays put while the filter narrows it. self.entries_scroll = siv.scroll(txn, card, .vertical); siv.setLayout(txn, self.entries_scroll, .{ .height = kit.px(entry_height * entries_shown) }); const rows = siv.flex(txn, self.entries_scroll, .{ .axis = .column, .align_items = .stretch }); self.nothing = kit.text(txn, rows, "No command matches", .{ .color_token = .muted_fg }); siv.setLayout(txn, self.nothing, .{ .padding = kit.edges(12) }); for (&self.entries, 0..) |*entry, i| { entry.* = makeEntry(txn, rows); const click = try self.arena.create(EntryClick); click.* = .{ .owner = self, .index = @intCast(i) }; kit.on(EntryClick, txn, entry.node, .pointer, click, EntryClick.onPointer); } _ = try kit.hintBar(List, self.arena, txn, card, self, &.{ .{ .key = .named(.down), .cap = "↓", .action = "next" }, .{ .key = .named(.up), .cap = "↑", .action = "previous" }, .{ .key = .named(.@"return"), .cap = "Enter", .action = "run" }, .{ .key = .named(.escape), .cap = "Esc", .action = "close" }, });}
fn makeEntry(txn: siv.Txn, parent: siv.Node) Entry { const node = siv.flex(txn, parent, .{ .gap = 10, .align_items = .center }); siv.setLayout(txn, node, .{ .height = kit.px(entry_height), .padding = .{ .start = 12, .end = 12 } }); siv.setCursor(txn, node, .pointer); const dot = siv.box(txn, node); siv.setLayout(txn, dot, .{ .width = kit.px(8), .height = kit.px(8) }); const title = kit.text(txn, node, "", .{ .color_token = .card_fg, .flags = kit.one_line }); siv.setLayout(txn, title, .{ .grow = 1, .shrink = 1, .basis = kit.px(0) }); const state = kit.text(txn, node, "", .{ .size_role = .caption, .color_token = .muted_fg }); const cap1_box = kit.keycap(txn, node, ""); const cap2_box = kit.keycap(txn, node, ""); return .{ .node = node, .dot = dot, .title = title, .state = state, .cap1_box = cap1_box, .cap1 = siv.nodeGetRelated(cap1_box, .first_child), .cap2_box = cap2_box, .cap2 = siv.nodeGetRelated(cap2_box, .first_child), };}
/// Chips' picker card, near the top: a field, the labels, and its keys.fn buildPicker(self: *List, txn: siv.Txn) void { const card = siv.flex(txn, self.overlay_node, .{ .axis = .column, .gap = 10, .align_items = .stretch }); self.picker_card = card; siv.setLayout(txn, card, .{ .width = kit.px(480), .align_x = .center, .align_y = .start, .padding = kit.edges(18), .inset_top = 72, .flags = .{ .inset_top = true }, }); siv.setVisual(txn, card, .{ .background_token = .card_bg, .radius = kit.corners(12), .shadows = &card_shadows }); self.picker_title = kit.text(txn, card, "", .{ .weight = .bold, .color_token = .card_fg, .flags = kit.one_line }); self.picker_field = siv.textField(txn, card, .{ .label = "Label name" }); kit.on(List, txn, self.picker_field, .change, self, onPickerTyped); self.picker_lines_slot = siv.box(txn, card); self.picker_hints_slot = siv.box(txn, card);}
pub fn onKey(self: *List, key: Key) !bool { return switch (self.overlay) { .none => self.directKey(key), .palette => self.paletteKey(key), .filter_picker => self.filterPickerKey(key), .label_picker => self.labelPickerKey(key), };}
/// The keys that work without the palette, each the same as an entry.fn directKey(self: *List, key: Key) !bool { if (key.isCtrl('k') or key.is(':')) return self.openPalette(); if (self.view == .reading and (key.isNamed(.escape) or key.is('q'))) return self.run(.of(.close)); 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.run(.of(.open)); if (key.is('e')) return self.run(.of(.done)); if (key.is('#')) return self.run(.of(.trash)); if (key.is('s')) return self.run(.of(.star)); if (key.is('u')) return self.run(.of(.unread)); if (key.is('l')) return self.openPicker(.label_picker); if (key.is('L')) return self.openPicker(.filter_picker); if (key.isNamed(.backspace) and key.mods.shift) return self.run(.of(.clear)); if (key.isNamed(.backspace)) { const last = self.filters.last() orelse return true; return self.run(.{ .action = .unfilter, .label = last }); } return false;}
/// While the palette is open; keys it declines type into the filter.fn paletteKey(self: *List, key: Key) !bool { if (key.isNamed(.escape) or key.isCtrl('k')) return self.closeOverlay(); if (key.isNamed(.down) or key.isCtrl('n')) return self.movePalette(1); if (key.isNamed(.up) or key.isCtrl('p')) return self.movePalette(-1); if (key.isNamed(.@"return")) { if (self.shown.len == 0) return true; return self.run(self.shown[self.selected]); } return false;}
/// The field has focus and takes typing; arrows, Enter, and Escape steer.fn filterPickerKey(self: *List, key: Key) !bool { if (key.isNamed(.escape)) return self.closeOverlay(); if (key.isNamed(.down)) return self.movePick(1); if (key.isNamed(.up)) return self.movePick(-1); if (key.isNamed(.@"return")) { var buf: [label_count]Label = undefined; const lines = self.pickerLines(&buf); if (lines.len == 0) return true; return self.run(.{ .action = .filter, .label = lines[@min(self.pick_index, lines.len - 1)] }); } return false;}
/// A label's key toggles it; `/` moves to the field to filter by name.fn labelPickerKey(self: *List, key: Key) !bool { if (key.isNamed(.escape) or key.isNamed(.@"return")) return self.closeOverlay(); if (key.isNamed(.down)) return self.movePick(1); if (key.isNamed(.up)) return self.movePick(-1); var buf: [label_count]Label = undefined; const lines = self.pickerLines(&buf); if (key.isNamed(.space)) { if (lines.len == 0) return true; return self.pickToggle(lines[@min(self.pick_index, lines.len - 1)]); } if (kit.focusWithin(self.window, self.picker_field)) return false; if (key.is('j')) return self.movePick(1); if (key.is('k')) return self.movePick(-1); if (key.is('/')) { try siv.nodeFocus(self.picker_field); return true; } const c = key.printable() orelse return false; const label = Label.fromKey(c) orelse return true; if (label.info().kind != .user) return true; return self.pickToggle(label);}
fn clicked(self: *List, spot: Spot, label: Label) !void { _ = switch (spot) { .chip => try self.run(.{ .action = .filter, .label = label }), .filter => try self.run(.{ .action = .unfilter, .label = label }), .pick => if (self.overlay == .label_picker) try self.pickToggle(label) else try self.run(.{ .action = .filter, .label = label }), };}
/// Does what a command says, closes any overlay, and shows the result./// Every key, entry, and click that changes mail or filters ends up here.fn run(self: *List, command: Command) !bool { self.overlay = .none; self.notice = ""; switch (command.action) { .filter => { if (mem.findScalar(Label, self.filters.items, command.label) == null) { self.filters.appendAssumeCapacity(command.label); } self.view = .list; self.current = 0; }, .unfilter => { if (mem.findScalar(Label, self.filters.items, command.label)) |i| _ = self.filters.orderedRemove(i); self.view = .list; }, .clear => { self.filters.clearRetainingCapacity(); self.view = .list; }, .close => self.view = .list, .open, .done, .trash, .star, .unread, .toggle => self.onThread(command), } self.requery(); return self.commit();}
fn onThread(self: *List, command: Command) void { const current = self.current orelse return; const id = self.ids[current]; const thread = self.store.get(id); switch (command.action) { .open => { self.view = .reading; self.store.setUnread(id, false); }, .done => { self.store.archive(id); self.say("Done: “{s}”", .{thread.subject}); }, .trash => { self.store.trash(id); self.say("Moved “{s}” to Trash", .{thread.subject}); }, .star => { self.store.toggleLabel(id, .starred); const verb = if (thread.labels.contains(.starred)) "Starred" else "Unstarred"; self.say("{s} “{s}”", .{ verb, thread.subject }); }, .unread => { self.store.setUnread(id, !thread.unread); self.say("Marked “{s}” {s}", .{ thread.subject, if (thread.unread) "unread" else "read" }); }, .toggle => self.toggleLabel(command.label), .close, .filter, .unfilter, .clear => {}, }}
fn toggleLabel(self: *List, label: Label) void { const current = self.current orelse return; const id = self.ids[current]; self.store.toggleLabel(id, label); const thread = self.store.get(id); const name = label.info().name; if (thread.labels.contains(label)) self.say("Added {s} to “{s}”", .{ name, thread.subject }) else self.say("Removed {s} from “{s}”", .{ name, thread.subject });}
/// In the checklist the thread stays put until it closes, so the list does/// not jump to another thread under it.fn pickToggle(self: *List, label: Label) !bool { self.toggleLabel(label); return self.commit();}
fn say(self: *List, comptime fmt: []const u8, args: anytype) void { self.notice = mem.print(&self.notice_buf, fmt, args) catch "";}
fn openPalette(self: *List) !bool { self.overlay = .palette; self.typed = ""; self.selected = 0; self.top = 0; const txn = try siv.txnBegin(self.window); errdefer siv.txnAbort(txn); siv.setText(txn, self.field, ""); try siv.txnCommit(txn); return self.commit();}
fn openPicker(self: *List, overlay: Overlay) !bool { if (overlay == .label_picker and self.current == null) return true; self.overlay = overlay; self.pick_typed = ""; self.pick_index = 0; const labeling = overlay == .label_picker; const txn = try siv.txnBegin(self.window); errdefer siv.txnAbort(txn); siv.setText(txn, self.picker_field, ""); siv.setPlaceholder(txn, self.picker_field, if (labeling) "Press / to filter by name" else "Type a label name"); if (self.picker_hints != .null) siv.remove(txn, self.picker_hints); self.picker_hints = try kit.hintBar(List, self.arena, txn, self.picker_hints_slot, self, if (labeling) &.{ .{ .key = .named(.down), .cap = "↓", .action = "next" }, .{ .key = .named(.up), .cap = "↑", .action = "previous" }, .{ .key = .named(.space), .cap = "Space", .action = "toggle" }, .{ .key = .char('/'), .cap = "/", .action = "filter" }, .{ .key = .named(.escape), .cap = "Esc", .action = "done" }, } else &.{ .{ .key = .named(.down), .cap = "↓", .action = "next" }, .{ .key = .named(.up), .cap = "↑", .action = "previous" }, .{ .key = .named(.@"return"), .cap = "Enter", .action = "add filter" }, .{ .key = .named(.escape), .cap = "Esc", .action = "close" }, }); siv.setLayout(txn, self.picker_hints, .{ .padding = .{ .top = 4 } }); try siv.txnCommit(txn); return self.commit();}
/// Closes the palette or a picker; a label toggled in the checklist may/// take its thread out of the list only now.fn closeOverlay(self: *List) !bool { self.overlay = .none; self.requery(); return self.commit();}
fn move(self: *List, 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)); if (self.view == .reading) self.store.setUnread(self.ids[self.current.?], false); self.notice = ""; return self.commit();}
fn movePalette(self: *List, delta: i32) !bool { if (self.shown.len == 0) return true; const last: i32 = @intCast(self.shown.len - 1); self.selected = @intCast(math.clamp(@as(i32, @intCast(self.selected)) + delta, 0, last)); const txn = try siv.txnBegin(self.window); errdefer siv.txnAbort(txn); self.syncPalette(txn); try siv.txnCommit(txn); return true;}
fn movePick(self: *List, delta: i32) !bool { var buf: [label_count]Label = undefined; const lines = self.pickerLines(&buf); if (lines.len == 0) return true; const last: i32 = @intCast(lines.len - 1); self.pick_index = @intCast(math.clamp(@as(i32, @intCast(self.pick_index)) + delta, 0, last)); return self.commit();}
/// Threads with every filter's label; no filter is All Mail.fn requery(self: *List) void { const shown = self.currentId(); const filters = self.filters.items; const first: ?Label = if (filters.len > 0) filters[0] else null; const all = self.store.query(.{ .label = first }, &self.ids_buf); var n: u32 = 0; for (all) |id| { const keep = for (filters) |label| { if (!self.store.has(id, label)) break false; } else true; if (!keep) 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) self.view = .list; // The reader moved on to the next thread, as after Done: it is read now. const next = self.currentId(); if (self.view == .reading and next != shown) self.store.setUnread(next.?, false);}
fn currentId(self: *const List) ?Store.Id { const current = self.current orelse return null; return self.ids[current];}
/// Every command that applies now, in the order the palette lists them:/// the thread's actions, the filters to add and remove, then a toggle per/// user label.fn collect(self: *const List, out: *[max_commands]Command) []Command { var list: ArrayList(Command) = .initBuffer(out); if (self.view == .reading) list.appendAssumeCapacity(.of(.close)); if (self.current != null) { if (self.view == .list) list.appendAssumeCapacity(.of(.open)); for ([_]Action{ .done, .trash, .star, .unread }) |action| list.appendAssumeCapacity(.of(action)); } for (enums.values(Label)) |label| { if (mem.findScalar(Label, self.filters.items, label) != null) continue; list.appendAssumeCapacity(.{ .action = .filter, .label = label }); } for (self.filters.items) |label| list.appendAssumeCapacity(.{ .action = .unfilter, .label = label }); if (self.filters.items.len > 0) list.appendAssumeCapacity(.of(.clear)); if (self.current != null) { for (enums.values(Label)) |label| { if (label.info().kind == .user) list.appendAssumeCapacity(.{ .action = .toggle, .label = label }); } } return list.items;}
/// Keeps the commands whose title holds the typed text, ignoring case.fn filterCommands(self: *List) void { var all_buf: [max_commands]Command = undefined; var n: u32 = 0; for (self.collect(&all_buf)) |command| { var buf: [64]u8 = undefined; if (ascii.findIgnoreCase(self.commandTitle(command, &buf), self.typed) == null) continue; self.shown_buf[n] = command; n += 1; } self.shown = self.shown_buf[0..n]; self.selected = @min(self.selected, n -| 1);}
fn commandTitle(self: *const List, command: Command, buf: []u8) []const u8 { const thread = if (self.current) |c| self.store.get(self.ids[c]) else null; const name = command.label.info().name; return switch (command.action) { .open => "Open", .close => "Back to the list", .done => "Done", .trash => "Trash", .star => if (thread != null and thread.?.labels.contains(.starred)) "Unstar" else "Star", .unread => if (thread != null and thread.?.unread) "Mark read" else "Mark unread", .filter => mem.print(buf, "Filter by {s}", .{name}) catch "Filter by", .unfilter => mem.print(buf, "Remove filter {s}", .{name}) catch "Remove filter", .clear => "Clear filters", .toggle => mem.print(buf, "Toggle label {s}", .{name}) catch "Toggle label", };}
/// The keys that do the same thing without the palette: none, one, or two/// caps. Removing a filter has a key only for the last one added.fn caps(self: *const List, command: Command) [2][]const u8 { return switch (command.action) { .open => .{ "Enter", "" }, .close => .{ "Esc", "" }, .done => .{ "e", "" }, .trash => .{ "#", "" }, .star => .{ "s", "" }, .unread => .{ "u", "" }, .filter => .{ "L", "" }, .unfilter => .{ if (self.filters.last() == command.label) "Backspace" else "", "" }, .clear => .{ "Shift+Backspace", "" }, .toggle => .{ "l", command.label.keyCap() }, };}
/// The open picker's lines: labels to add as filters, or the user's labels.fn pickerLines(self: *const List, out: *[label_count]Label) []Label { var n: u32 = 0; for (enums.values(Label)) |label| { if (self.overlay == .label_picker and label.info().kind != .user) continue; if (self.overlay == .filter_picker and mem.findScalar(Label, self.filters.items, label) != null) continue; if (ascii.findIgnoreCase(label.info().name, self.pick_typed) == null) continue; out[n] = label; n += 1; } return out[0..n];}
/// "Inbox + Work", or All Mail with no filter.fn shownName(self: *const List, buf: []u8) []const u8 { if (self.filters.items.len == 0) return "All Mail"; var w: Io.Writer = .fixed(buf); for (self.filters.items, 0..) |label, i| { if (i > 0) w.writeAll(" + ") catch break; w.writeAll(label.info().name) catch break; } return w.buffered();}
/// "13 threads, 7 unread", of what is shown.fn countsText(self: *const List, buf: []u8) []const u8 { var unread: u32 = 0; for (self.ids) |id| { if (self.store.get(id).unread) unread += 1; } const total = self.ids.len; return mem.print(buf, "{d} thread{s}, {d} unread", .{ total, if (total == 1) "" else "s", unread }) catch "";}
fn commit(self: *List) !bool { const txn = try siv.txnBegin(self.window); errdefer siv.txnAbort(txn); try self.refresh(txn); try siv.txnCommit(txn); switch (self.overlay) { .none => if (kit.focusWithin(self.window, self.overlay_node)) try siv.nodeFocus(self.list), .palette => if (!kit.focusWithin(self.window, self.field)) try siv.nodeFocus(self.field), .filter_picker => if (!kit.focusWithin(self.window, self.picker_field)) try siv.nodeFocus(self.picker_field), .label_picker => if (!kit.focusWithin(self.window, self.picker_field)) try siv.nodeFocus(self.list), } return true;}
/// Redraws everything from the state.fn refresh(self: *List, txn: siv.Txn) !void { 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); } siv.setHidden(txn, self.empty, self.ids.len > 0); var name_buf: [160]u8 = undefined; var counts_buf: [64]u8 = undefined; const name = self.shownName(&name_buf); const counts = self.countsText(&counts_buf); siv.setText(txn, self.title, name); siv.setText(txn, self.counts, counts); siv.setText(txn, self.status_label, name); siv.setText(txn, self.status_counts, if (self.notice.len > 0) self.notice else counts); self.showFilters(txn); self.showReader(txn); siv.setHidden(txn, self.overlay_node, self.overlay == .none); siv.setHidden(txn, self.palette_card, self.overlay != .palette); siv.setHidden(txn, self.picker_card, self.overlay != .filter_picker and self.overlay != .label_picker); if (self.overlay == .palette) { self.filterCommands(); self.syncPalette(txn); } try self.showPicker(txn);}
/// The header's filter chips, each one removed by a click.fn showFilters(self: *List, txn: siv.Txn) void { removeChildren(txn, self.filter_chips); for (self.filters.items) |label| { const info = label.info(); const box = siv.flex(txn, self.filter_chips, .{ .gap = 6, .align_items = .center }); siv.setLayout(txn, box, .{ .padding = .{ .start = 10, .end = 8, .top = 3, .bottom = 3 } }); siv.setVisual(txn, box, .{ .background = kit.tint(info.color, 0.16), .radius = kit.corners(12) }); siv.setSemantics(txn, box, .{ .role = .button, .label = self.remove_names.get(label) }); siv.setCursor(txn, box, .pointer); siv.on(txn, box, .pointer, LabelClick.call, null, self.clicks.getPtr(.filter).getPtr(label)); _ = kit.text(txn, box, info.name, .{ .weight = .medium, .color = kit.color(info.color) }); _ = kit.text(txn, box, "✕", .{ .size_role = .caption, .color = kit.color(info.color) }); }}
/// The thread's chips, each one a filter when clicked: its user labels and/// Starred, without those already filtered when `skip_filtered`.fn addChips(self: *List, txn: siv.Txn, parent: siv.Node, thread: *const sample.Thread, skip_filtered: bool) void { var it = thread.labels.iterator(); while (it.next()) |label| { if (label.info().kind != .user and label != .starred) continue; if (skip_filtered and mem.findScalar(Label, self.filters.items, label) != null) continue; const chip = kit.chip(txn, parent, label); siv.setSemantics(txn, chip, .{ .role = .button, .label = label.info().name }); siv.setCursor(txn, chip, .pointer); siv.on(txn, chip, .pointer, LabelClick.call, null, self.clicks.getPtr(.chip).getPtr(label)); }}
fn showReader(self: *List, txn: siv.Txn) void { siv.setHidden(txn, self.reader, self.view != .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 age_buf: [16]u8 = undefined; var from_buf: [128]u8 = undefined; const from = mem.print(&from_buf, "{s} · {s} ago · {d} message{s}", .{ thread.from, kit.age(&age_buf, thread.minutes_ago), thread.messages, if (thread.messages == 1) "" else "s", }) catch thread.from; siv.setText(txn, self.reader_from, from); siv.setText(txn, self.reader_body, thread.snippet); if (self.reader_chips != .null) siv.remove(txn, self.reader_chips); self.reader_chips = siv.flex(txn, self.reader_chips_slot, .{ .gap = 6 }); self.addChips(txn, self.reader_chips, thread, false);}
fn syncPalette(self: *List, txn: siv.Txn) void { if (self.selected < self.top) self.top = self.selected; if (self.selected >= self.top + entries_shown) self.top = self.selected + 1 - entries_shown; siv.scrollTo(txn, self.entries_scroll, 0, @floatFromInt(self.top * entry_height)); siv.setHidden(txn, self.nothing, self.shown.len > 0); const thread = if (self.current) |c| self.store.get(self.ids[c]) else null; var context_buf: [160]u8 = undefined; const context = if (thread) |t| mem.print(&context_buf, "On “{s}” from {s}", .{ t.subject, t.from }) catch "" else "No thread selected"; siv.setText(txn, self.context, context); for (&self.entries, 0..) |*entry, i| { siv.setHidden(txn, entry.node, i >= self.shown.len); if (i >= self.shown.len) continue; const command = self.shown[i]; const on = command.action == .toggle and thread != null and thread.?.labels.contains(command.label); self.syncEntry(txn, entry, command, i == self.selected, on); }}
fn syncEntry(self: *const List, txn: siv.Txn, entry: *const Entry, command: Command, selected: bool, on: bool) void { siv.setVisual(txn, entry.node, .{ .background_token = if (selected) .selection_bg else .none, .radius = kit.corners(6), }); var buf: [64]u8 = undefined; const title = self.commandTitle(command, &buf); siv.setText(txn, entry.title, title); siv.setSemantics(txn, entry.node, .{ .role = .button, .label = title }); siv.setTextStyle(txn, entry.title, .{ .color_token = if (selected) .selection_fg else .card_fg, .flags = kit.one_line, }); const labeled = command.action == .filter or command.action == .unfilter or command.action == .toggle; const dot_color = if (labeled) kit.color(command.label.info().color) else siv.Color{}; siv.setVisual(txn, entry.dot, .{ .background = dot_color, .radius = kit.corners(4) }); const state = if (command.action != .toggle) "" else if (on) "✓ on" else "off"; siv.setText(txn, entry.state, state); siv.setTextStyle(txn, entry.state, .{ .size_role = .caption, .color_token = if (selected) .selection_fg else .muted_fg, }); const key = self.caps(command); siv.setText(txn, entry.cap1, key[0]); siv.setHidden(txn, entry.cap1_box, key[0].len == 0); siv.setText(txn, entry.cap2, key[1]); siv.setHidden(txn, entry.cap2_box, key[1].len == 0);}
fn showPicker(self: *List, txn: siv.Txn) !void { const labeling = self.overlay == .label_picker; if (!labeling and self.overlay != .filter_picker) return; const thread = if (self.current) |c| self.store.get(self.ids[c]) else null; var title_buf: [128]u8 = undefined; const subject = if (thread) |t| t.subject else ""; siv.setText(txn, self.picker_title, if (labeling) mem.print(&title_buf, "Labels on “{s}”", .{subject}) catch subject else "Add a filter"); if (self.picker_lines != .null) siv.remove(txn, self.picker_lines); self.picker_lines = siv.flex(txn, self.picker_lines_slot, .{ .axis = .column, .align_items = .stretch }); var buf: [label_count]Label = undefined; const lines = self.pickerLines(&buf); if (lines.len == 0) _ = kit.text(txn, self.picker_lines, "No label matches", .{ .color_token = .muted_fg }); for (lines, 0..) |label, i| { const nested = label == .hiring and i > 0 and lines[i - 1] == .work; const on = if (thread) |t| t.labels.contains(label) else false; try self.pickerLine(txn, label, .{ .highlighted = i == @min(self.pick_index, lines.len - 1), .nested = nested, .check = if (labeling) on else null, }); }}
const LineLook = struct { highlighted: bool, /// Work/Hiring right under Work: indented, without the parent's name. nested: bool, /// The label picker's checkbox; null in the filter picker. check: ?bool,};
fn pickerLine(self: *List, txn: siv.Txn, label: Label, look: LineLook) !void { const info = label.info(); const line = siv.flex(txn, self.picker_lines, .{ .gap = 10, .align_items = .center }); const indent: f32 = if (look.nested) 34 else 10; siv.setLayout(txn, line, .{ .padding = .{ .start = indent, .end = 10, .top = 6, .bottom = 6 } }); siv.setVisual(txn, line, .{ .background_token = if (look.highlighted) .selection_bg else .none, .radius = kit.corners(6), }); siv.setSemantics(txn, line, .{ .role = if (look.check != null) .checkbox else .button, .label = info.name, .checked = if (look.check) |on| (if (on) .on else .off) else .unset, }); siv.setCursor(txn, line, .pointer); siv.on(txn, line, .pointer, LabelClick.call, null, self.clicks.getPtr(.pick).getPtr(label)); if (look.check) |on| { const box = siv.flex(txn, line, .{ .justify = .center, .align_items = .center }); siv.setLayout(txn, box, .{ .width = kit.px(16), .height = kit.px(16) }); siv.setVisual(txn, box, .{ .background_token = if (on) .accent else .none, .border_token = if (on) .accent else .border, .border_width = 1.5, .radius = kit.corners(4), }); if (on) _ = kit.text(txn, box, "✓", .{ .size_role = .caption, .weight = .bold, .color_token = .accent_fg }); } _ = kit.dot(txn, line, label, 10); const name = if (look.nested) "Hiring" else info.name; const name_node = kit.text(txn, line, name, .{ .color_token = .card_fg }); siv.setLayout(txn, name_node, .{ .grow = 1 }); if (look.check != null) _ = kit.keycap(txn, line, label.keyCap());}
fn onPaletteTyped(self: *List, event: *const siv.Event) !bool { const text = if (event.text) |t| t[0..event.text_len] else ""; const n = @min(text.len, self.typed_buf.len); @memcpy(self.typed_buf[0..n], text[0..n]); self.typed = self.typed_buf[0..n]; self.selected = 0; self.top = 0; if (self.overlay != .palette) return true; self.filterCommands(); const txn = try siv.txnBegin(self.window); errdefer siv.txnAbort(txn); self.syncPalette(txn); try siv.txnCommit(txn); return true;}
fn onPickerTyped(self: *List, event: *const siv.Event) !bool { const text = if (event.text) |t| t[0..event.text_len] else ""; const n = @min(text.len, self.pick_typed_buf.len); @memcpy(self.pick_typed_buf[0..n], text[0..n]); self.pick_typed = self.pick_typed_buf[0..n]; self.pick_index = 0; if (self.overlay != .filter_picker and self.overlay != .label_picker) return true; return self.commit();}
fn onScrim(self: *List, event: *const siv.Event) !bool { if (event.button != .primary) return false; if (event.phase == .up) _ = try self.closeOverlay(); return event.phase == .down or event.phase == .up;}
fn onSelect(self: *List, event: *const siv.Event) !bool { if (event.count > 0) self.current = event.index; return true;}
/// The list activates a row on every press, so a double click is told/// from a click here, on the release that ends it.fn onListPointer(self: *List, event: *const siv.Event) !bool { if (event.button != .primary or event.phase != .up or event.clicks != 2) return false; _ = try self.run(.of(.open)); return true;}
/// A press on a row: it selects, as the list's select event also says.fn onActivate(self: *List, event: *const siv.Event) !bool { self.current = event.index; return true;}
fn bindRow( user_data: ?*anyopaque, txn: siv.Txn, row: siv.Node, item: u64, _: u32, _: u32, recycled: bool,) callconv(.c) void { const self: *List = @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, }); removeChildren(txn, chips); self.addChips(txn, chips, thread, true); 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);}
/// Plain's row, with the thread's chips after the sender.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, .{ .max_width = 240 }); const chips = siv.flex(txn, top, .{ .gap = 6, .align_items = .center }); siv.setLayout(txn, chips, .{ .grow = 1, .shrink = 1, .basis = kit.px(0) }); _ = 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 } });}
fn removeChildren(txn: siv.Txn, parent: siv.Node) void { var child = siv.nodeGetRelated(parent, .first_child); while (child != .null) { const next = siv.nodeGetRelated(child, .next); siv.remove(txn, child); child = next; }}