//! Desk, the mailbox view: one list of threads, chosen by label filters //! that stack, so a thread must have every one (Inbox alone to start; //! none is All Mail). A header names what is shown, with its counts and //! its filters as chips, and a click on a row's chip adds that label as a //! filter. One quiet status line holds notices; Ctrl+K or `:` opens a //! palette of every command with its key. Every label gets a key where //! one is free, for `g`, `t`, and `+`; the palette reaches the rest. //! //! Done (`e`) archives, and in a view without Inbox it takes off the user //! labels filtered on too, so the thread leaves the view. `z` undoes Done, //! Trash, a label, a star, and read or unread, the last first, through the //! mailbox, so Gmail hears it too; for a few seconds after each the status //! line offers it to a click. `x`, Shift+j and Shift+k, and a Ctrl or //! Shift click select threads, and then Done, Trash, a label, a star, //! and read or unread apply to them all, and `z` undoes them together; //! Esc lets them go. The filters are saved, so the list comes //! back as it was left. Scrolled near its end, the list asks Gmail for //! more of what it shows (Pages). While a search is shown, the rows mark //! what its words matched. const std = @import("std"); const siv = @import("siv"); const zeit = @import("zeit"); const kit = @import("kit.zig"); const Compose = @import("Compose.zig"); const Contacts = @import("Contacts.zig"); const Mailbox = @import("Mailbox.zig"); const Query = @import("Query.zig"); const Reader = @import("Reader.zig"); const Settings = @import("Settings.zig"); const Images = @import("Images.zig"); const Zoom = @import("Zoom.zig"); const sample = @import("sample.zig"); const ascii = std.ascii; const math = std.math; const mem = std.mem; const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const Io = std.Io; const testing = std.testing; const ArrayList = std.ArrayList; const Key = kit.Key; const Label = Mailbox.Label; const LabelInfo = Mailbox.LabelInfo; const Thread = Mailbox.Thread; const log = std.log.scoped(.desk); const Desk = @This(); /// What takes keys before the list: nothing, a key waiting for its label /// (after `g`, `t`, or `+`), the palette, an open thread, a draft, or the /// search field. const Overlay = enum { none, goto, label, add, palette, open, compose, search }; const Action = enum { open, close, done, /// Out of the Inbox only, whatever the list shows: a notification's /// Archive. archive, trash, star, unread, toggle, show_only, add_filter, remove_filter, clear_filters, compose, reply, reply_all, forward, open_file, search, /// The search asked of the server, past the mail the account's own /// index holds (f). search_server, unsubscribe, follow_action, add_event, undo, mark, clear_marks, /// The open thread's messages, all unfolded or folded again (x). unfold_all, /// The open thread's mail in its own colors or the theme's (v). original, /// Shows another account's desk (Ctrl+1 to Ctrl+9). switch_account, /// Opens the Add account page. add_account, /// The draft's, which the palette lists while composing, with Vim's /// names: :w, :q, :wq, :q!. draft_write, draft_ask, draft_write_ask, draft_discard, send, attach, add_cc, /// A formatting command of the draft's message, `tool` saying which. format, /// The open thread's messages, larger, smaller, or at their own size /// (Ctrl+=, Ctrl+-, Ctrl+0). zoom_in, zoom_out, zoom_reset, /// Whether mail's images from the web load as a thread opens. auto_images, }; /// One thing the app can do; `label` matters for toggle and the filter /// actions only, `file` for open_file, as the reader counts files, /// `message` for reply and reply_all: the message answered, as the reader /// counts them, or null for the newest, and `account` for /// switch_account, as the switcher counts them. const Command = struct { action: Action, label: Label, file: u16 = 0, message: ?u32 = null, account: u8 = 0, tool: u8 = 0, fn of(action: Action) Command { return .{ .action = action, .label = .inbox }; } }; /// How many of a thread's files the palette lists. const max_files = 32; /// How many of a thread's messages before the newest the palette offers /// to answer, newest first; each has two entries. const max_replies = 16; /// More labels than this, all on one thread, would show nothing. const max_filters = 16; /// One line per thread: who, then what, then when. const row_height = 38; /// Half the reader's height, in siv's pages, which are 90% of it. const half_page = 0.5 / 0.9; /// The column of who wrote, so every subject starts at one edge. const who_width = 220; /// A row's lines are this tall whatever font draws them, so a subject in /// another script, or with an emoji, does not push its row's text down. const line_height = 20; const header_height = 48; /// The bottom bar's height, the same with the hint, the search field, /// and the labels after `g`, `t`, or `+`, so the list does not jump. const bar_height = 40; const entry_height = 36; const entries_shown = 10; /// A click on a row's chip: it adds the chip's label as a filter. Made /// once per label, so rows can be bound again without allocating. const ChipClick = struct { desk: *Desk, label: Label, fn call(user_data: ?*anyopaque, _: siv.Node, event: *const siv.Event) callconv(.c) siv.EventResult { if (event.button != .primary) return .pass; // The press too: the row would take it and select itself. if (event.phase == .down) return .handled; if (event.phase != .up) return .pass; const self: *ChipClick = @ptrCast(@alignCast(user_data)); const desk = self.desk; if (desk.overlay != .none and desk.overlay != .search) return .handled; _ = desk.run(.{ .action = .add_filter, .label = self.label }) catch |err| { log.err("chip click: {t}", .{err}); return .pass; }; return .handled; } }; /// Reads more of what the list shows from Gmail, as the list is /// scrolled to its end; the answer comes back through `paged`. pub const Pages = struct { context: *anyopaque, /// Asks for more of the threads that have every label `labels` /// names, by Gmail's ids, and match `search`, in Gmail's syntax, /// unless it is empty. `first` starts the list over. more: *const fn ( context: *anyopaque, labels: []const []const u8, search: []const u8, first: bool, reach: Reach, ) void, /// How long typing in search pauses before the server is asked, in /// milliseconds, and the fewest characters it is asked for; Gmail's /// wait longer, as its reads cost quota. pause_ms: u32, shortest: u8, }; /// Who answers a search: the account's own index when it can, else the /// server; or the server, for the mail older than the index holds. pub const Reach = enum { any, server }; /// The other accounts' desks in the same window, for the header to /// name and Ctrl+1 to Ctrl+9 to show (Accounts.zig). pub const Switcher = struct { context: *anyopaque, /// This desk's account, as the header names it. name: []const u8, /// Its place among the accounts, as Ctrl and a digit counts them /// from 0. index: u8, /// The other accounts, into `buf`. others: *const fn (context: *anyopaque, buf: []Other) []const Other, /// Shows the account at `index`. show: *const fn (context: *anyopaque, index: u8) void, /// This desk's mail changed, which another desk's header counts. changed: *const fn (context: *anyopaque) void, }; /// Opens the Add account page over the desks (AddAccount.zig). pub const Adder = struct { context: *anyopaque, open: *const fn (context: *anyopaque) void }; /// Another account, as the header shows it. pub const Other = struct { index: u8, name: []const u8, /// Its Inbox's unread threads. unread: u32, /// Whether mail came to its Inbox since the user last looked at it. fresh: bool, }; /// The most accounts one window switches between, one per digit. pub const max_accounts = 9; /// Where asking Gmail for more of what the list shows stands. const Paging = enum { /// Nothing asked; the list's end asks. idle, asking, /// Asking failed; scrolling asks again. failed, /// Gmail has no more. exhausted, }; /// How near the list's end asks for more, in rows: a screenful ahead. const rows_ahead = 15; /// What one action changed, for `z` to put back: each thread it /// changed, as it was before and after. const Undo = struct { /// Holds the rest. arena: ArenaAllocator, /// What it did, as its notice said. what: []const u8, edits: ArrayList(Edit), const Edit = struct { id: Mailbox.Id, before: Mailbox.Labels, after: Mailbox.Labels, unread_before: bool, unread_after: bool, }; }; /// How many actions `z` can undo, the oldest forgotten first. const max_undos = 32; /// How long the status line offers to undo an action. const undo_offer_ms = 6000; /// 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, }; gpa: Allocator, /// Holds what lives as long as the window. arena_state: ArenaAllocator, arena: Allocator, /// Holds what each refresh makes for the header, the reader, and the /// pending bar; reset when they are made again. scratch: ArenaAllocator, /// What the header's chips show, and what their handlers hold: they are /// made again only when it changes, so that drawing the desk again /// between a press on one and its release loses no click. chips_key: ArrayList(u8), chips_arena: ArenaAllocator, io: Io, /// The local time zone, for the day mail came once it is a week old. zone: zeit.TimeZone, window: siv.Window, mail: *Mailbox, /// Mail read anew while the user was busy, shown once they are done. pending_mail: ?Mailbox, settings: ?Settings, /// The window's layers: the page, the reader, and the palette, which is /// made again when the labels change. layers: siv.Node, /// Each label's key, 0 where none was free; see assignKeys. keys: []u8, /// Each label's chip click, by label. chip_clicks: []ChipClick, overlay: Overlay, /// What the last command did, shown in the status line. notice: []const u8, notice_buf: [128]u8, /// Whether the notice is a session's news, such as mail that could not /// load, which mail coming again takes away; the user's own stay. session_notice: bool, /// What the last actions changed, the newest last; `z` takes it back. undos: ArrayList(Undo), /// Whether the status line offers to undo the last action, as it does /// for a few seconds after, until `undo_timer` ends it. undo_offered: bool, undo_timer: siv.Timer, // The list: the threads with every label in `filters`, in the order // they were added. filters: ArrayList(Label), ids: []Mailbox.Id, ids_buf: ArrayList(Mailbox.Id), current: ?u32, /// The threads selected to act on together, in the order selected; the /// list's own selection is the cursor, `current`. marked: std.array_hash_map.Auto(Mailbox.Id, void), list: siv.Node, empty: siv.Node, empty_text: siv.Node, empty_hints: siv.Node, empty_search_hints: siv.Node, title: siv.Node, counts: siv.Node, filter_chips: siv.Node, /// The account's name before the title, and the other accounts after /// the counts, while there are others. account_name: siv.Node, others: siv.Node, /// The other accounts' desks; null when this is the only one. switcher: ?Switcher, /// What the palette's Add account opens; null where there is none. adder: ?Adder, /// Whether another account's desk shows instead, so this one takes no /// focus. hidden: bool, /// What the status line calls where the mail is: Gmail, Fastmail. server: []const u8, // Search: what the list shows while `search` is not empty, starting // from All Mail; a filter added meanwhile narrows it. It matches the // loaded mail as it is typed, then Gmail's answer, `found`, once Enter // asks. search: []const u8, search_buf: [128]u8, /// The field's text as typed, untrimmed, in `search_buf`. search_text: []const u8, /// What `search` asks, parsed: it narrows the loaded mail as it is /// typed, and the rows mark what its words matched. search_query: Query, /// The filters from before the search, put back when it ends. before_search: ArrayList(Label), /// Gmail's ids of the threads its search found. found: std.array_hash_map.String(void), found_arena: ArenaAllocator, /// Whether `found` answers `search`. found_ready: bool, /// When the oldest mail `found` was searched in came, when the index /// answered and holds no older mail; null when all of it was searched. found_since: ?i64, /// Who answers the search, and whether the server's first answer is /// awaited after the index's. reach: Reach, server_asked: bool, /// Asks the server for the search once typing pauses. search_timer: siv.Timer, /// Asks Gmail for more; null when there is none, as with the samples. pages: ?Pages, paging: Paging, /// What `paging` is about: the filters' ids, or the search's words /// after a "/"; see viewKey. paged_view: []const u8, paged_view_buf: [view_key_bytes]u8, /// The part of the list scrolled into view, from its viewport events. viewport: struct { y: f32, height: f32 }, search_bar: siv.Node, search_field: siv.Node, /// What the field suggests for the word before its caret, in a list /// that rises from `caret_mark`, a sliver kept at the word's start. suggestions: siv.Node, caret_mark: siv.Node, suggested: [max_suggested]Suggested, suggested_count: u8, /// The word they would replace, as byte offsets into the field's text. suggested_word: struct { start: u32, end: u32 }, // The palette. filter: []const u8, filter_buf: [128]u8, shown: []Command, /// Room for every command, made once the labels are known: what is shown, /// and every command before filtering. shown_buf: []Command, all_buf: []Command, selected: u32, palette_layer: siv.Node, /// Where Esc goes back to from the palette: the draft, when it was open. palette_from: Overlay, field: siv.Node, context: siv.Node, entries_scroll: siv.Node, nothing: siv.Node, entries: []Entry, // The open thread: a card, its head made again on each refresh, and the // messages, which `messages` keeps. reader_layer: siv.Node, reader: siv.Node, reader_head: siv.Node, reader_body: siv.Node, /// Holds what the card needs, such as its hints' clicks, while it is open. reader_arena: ArenaAllocator, messages: Reader, // Writing: the draft, and where Esc goes back to. compose: Compose, compose_from: Overlay, /// A reply asked for before its thread was read, which starts once the /// thread's messages come: what kind, and to which thread. reply_waiting: ?Compose.Kind, reply_thread: []const u8, reply_thread_buf: [64]u8, // The status line: the mode in the accent, then the bar for it. mode: siv.Node, status_slot: siv.Node, status: siv.Node, status_line: siv.Node, hints: siv.Node, search_hints: siv.Node, /// The keys for the threads selected, and how many they are. mark_hints: siv.Node, marked_count: siv.Node, undo_hint: siv.Node, /// Asks the server for mail older than the index searched. older_hint: siv.Node, /// Turns while mail is on its way: the first load, or a page of the list. spinner: siv.Node, /// The account's mail is loading for the first time (Session). loading: bool, pending: siv.Node, /// Builds the view in `parent`, a node in `window` it fills, showing /// `mail`. `settings` keeps the filters between runs; null keeps /// nothing. `zoom` is how large the reader shows mail, and `images` /// whether it loads mail's images from the web, which the other /// accounts' desks share. `io` tells the time, and `zone`, which the /// view takes, the day, for the threads' ages. pub fn create( gpa: Allocator, io: Io, zone: zeit.TimeZone, window: siv.Window, parent: siv.Node, mail: *Mailbox, settings: ?Settings, zoom: *Zoom, images: *Images, ) !*Desk { const self = try gpa.create(Desk); errdefer gpa.destroy(self); self.* = .{ .gpa = gpa, .arena_state = .init(gpa), .arena = undefined, .scratch = .init(gpa), .chips_key = .empty, .chips_arena = .init(gpa), .io = io, .zone = zone, .window = window, .mail = mail, .pending_mail = null, .settings = settings, .layers = .null, .keys = &.{}, .chip_clicks = &.{}, .overlay = .none, .notice = "", .session_notice = false, .notice_buf = undefined, .undos = .empty, .undo_offered = false, .undo_timer = .null, .filters = .empty, .ids = &.{}, .ids_buf = .empty, .current = null, .marked = .empty, .list = .null, .empty = .null, .empty_text = .null, .empty_hints = .null, .empty_search_hints = .null, .title = .null, .counts = .null, .filter_chips = .null, .account_name = .null, .others = .null, .switcher = null, .adder = null, .hidden = false, .server = "Gmail", .search = "", .search_buf = undefined, .search_text = "", .search_query = .{ .terms = undefined, .len = 0 }, .before_search = .empty, .found = .empty, .found_arena = .init(gpa), .found_ready = false, .found_since = null, .reach = .any, .server_asked = false, .search_timer = .null, .pages = null, .paging = .idle, .paged_view = "", .paged_view_buf = undefined, .viewport = .{ .y = 0, .height = 0 }, .search_bar = .null, .search_field = .null, .suggestions = .null, .caret_mark = .null, .suggested = undefined, .suggested_count = 0, .suggested_word = .{ .start = 0, .end = 0 }, .filter = "", .filter_buf = undefined, .shown = &.{}, .shown_buf = &.{}, .all_buf = &.{}, .selected = 0, .palette_layer = .null, .palette_from = .none, .field = .null, .context = .null, .entries_scroll = .null, .nothing = .null, .entries = &.{}, .reader_layer = .null, .reader = .null, .reader_head = .null, .reader_body = .null, .reader_arena = .init(gpa), .messages = .init(gpa, io, window, .{ .context = self, .redraw = redrawFromReader, .say = sayFromReader, .compose = composeFromReader, .reply = replyFromReader, .attach = attachFromReader, .read = readFromReader, .came = cameFromReader, }, zoom, images), .compose = .init(gpa, io), .compose_from = .none, .reply_waiting = null, .reply_thread = "", .reply_thread_buf = undefined, .mode = .null, .status_slot = .null, .status = .null, .status_line = .null, .hints = .null, .search_hints = .null, .mark_hints = .null, .marked_count = .null, .undo_hint = .null, .older_hint = .null, .spinner = .null, .loading = false, .pending = .null, }; self.arena = self.arena_state.allocator(); errdefer self.deinitState(); try self.filters.ensureTotalCapacity(gpa, max_filters); try self.undos.ensureTotalCapacity(gpa, max_undos); try self.before_search.ensureTotalCapacity(gpa, max_filters); try self.prepareLabels(); self.loadFilters(&self.filters, self.labelInfo(.inbox).id); const txn = try siv.txnBegin(window); errdefer siv.txnAbort(txn); siv.setVisual(txn, siv.windowGetRoot(window), .{ .background_token = .view_bg }); const layers = siv.stack(txn, parent, .{}); self.layers = layers; siv.setLayout(txn, layers, .{ .width = kit.whole, .height = kit.whole }); const page = siv.flex(txn, layers, .{ .axis = .column, .align_items = .stretch }); siv.setLayout(txn, page, .{ .width = kit.whole, .height = kit.whole }); try self.buildHeader(txn, page); try self.buildList(txn, page); try self.buildStatus(txn, page); try self.buildReader(txn, layers); self.compose.build(txn, layers); self.compose.host = .{ .context = self, .state = composeState, .close = composeClose }; try self.buildPalette(txn, layers); // Over the page, which holds the field: Up, Down, Tab, Enter, and Esc // in the field are the list's while it shows. self.suggestions = siv.suggestionList(txn, layers, .{ .anchor = self.search_field, .label = "Search suggestions" }); siv.setAnchor(txn, self.suggestions, .{ .anchor = self.caret_mark, .gap = 6, .flags = .{ .above = true } }); siv.setLayout(txn, self.suggestions, .{ .min_width = 260, .max_width = 420 }); kit.on(Desk, txn, self.suggestions, .select, self, onSuggestion); kit.keymap(Desk, txn, parent, self); try siv.txnCommit(txn); try self.refresh(); return self; } /// Frees the view. Call after the window is destroyed, as its handlers /// hold the view. pub fn destroy(self: *Desk) void { const gpa = self.gpa; self.deinitState(); gpa.destroy(self); } fn deinitState(self: *Desk) void { if (self.undo_timer != .null) siv.timerCancel(self.undo_timer); if (self.search_timer != .null) siv.timerCancel(self.search_timer); self.forgetUndos(); self.undos.deinit(self.gpa); if (self.pending_mail) |*m| m.deinit(); self.filters.deinit(self.gpa); self.before_search.deinit(self.gpa); self.ids_buf.deinit(self.gpa); self.marked.deinit(self.gpa); self.search_query.deinit(self.gpa); self.found.deinit(self.gpa); self.found_arena.deinit(); self.messages.deinit(); self.compose.deinit(); self.reader_arena.deinit(); self.scratch.deinit(); self.chips_key.deinit(self.gpa); self.chips_arena.deinit(); self.arena_state.deinit(); self.zone.deinit(); } /// What follows from the mail's labels: their keys, their chips' clicks, /// and room in the palette for a command per label. fn prepareLabels(self: *Desk) !void { try self.assignKeys(); const count = self.mail.labelCount(); self.chip_clicks = try self.arena.alloc(ChipClick, count); for (self.chip_clicks, 0..) |*click, i| click.* = .{ .desk = self, .label = @fromBackingInt(@intCast(i)) }; const commands = commandCapacity(count); self.shown_buf = try self.arena.alloc(Command, commands); self.all_buf = try self.arena.alloc(Command, commands); self.entries = try self.arena.alloc(Entry, commands); self.shown = &.{}; } /// Every action but the per-label ones, the files and earlier replies, /// then four per label. fn commandCapacity(label_count: u16) u32 { const once: u32 = @typeInfo(Action).@"enum".field_names.len; return once + max_accounts + max_files + max_replies * 2 + @as(u32, label_count) * 4; } /// Gives each label a key for `g`, `t`, and `+`: Gmail's system labels /// fixed ones, then each of the user's the first free letter of its own /// name (its last part, for a nested one), else any free letter or digit. /// Past those, a label has none, and the palette reaches it. fn assignKeys(self: *Desk) !void { const count = self.mail.labelCount(); self.keys = try self.arena.alloc(u8, count); @memset(self.keys, 0); var used: std.bit_set.Integer(128) = .empty; const system = [_]struct { Label, u8 }{ .{ .inbox, 'i' }, .{ .starred, 's' }, .{ .sent, 't' }, .{ .drafts, 'd' }, .{ .spam, '!' }, .{ .trash, '#' }, }; for (system) |entry| { self.keys[@backingInt(entry[0])] = entry[1]; used.set(entry[1]); } const fallback = "abcdefghijklmnopqrstuvwxyz0123456789"; for (self.keys, 0..) |*key, i| { if (key.* != 0) continue; const name = self.mail.labels.items[i].name; const own = name[if (mem.findScalarLast(u8, name, '/')) |slash| slash + 1 else 0..]; for ([_][]const u8{ own, fallback }) |candidates| { for (candidates) |c| { const lower = ascii.toLower(c); if (!ascii.isAlphanumeric(lower) or used.isSet(lower)) continue; key.* = lower; used.set(lower); break; } if (key.* != 0) break; } } } fn labelInfo(self: *const Desk, label: Label) *const LabelInfo { return self.mail.label(label); } /// The label's key as text, for a keycap; empty when it has none. fn keyCap(self: *const Desk, label: Label) []const u8 { const key = &self.keys[@backingInt(label)]; return if (key.* == 0) "" else key[0..1]; } fn labelForKey(self: *const Desk, c: u8) ?Label { const i = mem.findScalar(u8, self.keys, c) orelse return null; return @fromBackingInt(@intCast(i)); } fn hasLabel(t: *const Thread, label: Label) bool { return t.labels.isSet(@backingInt(label)); } fn filtered(self: *const Desk, label: Label) bool { return mem.findScalar(Label, self.filters.items, label) != null; } /// Now, in seconds since the epoch, for the threads' ages. fn now(self: *const Desk) i64 { return Io.Timestamp.now(self.io, .real).toSeconds(); } /// The title naming what is shown, in the accent, the filters that /// choose it, each a tag that a click removes, and the counts on the right. fn buildHeader(self: *Desk, txn: siv.Txn, page: siv.Node) !void { const header = siv.flex(txn, page, .{ .gap = 14, .align_items = .center }); siv.setLayout(txn, header, .{ .min_height = header_height, .padding = .{ .start = 20, .end = 20, .top = 6, .bottom = 6 }, }); self.account_name = kit.text(txn, header, "", .{ .size_role = .heading, .color_token = .muted_fg }); siv.setLayout(txn, self.account_name, .{ .shrink = 0 }); siv.setHidden(txn, self.account_name, true); self.title = kit.text(txn, header, "", .{ .size_role = .heading, .weight = .bold, .color_token = .accent }); siv.setSemantics(txn, self.title, .{ .role = .heading, .level = 1 }); siv.setLayout(txn, self.title, .{ .shrink = 1 }); self.filter_chips = siv.flex(txn, header, .{ .gap = 8, .align_items = .center }); const add = siv.flex(txn, header, .{ .gap = 4, .align_items = .center }); siv.setLayout(txn, add, .{ .padding = .{ .start = 4, .end = 4, .top = 2, .bottom = 2 } }); try kit.clickable(Desk, Key, kit.press(Desk), self.arena, txn, add, self, .char('+'), "Add filter"); _ = kit.keycap(txn, add, "+"); _ = kit.text(txn, add, "filter", .{ .size_role = .caption, .color_token = .muted_fg }); const gap = siv.box(txn, header); siv.setLayout(txn, gap, .{ .grow = 1 }); self.counts = kit.text(txn, header, "", .{ .size_role = .caption, .color_token = .muted_fg, .flags = kit.one_line, }); siv.setLayout(txn, self.counts, .{ .shrink = 1 }); self.others = siv.flex(txn, header, .{ .gap = 8, .align_items = .center }); siv.setLayout(txn, self.others, .{ .shrink = 0 }); } fn buildList(self: *Desk, txn: siv.Txn, page: siv.Node) !void { const body = siv.stack(txn, page, .{}); 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, // The list is the window's surface, edge to edge, not a box in it. // Rows end before the scrollbar, so a press at their end opens // the thread rather than grabbing the bar. .flags = .{ .flat = true, .gutter = true }, }); siv.setLayout(txn, self.list, .{ .width = kit.whole, .height = kit.whole }); kit.on(Desk, txn, self.list, .select, self, onSelect); // A list view is a scroll node in a column; the scroll node tells // where it is scrolled. kit.on(Desk, txn, siv.nodeGetRelated(self.list, .first_child), .viewport, self, onViewport); kit.on(Desk, txn, self.list, .activate, self, onActivate); // Why the list is empty, and the key out of it when filters narrowed it. self.empty = siv.flex(txn, body, .{ .axis = .column, .gap = 4, .align_items = .center }); siv.setLayout(txn, self.empty, .{ .align_x = .center, .align_y = .center }); self.empty_text = kit.text(txn, self.empty, "Nothing here", .{ .color_token = .muted_fg }); self.empty_hints = try kit.hintBar(Desk, self.arena, txn, self.empty, self, &.{ .{ .key = .char('-'), .cap = "-", .action = "remove filter" }, }); self.empty_search_hints = try kit.hintBar(Desk, self.arena, txn, self.empty, self, &.{ .{ .key = .named(.@"return"), .cap = "⏎", .action = "search all the mail" }, .{ .key = .char('F'), .cap = "F", .action = "search older mail" }, .{ .key = .char('q'), .cap = "q", .action = "end search" }, }); } /// The statusline: the mode in a block of the accent, then the last /// command's notice, else the keys that matter here, and the key that /// opens the palette, each one clickable. fn buildStatus(self: *Desk, txn: siv.Txn, page: siv.Node) !void { const line = siv.flex(txn, page, .{ .align_items = .stretch }); siv.setVisual(txn, line, .{ .background_token = .sidebar_bg }); const mode = siv.flex(txn, line, .{ .align_items = .center }); siv.setLayout(txn, mode, .{ .min_height = bar_height, .padding = .{ .start = 14, .end = 14 } }); siv.setVisual(txn, mode, .{ .background_token = .accent }); self.mode = kit.text(txn, mode, "", .{ .size_role = .caption, .weight = .bold, .color_token = .accent_fg }); self.status_slot = siv.flex(txn, line, .{ .axis = .column, .align_items = .stretch, .justify = .center }); siv.setLayout(txn, self.status_slot, .{ .grow = 1, .shrink = 1, .basis = kit.px(0) }); self.status = siv.flex(txn, self.status_slot, .{ .gap = 8, .align_items = .center }); siv.setLayout(txn, self.status, .{ .min_height = bar_height, .padding = .{ .start = 4, .end = 16 }, }); self.marked_count = kit.text(txn, self.status, "", .{ .size_role = .caption, .weight = .bold, .color_token = .accent, .flags = kit.one_line, }); siv.setLayout(txn, self.marked_count, .{ .padding = .{ .start = 12 } }); self.hints = try kit.hintBar(Desk, self.arena, txn, self.status, self, &.{ .{ .key = .char('j'), .cap = "j/k", .action = "move" }, .{ .key = .named(.@"return"), .cap = "⏎", .action = "open" }, .{ .key = .char('e'), .cap = "e", .action = "done" }, .{ .key = .char('/'), .cap = "/", .action = "search" }, .{ .key = .char('+'), .cap = "+", .action = "filter" }, }); self.search_hints = try kit.hintBar(Desk, self.arena, txn, self.status, self, &.{ .{ .key = .char('i'), .cap = "i", .action = "edit search" }, .{ .key = .char('q'), .cap = "q", .action = "end search" }, .{ .key = .char('+'), .cap = "+", .action = "narrow" }, .{ .key = .char('j'), .cap = "j/k", .action = "move" }, .{ .key = .char('e'), .cap = "e", .action = "done" }, }); self.mark_hints = try kit.hintBar(Desk, self.arena, txn, self.status, self, &.{ .{ .key = .named(.escape), .cap = "esc", .action = "clear" }, .{ .key = .char('x'), .cap = "x", .action = "select" }, .{ .key = .char('e'), .cap = "e", .action = "done" }, .{ .key = .char('#'), .cap = "#", .action = "trash" }, .{ .key = .char('t'), .cap = "t", .action = "label" }, .{ .key = .char('s'), .cap = "s", .action = "star" }, .{ .key = .char('u'), .cap = "u", .action = "read" }, }); self.spinner = siv.spinner(txn, self.status, .{ .label = "Loading mail", .diameter = 14 }); self.status_line = kit.text(txn, self.status, "", .{ .size_role = .caption, .color_token = .view_fg, .flags = kit.one_line, }); siv.setLayout(txn, self.status_line, .{ .grow = 1, .shrink = 1, .basis = kit.px(0), .padding = .{ .start = 12 } }); self.undo_hint = try kit.hintBar(Desk, self.arena, txn, self.status, self, &.{ .{ .key = .char('z'), .cap = "z", .action = "undo" }, }); self.older_hint = try kit.hintBar(Desk, self.arena, txn, self.status, self, &.{ .{ .key = .char('F'), .cap = "F", .action = "search older mail" }, }); const commands = siv.flex(txn, self.status, .{ .gap = 6, .align_items = .center }); try kit.clickable(Desk, Key, kit.press(Desk), self.arena, txn, commands, self, .ctrl('k'), "Ctrl+K for commands"); _ = kit.keycap(txn, commands, "^K"); _ = kit.text(txn, commands, "commands", .{ .size_role = .caption, .color_token = .muted_fg }); // In its place while searching. self.search_bar = siv.flex(txn, self.status_slot, .{ .gap = 8, .align_items = .center }); siv.setLayout(txn, self.search_bar, .{ .min_height = bar_height, .padding = .{ .start = 12, .end = 8, .top = 4, .bottom = 4 }, }); // The field, and over it the mark the suggestions rise from. const field_slot = siv.stack(txn, self.search_bar, .{}); siv.setLayout(txn, field_slot, .{ .grow = 1, .shrink = 1, .basis = kit.px(0) }); self.search_field = siv.textField(txn, field_slot, .{ .placeholder = "Words, or Gmail’s from:, has:attachment, older_than:1y…", .label = "Search", }); siv.setLayout(txn, self.search_field, .{ .width = kit.whole }); self.caret_mark = siv.box(txn, field_slot); siv.setLayout(txn, self.caret_mark, .{ .width = kit.px(1), .height = kit.whole, .flags = .{ .inset_start = true }, }); kit.on(Desk, txn, self.search_field, .change, self, onSearchChange); _ = try kit.hintBar(Desk, self.arena, txn, self.search_bar, self, &.{ .{ .key = .named(.@"return"), .cap = "⏎", .action = "results" }, .{ .key = .named(.escape), .cap = "esc", .action = "cancel" }, }); siv.setHidden(txn, self.search_bar, true); } /// An open thread: a card over a scrim. fn buildReader(self: *Desk, txn: siv.Txn, layers: siv.Node) !void { self.reader_layer = siv.stack(txn, layers, .{ .align_x = .center, .align_y = .center }); siv.setLayout(txn, self.reader_layer, .{ .width = kit.whole, .height = kit.whole }); const scrim = siv.box(txn, self.reader_layer); siv.setLayout(txn, scrim, .{ .width = kit.whole, .height = kit.whole }); // Its own compositor layer: without one, a list view draws over the // scrim once it is shown a second time. siv.setVisual(txn, scrim, .{ .background_token = .scrim, .flags = .{ .layer = true } }); try kit.clickable(Desk, void, clickScrim, self.arena, txn, scrim, self, {}, "Close"); siv.setHidden(txn, self.reader_layer, true); } /// The palette: a scrim and a card holding the filter field and every /// entry, made once; filtering only fills and hides rows. fn buildPalette(self: *Desk, txn: siv.Txn, layers: siv.Node) !void { self.palette_layer = siv.stack(txn, layers, .{}); siv.setLayout(txn, self.palette_layer, .{ .width = kit.whole, .height = kit.whole }); siv.setHidden(txn, self.palette_layer, true); const scrim = siv.box(txn, self.palette_layer); siv.setLayout(txn, scrim, .{ .width = kit.whole, .height = kit.whole }); siv.setVisual(txn, scrim, .{ .background_token = .scrim, .flags = .{ .layer = true } }); try kit.clickable(Desk, Key, kit.press(Desk), self.arena, txn, scrim, self, .named(.escape), "Close commands"); const card = siv.card(txn, self.palette_layer, .{ .flags = .{ .accent = true } }); siv.setFlex(txn, card, .{ .axis = .column, .gap = 8, .align_items = .stretch }); siv.setLayout(txn, card, .{ .width = kit.px(600), .align_x = .center, .align_y = .center, .padding = kit.edges(12), }); self.field = siv.textField(txn, card, .{ .placeholder = "Type a command or a label", .label = "Command" }); kit.on(Desk, txn, self.field, .change, self, onFilterChange); 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); try kit.clickable(Desk, u32, runEntry, self.arena, txn, entry.node, self, @intCast(i), "Command"); } const hints = try kit.hintBar(Desk, self.arena, txn, card, self, &.{ .{ .key = .named(.down), .cap = "↓", .action = "next" }, .{ .key = .named(.up), .cap = "↑", .action = "previous" }, .{ .key = .named(.@"return"), .cap = "⏎", .action = "run" }, .{ .key = .named(.escape), .cap = "esc", .action = "close" }, }); // In line with the context above, not a bar's own inset in the card's. siv.setLayout(txn, hints, .{ .padding = .{ .start = 4, .end = 4, .top = 8, .bottom = 8 } }); } 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 } }); 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), }; } // Keys. pub fn onKey(self: *Desk, key: Key) !bool { if (self.switcher != null and key.mods.ctrl and !key.mods.shift and key.code >= '1' and key.code <= '9') { return self.run(.{ .action = .switch_account, .label = .inbox, .account = @intCast(key.code - '1') }); } return switch (self.overlay) { .none => self.directKey(key), .goto, .label, .add => self.pendingKey(key), .palette => self.paletteKey(key), .open => self.openKey(key), .compose => self.composeKey(key), .search => self.searchKey(key), }; } /// The keys that work without the palette, each the same as an entry. fn directKey(self: *Desk, key: Key) !bool { if (key.isCtrl('k') or key.is(':')) return self.openPalette(); if (key.is('/')) return self.openSearch(); if (key.isNamed(.escape) and self.marked.count() > 0) return self.run(.of(.clear_marks)); // Back to the search's words, as vim's i, or Esc a step back; q ends it. if ((key.is('i') or key.isNamed(.escape)) and self.search.len > 0) return self.openSearch(); if (key.is('q') and self.search.len > 0) return self.endSearch(); if (key.is('F') and self.found_since != null) return self.run(.of(.search_server)); // A search nothing has matched yet asks the server now. if (key.isNamed(.@"return") and self.search.len > 0 and self.ids.len == 0 and !self.found_ready) { try self.askServer(); return true; } if (key.is('x')) return self.run(.of(.mark)); // Shift+j and Shift+k select as they go. if (key.is('J') or (key.isNamed(.down) and key.mods.shift)) return self.extendMarks(1); if (key.is('K') or (key.isNamed(.up) and key.mods.shift)) return self.extendMarks(-1); if (key.is('j') or key.isNamed(.down) or key.isCtrl('n')) return self.moveRow(1); if (key.is('k') or key.isNamed(.up) or key.isCtrl('p')) return self.moveRow(-1); // Half a page of rows, as in vim. if (key.isCtrl('d')) return self.moveRow(self.halfPageRows()); if (key.isCtrl('u')) return self.moveRow(-self.halfPageRows()); if (key.is('g')) return self.setOverlay(.goto); if (key.is('t') or key.is('l')) return if (self.thread() != null) self.setOverlay(.label) else true; if (key.is('+')) return self.setOverlay(.add); if (key.is('-') or key.isNamed(.backspace)) { const last = self.filters.getLastOrNull() orelse return true; return self.run(.{ .action = .remove_filter, .label = last }); } const command = commandForKey(key) orelse return false; return self.run(command); } /// The single keys that run a command directly. fn commandForKey(key: Key) ?Command { const action: Action = if (key.isNamed(.@"return") or key.is('o')) .open else if (key.is('e')) .done else if (key.is('#')) .trash else if (key.is('s')) .star else if (key.is('u')) .unread else if (key.is('c')) .compose else if (key.is('r')) .reply else if (key.is('a')) .reply_all else if (key.is('f')) .forward else if (key.is('z')) .undo else return null; return .of(action); } /// After `g`, `t`, or `+`: the next key names a label. Labeling stays in /// the mode for more toggles; the others run once. fn pendingKey(self: *Desk, key: Key) !bool { if (key.isNamed(.escape) or key.isNamed(.@"return")) return self.setOverlay(self.returnTo()); const c = key.printable() orelse return true; const label = self.labelForKey(c) orelse return true; switch (self.overlay) { .goto => return self.run(.{ .action = .show_only, .label = label }), .add => return self.run(.{ .action = .add_filter, .label = label }), else => { if (self.labelInfo(label).kind != .user) return true; const keep = self.overlay; _ = try self.run(.{ .action = .toggle, .label = label }); return self.setOverlay(keep); }, } } /// While the palette is open; keys it declines type into the filter. fn paletteKey(self: *Desk, key: Key) !bool { if (key.isNamed(.escape) or key.isCtrl('k')) return self.leavePalette(); 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; } fn openKey(self: *Desk, key: Key) !bool { if (key.isNamed(.escape) or key.is('q')) return self.run(.of(.close)); if (key.isCtrl('k') or key.is(':')) return self.openPalette(); if (key.is('/')) return self.openSearch(); // j and k read on through the thread; J and K go to the next one. if (key.is('j') or key.isNamed(.down)) return self.messages.step(1, key.keycode); if (key.is('k') or key.isNamed(.up)) return self.messages.step(-1, key.keycode); if (key.is('J') or key.isCtrl('n')) return self.moveRow(1); if (key.is('K') or key.isCtrl('p')) return self.moveRow(-1); // Half a screen, as in vim, and on while held. if (key.isCtrl('d') or key.isCtrl('u')) { self.messages.page(if (key.isCtrl('u')) -half_page else half_page, key.keycode); return true; } if (key.is('t') or key.is('l')) return self.setOverlay(.label); if (key.is(' ') or key.isNamed(.page_down) or key.isNamed(.page_up)) { self.messages.page(if (key.mods.shift or key.isNamed(.page_up)) -1 else 1, key.keycode); return true; } if (key.is('x')) return self.run(.of(.unfold_all)); if (key.is('i')) { self.messages.showImages(); try self.refresh(); return true; } if (key.is('v')) return self.run(.of(.original)); if (key.isCtrl('=') or key.isCtrl('+')) return self.run(.of(.zoom_in)); if (key.isCtrl('-')) return self.run(.of(.zoom_out)); if (key.isCtrl('0')) return self.run(.of(.zoom_reset)); for ([_]u8{ 'e', '#', 's', 'u', 'c', 'r', 'a', 'f', 'z' }) |c| { if (key.is(c)) return self.run(commandForKey(key).?); } return true; } /// Keys the search field does not type: Enter takes the search and goes /// to the results, as Down does, and Escape ends the search. fn searchKey(self: *Desk, key: Key) !bool { if (self.suggestionKey(key)) return false; if (key.isCtrl('n') or key.isCtrl('p')) return self.moveSuggestion(if (key.isCtrl('n')) 1 else -1); if (key.isCtrl('k')) { try kit.killLine(self.search_field); return true; } if (key.isNamed(.@"return")) return self.searchEnter(); if (key.isNamed(.down)) return self.setOverlay(.none); if (key.isNamed(.escape)) { self.overlay = .none; return self.endSearch(); } return false; } /// Keys the draft's fields do not type. fn composeKey(self: *Desk, key: Key) !bool { if (self.compose.suggestionKey(key)) return false; if (key.isCtrl('n') or key.isCtrl('p')) return self.compose.moveSuggestion(if (key.isCtrl('n')) 1 else -1); if (key.isCtrl('k')) if (self.compose.focusedLine()) |field| { try kit.killLine(field); return true; }; // Vim's : in normal mode opens the palette, with the draft's commands; // visual mode's stays Vim's, for its ranges. if (key.is(':') and self.compose.vimIdle()) return self.openPalette(); // Vim's, in the body: it leaves insert mode, and the habit of a // second one must not close the card. if (key.isNamed(.escape) and self.compose.vimMode() != null) return false; return self.compose.onKey(key); } /// The body's Vim mode shows in the status line. fn composeState(context: *anyopaque) !void { const self: *Desk = @ptrCast(@alignCast(context)); if (self.overlay != .compose) return; const txn = try siv.txnBegin(self.window); errdefer siv.txnAbort(txn); self.renderStatus(txn); try siv.txnCommit(txn); } fn composeClose(context: *anyopaque, how: Compose.Close) !void { const self: *Desk = @ptrCast(@alignCast(context)); _ = switch (how) { .keep => keep: { self.compose.putAway(); break :keep try self.closeCompose("Draft kept: c opens it"); }, .send => try self.sendDraft(), .discard => discard: { self.compose.discard(); break :discard try self.closeCompose("Draft discarded"); }, }; } /// Where Escape goes from labeling: back to the open thread if one was. fn returnTo(self: *const Desk) Overlay { return if (self.overlay == .label and self.reader != .null) .open else .none; } // Commands. Every key, palette entry, and click ends up in run. fn run(self: *Desk, command: Command) !bool { self.notice = ""; self.undo_offered = false; switch (command.action) { .close => self.overlay = .none, .undo => try self.undo(), .mark => { self.overlay = .none; if (self.thread()) |id| self.toggleMark(id); }, .clear_marks => { self.overlay = .none; self.marked.clearRetainingCapacity(); }, .show_only, .add_filter, .remove_filter, .clear_filters => { self.overlay = .none; self.changeFilters(command); }, .compose => return self.startCompose(.new, null), .search => return self.openSearch(), .search_server => return self.searchServer(), .reply => return self.startCompose(.reply, command.message), .reply_all => return self.startCompose(.reply_all, command.message), .forward => return self.startCompose(.forward, command.message), .unsubscribe, .follow_action, .add_event => { const id = self.thread() orelse return true; self.overlay = .open; try self.refresh(); const gmail_id = self.mail.get(id).id; switch (command.action) { .unsubscribe => try self.messages.unsubscribe(gmail_id), .follow_action => self.messages.followAction(gmail_id), else => self.messages.addEvent(gmail_id), } return true; }, .open_file => { // The thread the file is in shows with it. const id = self.thread() orelse return true; self.overlay = .open; try self.refresh(); try self.messages.open(self.mail.get(id).id, command.file); return true; }, .open => { const id = self.thread() orelse return true; self.seeing(id); self.overlay = .open; }, .unfold_all, .original => { self.overlay = .open; if (command.action == .unfold_all) self.messages.toggleAll() else self.messages.toggleOriginal(); }, .zoom_in, .zoom_out, .zoom_reset => { self.overlay = .open; const zoom = self.messages.zoom; self.messages.zoomTo(switch (command.action) { .zoom_in => zoom.step(.larger), .zoom_out => zoom.step(.smaller), else => 1, }); self.say("Text at {d:.0}%", .{zoom.scale * 100}); }, .auto_images => { // The palette closed the thread; the next one opened follows. self.overlay = .none; const images = self.messages.web_images; images.toggle(); self.say("{s}", .{if (images.auto) "Images from the web load, except in Spam" else "Images from the web wait for i"}); }, .draft_write, .draft_ask, .draft_write_ask, .draft_discard => { self.overlay = .compose; try self.refresh(); try self.compose.vim(switch (command.action) { .draft_write => .write, .draft_ask => .ask, .draft_write_ask => .write_ask, else => .discard, }); if (command.action == .draft_write) self.say("Draft saved", .{}); if (command.action != .draft_write) return true; }, .send, .attach, .add_cc => { self.overlay = .compose; try self.refresh(); try self.compose.focusBody(); _ = try self.compose.onKey(switch (command.action) { .send => Compose.send_key, .attach => Compose.attach_key, else => Compose.cc_key, }); return true; }, .format => { self.overlay = .compose; try self.refresh(); try self.compose.runFormat(command.tool); return true; }, .add_account => { self.overlay = .none; try self.refresh(); // Over the desk, which gives up the keys to it. if (self.adder) |a| a.open(a.context); return true; }, .switch_account => { const s = self.switcher orelse return true; if (self.overlay == .palette) self.overlay = .none; // The other desk takes the window; this one keeps its state. if (command.account != s.index) s.show(s.context, command.account); return true; }, else => { if (self.overlay != .open and self.overlay != .label) self.overlay = .none; try self.onThread(command); }, } try self.refresh(); return true; } /// Changes the filters and saves them. Showing only a label ends a /// search; adding one during a search narrows it. fn changeFilters(self: *Desk, command: Command) void { const label = command.label; switch (command.action) { .show_only => { self.search = ""; self.forgetFound(); self.before_search.clearRetainingCapacity(); self.filters.clearRetainingCapacity(); self.filters.appendAssumeCapacity(label); self.current = 0; }, .add_filter => if (!self.filtered(label) and self.filters.items.len < max_filters) { self.filters.appendAssumeCapacity(label); self.current = 0; }, .remove_filter => if (mem.findScalar(Label, self.filters.items, label)) |i| { _ = self.filters.orderedRemove(i); }, .clear_filters => self.filters.clearRetainingCapacity(), else => unreachable, } self.saveFilters(); } /// What a command does to the threads selected, or else to the one /// under the cursor, then where the cursor goes: it stays at the same /// row, on the next thread if this one left. Done and Trash let the /// threads selected go, as they leave the list. fn onThread(self: *Desk, command: Command) Allocator.Error!void { var one: [1]Mailbox.Id = undefined; const ids = self.targets(&one); if (ids.len == 0) return; try self.act(command, ids); if (command.action == .done or command.action == .trash) self.marked.clearRetainingCapacity(); try self.requeryList(); if (self.thread() == null and self.overlay == .open) self.overlay = .none; } /// The threads an action applies to: those selected, else the one under /// the cursor, if any. An open thread is the one it applies to. fn targets(self: *const Desk, one: *[1]Mailbox.Id) []const Mailbox.Id { if (self.batching()) return self.marked.keys(); one[0] = self.thread() orelse return &.{}; return one; } /// Whether an action applies to the threads selected. fn batching(self: *const Desk) bool { const reading = self.overlay == .open or (self.overlay == .label and self.reader != .null); return self.marked.count() > 0 and !reading; } /// Does `command` to each thread of `ids`, and keeps what it changed for /// `z` to undo. fn act(self: *Desk, command: Command, ids: []const Mailbox.Id) Allocator.Error!void { var entry: Undo = .{ .arena = .init(self.gpa), .what = "", .edits = .empty }; errdefer entry.arena.deinit(); const a = entry.arena.allocator(); const on = self.target(command, ids); for (ids) |id| { const t = self.mail.get(id); const before = try t.labels.clone(a); const unread = t.unread; self.apply(command, id, on); if (before.eql(t.labels) and unread == t.unread) continue; try entry.edits.append(a, .{ .id = id, .before = before, .after = try t.labels.clone(a), .unread_before = unread, .unread_after = t.unread, }); } self.sayDone(command, ids, on); if (entry.edits.items.len == 0) return entry.arena.deinit(); entry.what = try a.dupe(u8, self.notice); if (self.undos.items.len == max_undos) { var oldest = self.undos.orderedRemove(0); oldest.arena.deinit(); } self.undos.appendAssumeCapacity(entry); self.offerUndo(); } /// What a star, a label, or unread is set to on `ids`: a star or a /// label goes on unless all have it, and unread unless one is. fn target(self: *const Desk, command: Command, ids: []const Mailbox.Id) bool { return switch (command.action) { .star => !self.allHave(ids, .starred), .toggle => !self.allHave(ids, command.label), .unread => for (ids) |id| { if (self.mail.get(id).unread) break false; } else true, else => true, }; } fn allHave(self: *const Desk, ids: []const Mailbox.Id, label: Label) bool { return for (ids) |id| { if (!self.mail.has(id, label)) break false; } else true; } /// What a command does to one thread; `on` is what a star, a label, or /// unread is set to. fn apply(self: *Desk, command: Command, id: Mailbox.Id, on: bool) void { switch (command.action) { .done => { self.mail.archive(id); // Out of a view without Inbox too, as it was out of a label's // column: the user labels filtered on come off. A search is no // label's, so there Done only archives. if (self.search.len == 0 and !self.filtered(.inbox)) { for (self.filters.items) |label| { if (self.labelInfo(label).kind == .user) self.mail.setLabel(id, label, false); } } }, .archive => self.mail.archive(id), .trash => self.mail.trash(id), .star => self.mail.setLabel(id, .starred, on), .unread => self.mail.setUnread(id, on), .toggle => self.mail.setLabel(id, command.label, on), else => unreachable, } } /// The notice for what a command did to `ids`: one by its subject, more /// by how many. fn sayDone(self: *Desk, command: Command, ids: []const Mailbox.Id, on: bool) void { const subject = self.mail.get(ids[0]).subject; var many_buf: [24]u8 = undefined; const many = if (ids.len == 1) "" else mem.print(&many_buf, " {d} threads", .{ids.len}) catch ""; const name = self.labelInfo(command.label).name; switch (command.action) { .done, .archive => if (ids.len == 1) self.say("Done: “{s}”", .{subject}) else self.say("Done:{s}", .{many}), .trash => if (ids.len == 1) self.say("Moved “{s}” to Trash", .{subject}) else self.say("Moved{s} to Trash", .{many}), .star => self.say("{s}{s}", .{ if (on) "Starred" else "Unstarred", many }), .unread => self.say("Marked{s} {s}", .{ many, if (on) "unread" else "read" }), .toggle => if (on) self.say("Added {s}{s}{s}", .{ name, if (ids.len == 1) "" else " to", many }) else self.say("Removed {s}{s}{s}", .{ name, if (ids.len == 1) "" else " from", many }), else => unreachable, } } /// Selects the thread to act on with others, or lets it go. fn toggleMark(self: *Desk, id: Mailbox.Id) void { if (self.marked.orderedRemove(id)) return; self.marked.put(self.gpa, id, {}) catch |err| log.err("selecting: {t}", .{err}); } /// Selects the thread under the cursor and the one `delta` rows on, and /// moves there. fn extendMarks(self: *Desk, delta: i32) !bool { const current = self.current orelse return true; const next = step(current, delta, self.ids.len); for ([_]u32{ current, next }) |row| try self.marked.put(self.gpa, self.ids[row], {}); self.current = next; try self.refresh(); return true; } /// Selects the rows from `from` to `to`, both included. fn markRows(self: *Desk, from: u32, to: u32) Allocator.Error!void { for (@min(from, to)..@max(from, to) + 1) |row| try self.marked.put(self.gpa, self.ids[row], {}); } /// Takes back what the last action changed, through the mailbox, so /// Gmail hears it too, and selects the first thread it changed. fn undo(self: *Desk) Allocator.Error!void { var last = self.undos.pop() orelse return self.say("Nothing to undo", .{}); defer last.arena.deinit(); for (last.edits.items) |*e| { const trash = @backingInt(Label.trash); if (!e.before.isSet(trash) and e.after.isSet(trash)) { self.mail.untrash(e.id, &e.before); } else for (0..e.before.bit_length) |i| { const was = e.before.isSet(i); if (was != e.after.isSet(i)) self.mail.setLabel(e.id, @fromBackingInt(@intCast(i)), was); } if (e.unread_before != e.unread_after) self.mail.setUnread(e.id, e.unread_before); } self.say("Undone: {s}", .{last.what}); try self.requeryList(); // An open thread stays open. if (self.overlay == .open) return; if (mem.findScalar(Mailbox.Id, self.ids, last.edits.items[0].id)) |i| self.current = @intCast(i); } /// Offers `z` in the status line for a few seconds. fn offerUndo(self: *Desk) void { self.undo_offered = true; if (self.undo_timer != .null) siv.timerCancel(self.undo_timer); const app = siv.windowGetApp(self.window); self.undo_timer = siv.timerStart(app, undo_offer_ms, endUndoOffer, null, self) catch |err| blk: { log.err("undo timer: {t}", .{err}); break :blk .null; }; } fn endUndoOffer(user_data: ?*anyopaque) callconv(.c) void { const self: *Desk = @ptrCast(@alignCast(user_data)); self.undo_timer = .null; self.undo_offered = false; const txn = siv.txnBegin(self.window) catch |err| return log.err("undo offer: {t}", .{err}); siv.setHidden(txn, self.undo_hint, true); siv.txnCommit(txn) catch |err| log.err("undo offer: {t}", .{err}); } /// Forgets what could be undone, as when the mail is replaced and its /// threads are not where they were. /// Thread `gone` is leaving the mailbox, and the last thread, `last`, /// takes its place, so what the desk keeps by place follows: the threads /// marked and those an undo would change. fn vacate(self: *Desk, gone: Mailbox.Id, last: Mailbox.Id) void { _ = self.marked.orderedRemove(gone); if (last != gone and self.marked.orderedRemove(last)) self.marked.putAssumeCapacity(gone, {}); var i = self.undos.items.len; while (i > 0) { i -= 1; const u = &self.undos.items[i]; var e = u.edits.items.len; while (e > 0) { e -= 1; const edit = &u.edits.items[e]; if (edit.id == gone) { _ = u.edits.orderedRemove(e); } else if (edit.id == last) { edit.id = gone; } } if (u.edits.items.len == 0) { u.arena.deinit(); _ = self.undos.orderedRemove(i); } } } fn forgetUndos(self: *Desk) void { for (self.undos.items) |*u| u.arena.deinit(); self.undos.clearRetainingCapacity(); self.undo_offered = false; } fn say(self: *Desk, comptime fmt: []const u8, args: anytype) void { self.notice = mem.print(&self.notice_buf, fmt, args) catch ""; self.session_notice = false; } /// Shows a message in the status line, until the next one. pub fn notify(self: *Desk, comptime fmt: []const u8, args: anytype) !void { self.say(fmt, args); self.undo_offered = false; try self.refresh(); } /// Shows a session's news in the status line, until mail comes again. pub fn tell(self: *Desk, comptime fmt: []const u8, args: anytype) !void { self.say(fmt, args); self.session_notice = true; self.undo_offered = false; try self.refresh(); } /// Shows `mail` in place of what the desk had, such as Gmail's once it /// loads, and takes it over. Its labels may differ, so the filters come /// back from the saved list, as at start, else by Gmail's ids, and the /// labels get their keys again. The selected thread stays selected, and /// open if it was. With a picker, the palette, the search field, or a /// draft open, it waits until they close, so new mail never snatches any /// from under the user. pub fn replaceMail(self: *Desk, mail: Mailbox) (Allocator.Error || siv.Error)!void { if (self.busy()) { if (self.pending_mail) |*old| old.deinit(); self.pending_mail = mail; return; } // A label or a thread is its place in a mailbox: what is shown and // selected crosses over by Gmail's ids. var shown_buf: [filters_bytes]u8 = undefined; const shown = self.filterIds(&shown_buf, self.filters.items); var before_buf: [filters_bytes]u8 = undefined; const before = self.filterIds(&before_buf, self.before_search.items); var thread_buf: [max_label_id]u8 = undefined; const thread_id = if (self.thread()) |id| copyInto(&thread_buf, self.mail.get(id).id) else ""; const reading = self.overlay == .open; self.mail.deinit(); self.mail.* = mail; self.forgetUndos(); self.marked.clearRetainingCapacity(); // Such as "Loading mail…". self.notice = ""; self.loading = false; if (self.search.len > 0) { self.loadFilters(&self.before_search, before); self.readFilters(&self.filters, shown); } else { self.loadFilters(&self.filters, shown); } // What pointed at threads by place. self.current = null; self.overlay = .none; try self.prepareLabels(); const txn = try siv.txnBegin(self.window); errdefer siv.txnAbort(txn); siv.remove(txn, self.palette_layer); try self.buildPalette(txn, self.layers); try siv.txnCommit(txn); try self.requeryList(); const kept = self.mail.findThread(thread_id); if (kept) |id| self.current = if (mem.findScalar(Mailbox.Id, self.ids, id)) |i| @intCast(i) else 0; if (reading and kept != null and self.thread() == kept) self.overlay = .open; try self.refresh(); } /// Shows what changed in the mail in place, as Gmail's changes put in /// it: threads changed or added, and those `gone` names, Gmail's ids, /// which the desk takes out of its mail itself, as that moves other /// threads' places. The selected thread stays selected, and open if it /// was; when it leaves the list, the next takes its row, and the reader /// and the label picker close. pub fn updated(self: *Desk, gone: []const []const u8) !void { // The mailbox's own copy, which outlasts the thread. const selected = if (self.thread()) |id| self.mail.get(id).id else ""; var buf: [2]*Mailbox = undefined; for (self.mailboxes(&buf)) |m| for (gone) |g| { const id = m.findThread(g) orelse continue; if (m == self.mail) self.vacate(id, @intCast(m.threads.items.len - 1)); m.remove(id); }; // Such as "Could not load mail from Gmail", but not "Draft discarded", // whose change the server may tell of at once. if (self.session_notice) self.notice = ""; self.loading = false; try self.requeryList(); const row = if (self.mail.findThread(selected)) |id| mem.findScalar(Mailbox.Id, self.ids, id) else null; if (row) |i| { self.current = @intCast(i); } else if (selected.len > 0) switch (self.overlay) { .open, .label => self.overlay = .none, else => {}, }; try self.refresh(); } /// Opens the thread Gmail calls `id`, as a click on its notification /// asks: in the list shown if it is there, else in the Inbox. A picker, /// the palette, a draft, or the search field open stays as it is. pub fn reveal(self: *Desk, id: []const u8) !void { if (self.switcher) |s| if (self.hidden) s.show(s.context, s.index); if (self.busy()) return; const t = self.mail.findThread(id) orelse return; try self.requeryList(); if (mem.findScalar(Mailbox.Id, self.ids, t) == null) { self.changeFilters(.{ .action = .show_only, .label = .inbox }); try self.requeryList(); } const i = mem.findScalar(Mailbox.Id, self.ids, t) orelse return self.refresh(); self.current = @intCast(i); _ = try self.run(.of(.open)); } /// Archives the threads their backend calls `names`, as a notification's /// Archive asks; z undoes it as it does Done. pub fn archiveNamed(self: *Desk, names: []const []const u8) !void { var ids: ArrayList(Mailbox.Id) = .empty; defer ids.deinit(self.gpa); for (names) |name| if (self.mail.findThread(name)) |t| { try ids.append(self.gpa, t); _ = self.marked.orderedRemove(t); }; if (ids.items.len == 0) return; try self.act(.of(.archive), ids.items); try self.requeryList(); if (self.thread() == null and self.overlay == .open) self.overlay = .none; try self.refresh(); } /// The thread the reader shows, as Gmail calls it, or null when the /// reader is closed. pub fn opened(self: *const Desk) ?[]const u8 { if (self.reader == .null) return null; const id = self.thread() orelse return null; return self.mail.get(id).id; } /// Whether the user is in the middle of something new mail should wait /// for: a picker, the palette, writing, or searching. fn busy(self: *const Desk) bool { return switch (self.overlay) { .none, .open => false, .goto, .label, .add, .palette, .compose, .search => true, }; } /// The selected thread's newest message, once the reader has read it. fn newest(self: *const Desk) ?*const Mailbox.Message { const id = self.thread() orelse return null; return self.messages.newest(self.mail.get(id).id); } /// The selected thread's `which`th message, or its newest when null, /// once the reader has read it. fn message(self: *const Desk, which: ?u32) ?*const Mailbox.Message { const i = which orelse return self.newest(); const id = self.thread() orelse return null; const list = self.messages.conversation(self.mail.get(id).id); return if (i < list.len) &list[i] else null; } /// Opens the draft: a new message, or a reply to the selected thread's /// `which`th message (its newest when null), which must have been read. fn startCompose(self: *Desk, kind: Compose.Kind, which: ?u32) !bool { if (self.compose.sending) { try self.notify("Still sending the last message", .{}); return true; } const answered = if (kind == .new) null else self.message(which) orelse return self.replyOnceRead(kind, which); const t = if (self.thread()) |id| self.mail.get(id) else null; try self.compose.start(kind, t, answered); try self.showCompose(); // On the card, which says how many are on their way. if (kind == .forward) { try self.compose.expectFiles(Reader.fileCount(answered.?)); try self.messages.forwardFiles(answered.?, self.compose.generation); } return true; } /// Reads the selected thread for a reply to its newest message, as `r` /// asks of a thread not yet opened, and starts the reply when it comes. fn replyOnceRead(self: *Desk, kind: Compose.Kind, which: ?u32) !bool { const id = self.thread() orelse return true; const gmail_id = self.mail.get(id).id; // An earlier message is offered only once the thread is read. if (which != null or gmail_id.len > self.reply_thread_buf.len) { try self.notify("Open the conversation first, to {s}", .{answering(kind)}); return true; } @memcpy(self.reply_thread_buf[0..gmail_id.len], gmail_id); self.reply_thread = self.reply_thread_buf[0..gmail_id.len]; self.reply_waiting = kind; if (!try self.messages.fetchNow(gmail_id)) { self.reply_waiting = null; try self.notify("Open the conversation first, to {s}", .{answering(kind)}); return true; } // Samples come at once, and the reply is open already. if (self.reply_waiting != null) try self.notify("Reading the conversation to {s}", .{answering(kind)}); return true; } /// What a reply or a forward does to a message, as the status line says. fn answering(kind: Compose.Kind) []const u8 { return if (kind == .forward) "forward it" else "reply to it"; } fn showCompose(self: *Desk) !void { self.compose_from = if (self.overlay == .open) .open else .none; self.overlay = .compose; const txn = try siv.txnBegin(self.window); errdefer siv.txnAbort(txn); try self.compose.show(txn); try siv.txnCommit(txn); try self.refresh(); try self.compose.focus(); } fn closeCompose(self: *Desk, comptime notice: []const u8) !bool { const txn = try siv.txnBegin(self.window); errdefer siv.txnAbort(txn); self.compose.hide(txn); try siv.txnCommit(txn); self.overlay = if (self.compose_from == .open and self.thread() != null) .open else .none; try self.notify(notice, .{}); return true; } fn sendDraft(self: *Desk) !bool { self.compose.ready() catch |err| { try self.notify("{s}", .{switch (err) { error.NoSender => "Sign in to an account to send mail", error.NoRecipient => "Who is it to? The To field is empty", error.FilesComing => "Still reading the files to forward", }}); return true; }; // Closed first: the answer may come before send returns. _ = try self.closeCompose("Sending…"); self.compose.send(); return true; } /// Hears whether the draft went. pub fn sent(self: *Desk, ok: bool) !void { self.compose.sent(ok); if (ok) try self.notify("Sent", .{}) else try self.notify("Could not send it: c opens the draft", .{}); } /// Copies what `buf` can hold of `text`, to outlive what holds it. fn copyInto(buf: []u8, text: []const u8) []const u8 { const n = @min(text.len, buf.len); @memcpy(buf[0..n], text[0..n]); return buf[0..n]; } fn setOverlay(self: *Desk, overlay: Overlay) !bool { self.overlay = overlay; try self.refresh(); return true; } /// Opens the search field on what is searched, if a search is shown. fn openSearch(self: *Desk) !bool { self.overlay = .search; const txn = try siv.txnBegin(self.window); errdefer siv.txnAbort(txn); siv.setText(txn, self.search_field, self.search); try siv.txnCommit(txn); try self.refresh(); try siv.nodeFocus(self.search_field); return true; } /// Shows what matches as it is typed, in all the mail: the filters are /// put aside as typing starts, and back if the field is emptied. fn onSearchChange(self: *Desk, event: *const siv.Event) !bool { if (self.overlay != .search) return true; const typed: []const u8 = if (event.text) |t| t[0..event.text_len] else ""; try self.searchTyped(typed, event.caret); return true; } /// The search field's text is `typed`, its caret at byte `caret`. fn searchTyped(self: *Desk, typed: []const u8, caret: u32) !void { const n = @min(typed.len, self.search_buf.len); @memcpy(self.search_buf[0..n], typed[0..n]); self.search_text = self.search_buf[0..n]; const was = self.search.len > 0; self.search = mem.trim(u8, self.search_buf[0..n], " "); self.forgetFound(); self.current = 0; const searching = self.search.len > 0; if (!was and searching) { self.before_search.clearRetainingCapacity(); self.before_search.appendSliceAssumeCapacity(self.filters.items); self.filters.clearRetainingCapacity(); } else if (was and !searching) { self.restoreFilters(); } try self.refresh(); self.armSearch(); try self.suggest(self.search_buf[0..n], @min(caret, n)); } /// The most the search field suggests at once. const max_suggested = 6; /// What the word being typed may become, whole: an operator, or right /// after one a person, a label, or a value, never a bare word's. const Suggested = struct { buf: [96]u8, len: u8, /// Whether it ends the word, so a space follows; an operator's does /// not, for its value. whole: bool, /// How the list shows it, until it is filled again. label: []const u8, detail: []const u8, fn text(s: *const Suggested) []const u8 { return s.buf[0..s.len]; } }; /// The operators of Gmail's syntax that search takes, as they are /// suggested, with what each asks. const search_operators = [_]struct { []const u8, []const u8 }{ .{ "from:", "who sent it" }, .{ "to:", "who it went to" }, .{ "cc:", "who was copied" }, .{ "subject:", "words in its subject" }, .{ "label:", "a label" }, .{ "in:", "a label, or anywhere" }, .{ "is:", "unread, read, or starred" }, .{ "before:", "a date, as 2026/01/31" }, .{ "after:", "a date, as 2026/01/31" }, .{ "older_than:", "an age, as 3d, 2w, 6m, 1y" }, .{ "newer_than:", "an age, as 3d, 2w, 6m, 1y" }, }; /// Fills the list with what the word before byte `caret` of `text` may /// become, and puts it at the word's start; clears it when nothing. fn suggest(self: *Desk, text: []const u8, caret: u32) !void { const start: u32 = if (mem.findScalarLast(u8, text[0..caret], ' ')) |space| @intCast(space + 1) else 0; const end: u32 = if (mem.findScalarPos(u8, text, caret, ' ')) |space| @intCast(space) else @intCast(text.len); self.suggested_word = .{ .start = start, .end = end }; self.suggested_count = 0; const word = text[start..caret]; const negated = word.len > 0 and word[0] == '-'; const sign = word[0..@intFromBool(negated)]; const typed = word[sign.len..]; if (mem.findScalar(u8, typed, ':')) |colon| { const op = typed[0 .. colon + 1]; const value = typed[colon + 1 ..]; if (eqlAny(op, &.{ "from:", "to:", "cc:" })) { var found: [max_suggested]Contacts.Match = undefined; for (self.compose.contacts.suggest(&found, value, &.{})) |m| { const label = if (m.name.len > 0) m.name else m.address; self.offerSuggestion(&.{ sign, op, m.address }, true, label, m.address); } } else if (eqlAny(op, &.{ "label:", "in:" })) { if (ascii.eqlIgnoreCase(op, "in:") and startsWithIgnoreCase("anywhere", value)) { self.offerSuggestion(&.{ sign, "in:anywhere" }, true, "anywhere", "Spam and Trash too"); } // The user's own first: the system's have their keys and in:. for ([_]Mailbox.Kind{ .user, .system }) |kind| for (self.mail.labels.items) |*info| { if (info.kind != kind) continue; const own = info.name[if (mem.findScalarLast(u8, info.name, '/')) |slash| slash + 1 else 0..]; if (!startsWithIgnoreCase(info.name, value) and !startsWithIgnoreCase(own, value)) continue; var name_buf: [64]u8 = undefined; if (info.name.len > name_buf.len) continue; // As the search takes it: no spaces, which end a word. const name = ascii.lowerString(&name_buf, info.name); mem.replaceScalar(u8, name, ' ', '-'); self.offerSuggestion(&.{ sign, op, name }, true, info.name, ""); }; } else if (ascii.eqlIgnoreCase(op, "is:")) { for ([_][]const u8{ "unread", "read", "starred" }) |v| { if (!startsWithIgnoreCase(v, value)) continue; self.offerSuggestion(&.{ sign, "is:", v }, true, v, ""); } } } else if (typed.len > 0) for (search_operators) |o| { if (o[0].len <= typed.len or !startsWithIgnoreCase(o[0], typed)) continue; self.offerSuggestion(&.{ sign, o[0] }, false, o[0], o[1]); }; const txn = try siv.txnBegin(self.window); errdefer siv.txnAbort(txn); siv.suggestionListClear(txn, self.suggestions); for (self.suggested[0..self.suggested_count]) |*s| { _ = siv.suggestionListItem(txn, self.suggestions, .{ .label = s.label, .detail = s.detail }); } const at = siv.nodeGetCaretRect(self.search_field, start) catch siv.Rect{}; siv.setLayout(txn, self.caret_mark, .{ .width = kit.px(1), .height = kit.whole, .inset_start = at.x, .flags = .{ .inset_start = true }, }); try siv.txnCommit(txn); } /// Adds the suggestion made of `parts`, shown as `label` and `detail`, /// unless the list is full or it is too long. fn offerSuggestion(self: *Desk, parts: []const []const u8, whole: bool, label: []const u8, detail: []const u8) void { if (self.suggested_count == max_suggested) return; const s = &self.suggested[self.suggested_count]; var len: usize = 0; for (parts) |p| { if (len + p.len > s.buf.len) return; @memcpy(s.buf[len..][0..p.len], p); len += p.len; } s.len = @intCast(len); s.whole = whole; s.label = label; s.detail = detail; self.suggested_count += 1; } /// A suggestion chosen by Tab, Enter, or a click. fn onSuggestion(self: *Desk, event: *const siv.Event) !bool { if (event.index < self.suggested_count) try self.takeSuggestion(@intCast(event.index)); return true; } /// Puts suggestion `i` in place of the word it was for, and suggests /// again: an operator taken goes on to its values. fn takeSuggestion(self: *Desk, i: u8) !void { const s = &self.suggested[i]; var old_buf: [128]u8 = undefined; const old = old_buf[0..self.search_text.len]; @memcpy(old, self.search_text); const w = self.suggested_word; var buf: [128]u8 = undefined; var out: Io.Writer = .fixed(&buf); const rest = mem.trimStart(u8, old[w.end..], " "); out.print("{s}{s}{s}", .{ old[0..w.start], s.text(), if (s.whole) " " else "" }) catch return; const caret: u32 = @intCast(out.end); if (rest.len > 0) out.print("{s}{s}", .{ if (s.whole) "" else " ", rest }) catch return; const text = out.buffered(); const txn = try siv.txnBegin(self.window); errdefer siv.txnAbort(txn); // Neither sends a change event, so the search hears it here. siv.setText(txn, self.search_field, text); siv.setSelection(txn, self.search_field, caret, caret); try siv.txnCommit(txn); try self.searchTyped(text, caret); } /// Whether `key` is for the suggestions showing, which the field gives /// them: Up, Down, Tab, Enter, and Esc. fn suggestionKey(self: *const Desk, key: Key) bool { if (key.mods.ctrl or key.mods.alt) return false; if (!(siv.nodeIsShown(self.suggestions) catch false)) return false; for ([_]siv.Key{ .up, .down, .tab, .@"return", .escape }) |k| if (key.isNamed(k)) return true; return false; } /// Moves the suggestions' highlight by `delta`, wrapping, as Down and Up /// do: Ctrl+N and Ctrl+P. fn moveSuggestion(self: *Desk, delta: i32) !bool { if (!(siv.nodeIsShown(self.suggestions) catch false)) return true; const at: i32 = @intFromFloat(try siv.widgetGetValue(self.suggestions)); const count: i32 = self.suggested_count; const next = if (at < 0) (if (delta > 0) 0 else count - 1) else @mod(at + delta, count); const txn = try siv.txnBegin(self.window); errdefer siv.txnAbort(txn); siv.widgetSetValue(txn, self.suggestions, @floatFromInt(next)); try siv.txnCommit(txn); return true; } fn eqlAny(s: []const u8, options: []const []const u8) bool { for (options) |o| if (ascii.eqlIgnoreCase(s, o)) return true; return false; } fn startsWithIgnoreCase(s: []const u8, prefix: []const u8) bool { return s.len >= prefix.len and ascii.eqlIgnoreCase(s[0..prefix.len], prefix); } /// Asks the server for the search once typing pauses, if it is long /// enough to. fn armSearch(self: *Desk) void { if (self.search_timer != .null) siv.timerCancel(self.search_timer); self.search_timer = .null; const p = self.pages orelse return; if (self.search.len < p.shortest) return; const app = siv.windowGetApp(self.window); self.search_timer = siv.timerStart(app, p.pause_ms, onSearchPause, null, self) catch |err| blk: { log.err("search timer: {t}", .{err}); break :blk .null; }; } fn onSearchPause(user_data: ?*anyopaque) callconv(.c) void { const self: *Desk = @ptrCast(@alignCast(user_data)); self.search_timer = .null; self.askServer() catch |err| log.err("searching: {t}", .{err}); } /// Enter in the search field: takes the search and goes to what it /// found, asking the server now, however short the search, if it has /// not been; an empty search ends. fn searchEnter(self: *Desk) !bool { self.overlay = .none; if (self.search.len == 0) return self.endSearch(); try self.askServer(); try self.refresh(); return true; } /// Asks the server for the search, which reaches mail not loaded; what /// it finds joins what matched as it was typed. Once a search: not /// again while it is asked or once it is answered. fn askServer(self: *Desk) !void { if (self.search_timer != .null) siv.timerCancel(self.search_timer); self.search_timer = .null; const p = self.pages orelse return; if (self.search.len == 0) return; var key_buf: [view_key_bytes]u8 = undefined; const key = self.viewKey(&key_buf); if (mem.eql(u8, key, self.paged_view) and (self.paging == .asking or self.found_ready)) return; self.say("Searching {s}…", .{self.server}); self.found_ready = false; @memcpy(self.paged_view_buf[0..key.len], key); self.paged_view = self.paged_view_buf[0..key.len]; self.paging = .asking; p.more(p.context, &.{}, self.search, true, self.reach); try self.refresh(); } /// What a search found goes with the search. fn forgetFound(self: *Desk) void { self.found_ready = false; self.found_since = null; self.reach = .any; self.server_asked = false; } /// Asks the server for the search the index answered from the mail it /// holds, which goes back only so far: what it finds joins the list. fn searchServer(self: *Desk) !bool { const p = self.pages orelse return true; if (self.search.len == 0 or self.found_since == null) return true; self.reach = .server; self.found_since = null; self.server_asked = true; self.paging = .asking; self.say("Searching {s}…", .{self.server}); p.more(p.context, &.{}, self.search, true, .server); try self.refresh(); return true; } /// Ends the search and puts back the filters from before it. fn endSearch(self: *Desk) !bool { if (self.search.len > 0) self.restoreFilters(); self.search = ""; self.forgetFound(); self.current = 0; try self.refresh(); return true; } fn restoreFilters(self: *Desk) void { self.filters.clearRetainingCapacity(); self.filters.appendSliceAssumeCapacity(self.before_search.items); self.before_search.clearRetainingCapacity(); } /// Takes more of a list from Gmail, its threads already added to the /// mail: `ids` for the view of `labels` (Gmail's ids) and `search`, or /// null when asking failed. For a search, `ids` is what Gmail found, /// which the list shows with what matched as it was typed; for labels, the list shows the new threads /// already. An answer for another view than the one shown is dropped. pub fn paged( self: *Desk, labels: []const []const u8, search: []const u8, ids: ?[]const []const u8, more: bool, since: ?i64, ) !void { var key_buf: [view_key_bytes]u8 = undefined; var key: ArrayList(u8) = .initBuffer(&key_buf); if (search.len > 0) { key.appendAssumeCapacity('/'); key.appendSliceAssumeCapacity(search[0..@min(search.len, self.search_buf.len)]); } else for (labels) |id| { if (id.len > max_label_id or key.unusedCapacitySlice().len <= id.len) continue; key.appendSliceAssumeCapacity(id); key.appendAssumeCapacity('\n'); } if (!mem.eql(u8, key.items, self.paged_view) or self.paging != .asking) return self.grew(); const list = ids orelse { self.paging = .failed; self.server_asked = false; return self.notify("Could not load more from {s}", .{self.server}); }; self.paging = if (more) .idle else .exhausted; if (search.len == 0) return self.grew(); if (!self.found_ready) { // The search's first page joins what matched locally, which it // need not hold: the server matches whole words, where typing // matched any part of one, so "fede" keeps Federico's mail. self.found.clearRetainingCapacity(); _ = self.found_arena.reset(.retain_capacity); const arena = self.found_arena.allocator(); for (self.ids) |id| try self.found.put(self.gpa, try arena.dupe(u8, self.mail.get(id).id), {}); const local = self.found.count(); self.found_ready = true; self.found_since = since; for (list) |id| try self.found.put(self.gpa, try arena.dupe(u8, id), {}); const then = if (more) ", and more as you scroll" else ""; var day_buf: [32]u8 = undefined; if (since) |date| self.say("Found {d} in the mail since {s}{s}", .{ list.len, self.day(&day_buf, date), then }) else if (list.len == 0 and local > 0) self.say("{s} found nothing more", .{self.server}) else self.say("{s} found {d}{s}", .{ self.server, list.len, then }); // The same thread stays selected among more. const selected = self.thread(); try self.requeryList(); if (selected) |id| if (mem.findScalar(Mailbox.Id, self.ids, id)) |i| { self.current = @intCast(i); }; return self.refresh(); } for (list) |id| try self.found.put(self.gpa, try self.found_arena.allocator().dupe(u8, id), {}); if (self.server_asked) { self.server_asked = false; self.say("{s} found {d}{s}", .{ self.server, list.len, if (more) ", and more as you scroll" else "" }); } try self.grew(); } /// The day `then` came, as the list writes it: "Oct 12", with the year /// when it is not this one's. fn day(self: *const Desk, buf: []u8, then: i64) []const u8 { return kit.day(buf, siv.windowGetApp(self.window), &self.zone, self.now(), then); } /// Shows threads added to the mail meanwhile, such as older mail the /// backfill read: the rows and the counts, with the same thread /// selected and the list left where it is scrolled. pub fn grew(self: *Desk) !void { // They are shown with it. if (self.pending_mail != null) return; const selected = self.thread(); try self.requeryList(); if (selected) |id| { if (mem.findScalar(Mailbox.Id, self.ids, id)) |i| self.current = @intCast(i); } // First, so the status line shows whether it asks. self.wantMore(); 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); self.renderEmpty(txn); if (self.current) |c| siv.viewSetSelected(txn, self.list, c, true); var counts_buf: [64]u8 = undefined; siv.setText(txn, self.counts, self.countsText(&counts_buf)); // A page that came ends the wait the status line shows. self.renderStatus(txn); try siv.txnCommit(txn); } /// The mail to put what Gmail sends in: the desk's, and the mail waiting /// to replace it, which would lose it otherwise. pub fn mailboxes(self: *Desk, buf: *[2]*Mailbox) []const *Mailbox { buf[0] = self.mail; if (self.pending_mail) |*waiting| { buf[1] = waiting; return buf[0..2]; } return buf[0..1]; } /// Room for a view's key: the filters' ids, or a search. const view_key_bytes = filters_bytes + 1; /// What the list shows, as `paged_view` holds it. fn viewKey(self: *const Desk, buf: *[view_key_bytes]u8) []const u8 { if (self.search.len == 0) return self.filterIds(buf[0..filters_bytes], self.filters.items); buf[0] = '/'; @memcpy(buf[1..][0..self.search.len], self.search); return buf[0 .. 1 + self.search.len]; } /// Asks Gmail for more of what the list shows once it is scrolled near /// its end, once for each view until the answer comes. A search asks /// only once the server has answered it: before, it matches the loaded /// mail alone. fn wantMore(self: *Desk) void { const p = self.pages orelse return; if (self.search.len > 0 and !self.found_ready) return; var key_buf: [view_key_bytes]u8 = undefined; const key = self.viewKey(&key_buf); if (!mem.eql(u8, key, self.paged_view)) { @memcpy(self.paged_view_buf[0..key.len], key); self.paged_view = self.paged_view_buf[0..key.len]; self.paging = .idle; // Another view starts at its top; its own viewport event says // where it is. self.viewport.y = 0; } if (self.paging != .idle) return; const below: f32 = @floatFromInt(self.ids.len * row_height); if (below - (self.viewport.y + self.viewport.height) > rows_ahead * row_height) return; self.paging = .asking; var ids: [max_filters][]const u8 = undefined; const labels = if (self.search.len > 0) ids[0..0] else ids[0..self.filters.items.len]; for (labels, self.filters.items[0..labels.len]) |*id, l| id.* = self.labelInfo(l).id; p.more(p.context, labels, self.search, false, self.reach); } fn onViewport(self: *Desk, event: *const siv.Event) !bool { self.viewport = .{ .y = event.y, .height = event.height }; // Scrolling tries again after a failure. if (self.paging == .failed) self.paging = .idle; self.wantMore(); return false; } fn openPalette(self: *Desk) !bool { self.palette_from = if (self.overlay == .compose) .compose else .none; self.overlay = .palette; self.filter = ""; self.selected = 0; const txn = try siv.txnBegin(self.window); errdefer siv.txnAbort(txn); siv.setText(txn, self.field, ""); try siv.txnCommit(txn); try self.refresh(); try siv.nodeFocus(self.field); return true; } /// Closes the palette, back to writing if it opened over the draft. fn leavePalette(self: *Desk) !bool { _ = try self.setOverlay(self.palette_from); if (self.palette_from == .compose) try self.compose.focusBody(); return true; } fn movePalette(self: *Desk, delta: i32) !bool { if (self.shown.len == 0) return true; self.selected = step(self.selected, delta, self.shown.len); const txn = try siv.txnBegin(self.window); errdefer siv.txnAbort(txn); self.syncPalette(txn); try siv.txnCommit(txn); return true; } /// How many rows half the list's height holds, at least one. fn halfPageRows(self: *const Desk) i32 { return @max(1, @as(i32, @intFromFloat(self.viewport.height / (2 * row_height)))); } fn moveRow(self: *Desk, delta: i32) !bool { const current = self.current orelse return true; self.current = step(current, delta, self.ids.len); if (self.overlay == .open) self.seeing(self.ids[self.current.?]); try self.refresh(); return true; } fn step(index: u32, delta: i32, len: usize) u32 { const last: i32 = @intCast(len - 1); return @intCast(math.clamp(@as(i32, @intCast(index)) + delta, 0, last)); } // State. /// The filters as the ids Gmail gives their labels, one per line, in the /// order they were added. pub const filters_file = "filters"; /// Room in that file for a label id; Gmail's are much shorter. const max_label_id = 64; const filters_bytes = max_filters * (max_label_id + 1); /// The saved filters into `list`, or `fallback`, label ids as the file /// holds them, when none were saved. fn loadFilters(self: *Desk, list: *ArrayList(Label), fallback: []const u8) void { var buf: [filters_bytes]u8 = undefined; const saved = if (self.settings) |s| s.load(filters_file, &buf) else null; self.readFilters(list, saved orelse fallback); } /// Replaces `list` with the labels `ids` names, one per line. A line /// naming no label is skipped, so a deleted label does not lose the rest. fn readFilters(self: *const Desk, list: *ArrayList(Label), ids: []const u8) void { list.clearRetainingCapacity(); var lines = mem.tokenizeScalar(u8, ids, '\n'); while (lines.next()) |line| { const label = self.mail.findLabel(line) orelse continue; if (mem.findScalar(Label, list.items, label) != null or list.items.len == max_filters) continue; list.appendAssumeCapacity(label); } } /// `labels` as their ids, one per line, as the file holds them. fn filterIds(self: *const Desk, buf: *[filters_bytes]u8, labels: []const Label) []const u8 { var out: ArrayList(u8) = .initBuffer(buf); for (labels) |label| { const id = self.labelInfo(label).id; if (id.len > max_label_id) continue; out.appendSliceAssumeCapacity(id); out.appendAssumeCapacity('\n'); } return out.items; } /// Saves the filters, those from before a search while one is shown: a /// search's are for it alone. fn saveFilters(self: *Desk) void { const settings = self.settings orelse return; const labels = if (self.search.len > 0) self.before_search.items else self.filters.items; var buf: [filters_bytes]u8 = undefined; settings.save(filters_file, self.filterIds(&buf, labels)); } /// The selected thread, or null when there is none. fn thread(self: *const Desk) ?Mailbox.Id { return if (self.current) |c| self.ids[c] else null; } fn requeryList(self: *Desk) Allocator.Error!void { const searching = self.search.len > 0; // Typed, it narrows the loaded mail; once Gmail answers, its answer does. const typing = searching and !self.found_ready; self.search_query.deinit(self.gpa); // Empty, should init fail, so nothing is freed twice. self.search_query = .{ .terms = undefined, .len = 0 }; self.search_query = try .init(self.gpa, self.search, self.now(), self.mail); try self.mail.query(.{ .labels = self.filters.items, .query = if (typing) &self.search_query else null, .among = if (searching and self.found_ready) &self.found else null, }, &self.ids_buf); self.ids = self.ids_buf.items; self.current = if (self.ids.len == 0) null else @min(self.current orelse 0, self.ids.len - 1); // What is selected stays in sight: a thread that leaves the list is // let go. var i = self.marked.count(); while (i > 0) { i -= 1; if (mem.findScalar(Mailbox.Id, self.ids, self.marked.keys()[i]) == null) self.marked.orderedRemoveAt(i); } } // Drawing. fn refresh(self: *Desk) !void { // Mail that waited for the user is shown as soon as they are done. if (self.pending_mail) |mail| if (!self.busy()) { self.pending_mail = null; return self.replaceMail(mail); }; try self.requeryList(); if (self.overlay == .palette) self.filterCommands(); const txn = try siv.txnBegin(self.window); errdefer siv.txnAbort(txn); self.renderList(txn); // The header's chips, the reader, and the pending bar are made again // below, and the old ones' handlers go at this commit. _ = self.scratch.reset(.retain_capacity); try self.renderHeader(txn); try self.renderReader(txn); self.renderStatus(txn); try self.renderPending(txn); siv.setHidden(txn, self.palette_layer, self.overlay != .palette); self.syncPalette(txn); try siv.txnCommit(txn); if (self.switcher) |s| s.changed(s.context); if (self.hidden) return self.wantMore(); switch (self.overlay) { .palette, .search, .compose => {}, else => try siv.nodeFocus(self.list), } self.wantMore(); } fn renderList(self: *Desk, txn: siv.Txn) void { siv.viewSetCount(txn, self.list, @intCast(self.ids.len)); siv.viewRefresh(txn, self.list); self.renderEmpty(txn); const current = self.current orelse return; siv.viewSetSelected(txn, self.list, current, true); siv.viewScrollTo(txn, self.list, current); } /// The title, the counts, and a chip per filter, after the search's. fn renderHeader(self: *Desk, txn: siv.Txn) !void { var title_buf: [160]u8 = undefined; siv.setText(txn, self.title, self.shownName(&title_buf)); var counts_buf: [64]u8 = undefined; siv.setText(txn, self.counts, self.countsText(&counts_buf)); try self.renderAccounts(txn); const scratch = self.scratch.allocator(); var key: ArrayList(u8) = .empty; try key.print(scratch, "{s}\x00", .{self.search}); for (self.filters.items) |label| { const info = self.labelInfo(label); try key.print(scratch, "{d} {s} {x}\x00", .{ @backingInt(label), info.name, mem.asBytes(&info.color) }); } if (mem.eql(u8, key.items, self.chips_key.items)) return; self.chips_key.clearRetainingCapacity(); try self.chips_key.appendSlice(self.gpa, key.items); removeChildren(txn, self.filter_chips); _ = self.chips_arena.reset(.retain_capacity); const arena = self.chips_arena.allocator(); if (self.search.len > 0) { const name = try arena.print("“{s}”", .{self.search}); const chip = siv.chip(txn, self.filter_chips, .{ .label = name, .flags = .{ .removable = true } }); // Removing it is Escape: it ends the search. try kit.handle(Desk, Key, kit.press(Desk), arena, txn, chip, .close, self, .named(.escape)); } for (self.filters.items) |label| { const info = self.labelInfo(label); // Its ✕, a click on it, or Delete removes the filter. const chip = kit.chip(txn, self.filter_chips, info, .{ .removable = true }); const remove: Command = .{ .action = .remove_filter, .label = label }; try kit.handle(Desk, Command, runCommand, arena, txn, chip, .close, self, remove); } } /// The account's name before the title, and after the counts a button /// for each other account: its key, its name, and its Inbox's unread /// count, with a dot when mail came since the user last looked. fn renderAccounts(self: *Desk, txn: siv.Txn) !void { removeChildren(txn, self.others); const s = self.switcher orelse return; const scratch = self.scratch.allocator(); siv.setText(txn, self.account_name, try scratch.print("{s} /", .{s.name})); siv.setHidden(txn, self.account_name, false); var buf: [max_accounts]Other = undefined; for (s.others(s.context, &buf)) |o| { const button = siv.flex(txn, self.others, .{ .gap = 6, .align_items = .center }); siv.setLayout(txn, button, .{ .padding = .{ .start = 4, .end = 4, .top = 2, .bottom = 2 } }); const label = try scratch.print("{s}, {d} unread", .{ o.name, o.unread }); const command: Command = .{ .action = .switch_account, .label = .inbox, .account = o.index }; try kit.clickable(Desk, Command, runCommand, scratch, txn, button, self, command, label); _ = kit.keycap(txn, button, ctrl_digits[o.index]); if (o.fresh) _ = kit.dot(txn, button, self.labelInfo(.inbox).color, 6); _ = kit.text(txn, button, try scratch.print("{s} {d}", .{ o.name, o.unread }), .{ .size_role = .caption, .color_token = if (o.fresh) .view_fg else .muted_fg, }); } } /// Hides this desk while another account's shows, or shows it, with the /// list or what was open taking focus. pub fn show(self: *Desk, shown: bool) !void { self.hidden = !shown; const txn = try siv.txnBegin(self.window); errdefer siv.txnAbort(txn); siv.setHidden(txn, self.layers, !shown); try siv.txnCommit(txn); if (shown) try self.refresh(); } /// Draws the other accounts' counts again, as when their mail changed. pub fn refreshAccounts(self: *Desk) !void { const txn = try siv.txnBegin(self.window); errdefer siv.txnAbort(txn); try self.renderAccounts(txn); try siv.txnCommit(txn); } /// "Inbox + Work", All Mail with no filter, or Search during one. fn shownName(self: *const Desk, buf: []u8) []const u8 { if (self.search.len > 0) return "Search"; 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(self.labelInfo(label).name) catch break; } return w.buffered(); } /// What an empty list says, and `-` while a filter it can take away is /// why: not a search's, which says what to do itself, nor the Inbox's /// alone, which is done. fn renderEmpty(self: *Desk, txn: siv.Txn) void { siv.setHidden(txn, self.empty, self.ids.len > 0); if (self.ids.len > 0) return; var buf: [192]u8 = undefined; siv.setText(txn, self.empty_text, self.emptyText(&buf)); const inbox_zero = self.filters.items.len == 1 and self.filters.items[0] == .inbox; const offer = self.search.len == 0 and self.filters.items.len > 0 and !inbox_zero; siv.setHidden(txn, self.empty_hints, !offer); // Enter asks the server until it has answered. siv.setHidden(txn, self.empty_search_hints, self.search.len == 0); siv.setHidden(txn, siv.nodeGetRelated(self.empty_search_hints, .first_child), self.found_ready); const older = siv.nodeGetRelated(siv.nodeGetRelated(self.empty_search_hints, .first_child), .next); siv.setHidden(txn, older, self.found_since == null); } /// Why the list is empty, and what to do about it. fn emptyText(self: *const Desk, buf: []u8) []const u8 { if (self.search.len > 0) { var day_buf: [32]u8 = undefined; return if (self.found_since) |since| blk: { const day_text = self.day(&day_buf, since); break :blk mem.print(buf, "Nothing for “{s}” in the mail since {s}", .{ self.search, day_text }) catch "Nothing found"; } else if (self.found_ready) mem.print(buf, "{s} found nothing for “{s}”", .{ self.server, self.search }) catch "Nothing found" else mem.print(buf, "Nothing loaded matches “{s}”", .{self.search}) catch "Nothing matches"; } if (self.mail.threads.items.len == 0) return "No mail yet"; if (self.filters.items.len == 1 and self.filters.items[0] == .inbox) return "Inbox zero. Nothing left to do."; var name_buf: [160]u8 = undefined; const name = self.shownName(&name_buf); return mem.print(buf, "Nothing in {s}", .{name}) catch "Nothing here"; } /// "13 threads, 7 unread", of what is shown: no unread count when none /// is, and nothing when the list is empty, whose own text says so. fn countsText(self: *const Desk, buf: []u8) []const u8 { var unread: u32 = 0; for (self.ids) |id| { if (self.mail.get(id).unread) unread += 1; } const total = self.ids.len; if (total == 0) return ""; const plural = if (total == 1) "" else "s"; if (unread == 0) return mem.print(buf, "{d} thread{s}", .{ total, plural }) catch ""; return mem.print(buf, "{d} thread{s}, {d} unread", .{ total, plural, unread }) catch ""; } 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; } } fn renderReader(self: *Desk, txn: siv.Txn) !void { const showing = self.overlay == .open or (self.overlay == .label and self.reader != .null); const id = self.thread(); siv.setHidden(txn, self.reader_layer, !showing or id == null); if (!showing or id == null) { if (self.reader != .null) siv.remove(txn, self.reader); self.reader = .null; self.messages.close(); _ = self.reader_arena.reset(.retain_capacity); return; } const t = self.mail.get(id.?); if (self.reader == .null) try self.openReader(txn); siv.setSemantics(txn, self.reader, .{ .role = .dialog, .label = t.subject }); if (self.reader_head != .null) siv.remove(txn, self.reader_head); self.reader_head = siv.flex(txn, self.reader, .{ .axis = .column, .gap = 10, .align_items = .stretch }); siv.setLayout(txn, self.reader_head, .{ .padding = .{ .end = Reader.margin } }); siv.move(txn, self.reader_head, self.reader, self.reader_body); const heading: siv.TextStyleDesc.Options = .{ .size_role = .heading, .weight = .bold, .color_token = .view_fg }; // Starred is a gold star before the subject, as in the list. const starred = hasLabel(t, .starred); const title = kit.text(txn, self.reader_head, if (starred) try self.scratch.allocator().print("★ {s}", .{t.subject}) else t.subject, heading); if (starred) siv.setTextSpans(txn, title, .{ .spans = &.{.{ .start = 0, .end = "★".len, .flags = .{ .color = true }, .color = kit.color(self.labelInfo(.starred).color), }} }); var age_buf: [64]u8 = undefined; const meta = try self.scratch.allocator().print("{s} · {d} message{s} · {s}", .{ t.participants, t.messages, if (t.messages == 1) "" else "s", kit.ago(&age_buf, siv.windowGetApp(self.window), &self.zone, self.now(), t.date), }); const about = siv.flex(txn, self.reader_head, .{ .gap = 12, .align_items = .center, .flags = .{ .wrap = true } }); _ = kit.text(txn, about, meta, .{ .size_role = .caption, .color_token = .muted_fg }); // The labels the list's row shows, so the card says what it said. const chips = siv.flex(txn, about, .{ .gap = 6, .flags = .{ .wrap = true } }); self.addChips(txn, chips, t); try self.messages.render(txn, self.reader_body, t, &self.zone, self.now()); } /// The open thread's card: most of the window's height, its messages /// scrolling between the head and the hints. fn openReader(self: *Desk, txn: siv.Txn) !void { // The view's own background, which the messages' documents share. self.reader = siv.card(txn, self.reader_layer, .{ .background = .view_bg, .flags = .{ .accent = true } }); siv.setFlex(txn, self.reader, .{ .axis = .column, .gap = 14, .align_items = .stretch }); siv.setLayout(txn, self.reader, .{ // Room for mail made for 700px and more, as newsletters often // are, beside its margins; a narrow window keeps its edges. .width = .{ .unit = .fraction, .value = 0.94 }, .max_width = 1100, .height = .{ .unit = .fraction, .value = 0.9 }, // No end padding: the messages scroll to the card's edge, so the // scrollbar floats over their margin, not over what they say. .padding = .{ .start = Reader.margin, .top = 20, .bottom = 4 }, }); self.reader_head = .null; self.reader_body = siv.box(txn, self.reader); siv.setLayout(txn, self.reader_body, .{ .grow = 1, .basis = kit.px(0) }); // The keys used on every thread; the rest are in the palette, as the // status line's ^K says. const hints = try kit.hintBar(Desk, self.reader_arena.allocator(), txn, self.reader, self, &.{ .{ .key = .named(.escape), .cap = "esc", .action = "close" }, .{ .key = .char('j'), .cap = "j/k", .action = "scroll" }, .{ .key = .char('J'), .cap = "J/K", .action = "next thread" }, .{ .key = .char('r'), .cap = "r", .action = "reply" }, .{ .key = .char('f'), .cap = "f", .action = "forward" }, .{ .key = .char('e'), .cap = "e", .action = "done" }, .{ .key = .char('s'), .cap = "s", .action = "star" }, }); // On the card's margin, as the messages are, not a bar's own inset. siv.setLayout(txn, hints, .{ .padding = .{ .end = Reader.margin, .top = 8, .bottom = 8 } }); } fn redrawFromReader(context: *anyopaque) void { const self: *Desk = @ptrCast(@alignCast(context)); self.refresh() catch |err| log.err("showing messages: {t}", .{err}); } fn readFromReader(context: *anyopaque, gmail_id: []const u8) void { const self: *Desk = @ptrCast(@alignCast(context)); if (self.mail.findThread(gmail_id)) |id| self.mail.setUnread(id, false); } fn cameFromReader(context: *anyopaque, gmail_id: []const u8, ok: bool) void { const self: *Desk = @ptrCast(@alignCast(context)); const kind = self.reply_waiting orelse return; if (!mem.eql(u8, gmail_id, self.reply_thread)) return; self.reply_waiting = null; self.notice = ""; if (!ok) return self.notify("Could not read the conversation to {s}", .{answering(kind)}) catch |err| log.err("replying: {t}", .{err}); // Only to the thread still selected, not one the user moved on from. const id = self.thread() orelse return; if (!mem.eql(u8, self.mail.get(id).id, gmail_id)) return self.refresh() catch |err| log.err("replying: {t}", .{err}); _ = self.startCompose(kind, null) catch |err| log.err("replying: {t}", .{err}); } /// The user opens thread `id`. It is read once its messages show (the /// reader says so), not before: marked read first, Gmail would say none /// of them were unread when the reader asks for them, and they would /// fold. fn seeing(self: *Desk, id: Mailbox.Id) void { const t = self.mail.get(id); if (self.messages.holds(t.id, t.messages)) self.mail.setUnread(id, false); } fn sayFromReader(context: *anyopaque, text: []const u8) void { const self: *Desk = @ptrCast(@alignCast(context)); self.notify("{s}", .{text}) catch |err| log.err("status line: {t}", .{err}); } /// A mailto: link the reader followed, to unsubscribe: the user sends it. fn composeFromReader(context: *anyopaque, to: []const u8, subject: []const u8, body: []const u8) void { const self: *Desk = @ptrCast(@alignCast(context)); self.composeTo(to, subject, body) catch |err| switch (err) { error.DraftKept => self.notify("Send or discard the draft first: c opens it", .{}) catch |e| log.err("status line: {t}", .{e}), else => log.err("composing: {t}", .{err}), }; } /// A Reply or Reply all button under one of the reader's messages. fn replyFromReader(context: *anyopaque, which: u32, kind: Compose.Kind) void { const self: *Desk = @ptrCast(@alignCast(context)); const action: Action = switch (kind) { .new, .reply => .reply, .reply_all => .reply_all, .forward => .forward, }; _ = self.run(.{ .action = action, .label = .inbox, .message = which }) catch |err| log.err("replying: {t}", .{err}); } fn attachFromReader(context: *anyopaque, draft: u32, name: []const u8, mime: []const u8, bytes: ?[]const u8) void { const self: *Desk = @ptrCast(@alignCast(context)); self.compose.forwardedFile(draft, name, mime, bytes) catch |err| log.err("forwarding a file: {t}", .{err}); } fn composeTo(self: *Desk, to: []const u8, subject: []const u8, body: []const u8) !void { try self.compose.startTo(to, subject, body); try self.showCompose(); } /// The notice, else a hint; the header already says what is shown. fn renderStatus(self: *Desk, txn: siv.Txn) void { siv.setText(txn, self.mode, switch (self.overlay) { .none => if (self.search.len > 0) "RESULTS" else "MAIL", .goto => "SHOW ONLY", .add => "ADD FILTER", .label => "LABEL", .palette => "COMMAND", .open => "READ", .compose => self.compose.vimMode() orelse "COMPOSE", .search => "SEARCH", }); // Vim's command line, as it is typed, over any notice. const vim = if (self.overlay == .compose) self.compose.vimStatus() else ""; const line = if (vim.len > 0) vim else self.notice; const noticed = line.len > 0; siv.setText(txn, self.status_line, line); const marking = self.marked.count() > 0; siv.setHidden(txn, self.hints, noticed or marking or self.search.len > 0); siv.setHidden(txn, self.search_hints, noticed or marking or self.search.len == 0); siv.setHidden(txn, self.mark_hints, noticed or !marking); var count_buf: [24]u8 = undefined; siv.setText(txn, self.marked_count, mem.print(&count_buf, "{d} selected", .{self.marked.count()}) catch ""); siv.setHidden(txn, self.marked_count, !marking); siv.setHidden(txn, self.undo_hint, !noticed or !self.undo_offered); // The empty list offers it itself. const older = self.search.len > 0 and self.found_since != null and self.ids.len > 0; siv.setHidden(txn, self.older_hint, marking or !older); siv.setHidden(txn, self.spinner, !self.loading and self.paging != .asking); const pending = self.overlay == .goto or self.overlay == .label or self.overlay == .add; siv.setHidden(txn, self.status, pending or self.overlay == .search); siv.setHidden(txn, self.search_bar, self.overlay != .search); } /// After `g`, `t`, or `+`, the status line becomes the labels the next /// key can name, each one clickable. fn renderPending(self: *Desk, txn: siv.Txn) !void { if (self.pending != .null) siv.remove(txn, self.pending); self.pending = .null; // The mode says what the keys do: show only, add a filter, label. switch (self.overlay) { .goto, .label, .add => {}, else => return, } var one: [1]Mailbox.Id = undefined; const ids = self.targets(&one); const scratch = self.scratch.allocator(); var hints: ArrayList(kit.Hint) = .empty; // Escape first, where it stays put however many labels follow. const done = if (self.overlay == .label) "done" else "cancel"; try hints.append(scratch, .{ .key = .named(.escape), .cap = "esc", .action = done }); // Labels without a key are left to the palette. for (self.keys, 0..) |key, i| { const label: Label = @fromBackingInt(@intCast(i)); const info = self.labelInfo(label); if (key == 0) continue; if (self.overlay == .label and info.kind != .user) continue; if (self.overlay == .add and self.filtered(label)) continue; const on = self.overlay == .label and ids.len > 0 and self.allHave(ids, label); const action = if (on) try scratch.print("{s} ✓", .{info.name}) else info.name; try hints.append(scratch, .{ .key = .char(key), .cap = self.keyCap(label), .action = action }); } // Many labels wrap the keys onto more lines. self.pending = siv.flex(txn, self.status_slot, .{ .align_items = .start }); siv.setLayout(txn, self.pending, .{ .min_height = bar_height }); const bar = try kit.hintBar(Desk, scratch, txn, self.pending, self, hints.items); siv.setLayout(txn, bar, .{ .grow = 1, .shrink = 1, .basis = kit.px(0), .padding = .{ .start = 20, .end = 16, .top = 11, .bottom = 11 }, }); } // The palette's contents. /// Every command that applies now, in the order the palette lists them: /// the thread's actions, then the filters' by label, then the labels to /// toggle on the thread. fn collect(self: *const Desk, out: []Command) []Command { var list: ArrayList(Command) = .initBuffer(out); if (self.palette_from == .compose) { for ([_]Action{ .send, .draft_write, .draft_ask, .draft_write_ask, .draft_discard, .attach, .add_cc }) |a| { list.appendAssumeCapacity(.of(a)); } for (0..Compose.tool_count) |i| { list.appendAssumeCapacity(.{ .action = .format, .label = .inbox, .tool = @intCast(i) }); } return list.items; } const has_thread = self.thread() != null; if (self.overlay == .open) list.appendAssumeCapacity(.of(.close)); if (has_thread) { if (self.overlay != .open) list.appendAssumeCapacity(.of(.open)); for ([_]Action{ .done, .trash, .star, .unread }) |a| list.appendAssumeCapacity(.of(a)); if (self.overlay != .open) list.appendAssumeCapacity(.of(.mark)); } if (self.marked.count() > 0) list.appendAssumeCapacity(.of(.clear_marks)); // What only the open thread's card does, which its hints leave out. if (self.reader != .null) { list.appendAssumeCapacity(.of(.unfold_all)); list.appendAssumeCapacity(.of(.original)); list.appendAssumeCapacity(.of(.zoom_in)); list.appendAssumeCapacity(.of(.zoom_out)); if (self.messages.zoom.scale != 1) list.appendAssumeCapacity(.of(.zoom_reset)); } list.appendAssumeCapacity(.of(.compose)); list.appendAssumeCapacity(.of(.search)); if (self.found_since != null) list.appendAssumeCapacity(.of(.search_server)); list.appendAssumeCapacity(.of(.auto_images)); if (self.undos.items.len > 0) list.appendAssumeCapacity(.of(.undo)); if (self.switcher) |s| { var buf: [max_accounts]Other = undefined; for (s.others(s.context, &buf)) |o| { list.appendAssumeCapacity(.{ .action = .switch_account, .label = .inbox, .account = o.index }); } } if (self.adder != null) list.appendAssumeCapacity(.of(.add_account)); if (self.thread()) |id| { const gmail_id = self.mail.get(id).id; // The newest, as r and a answer it, then those before it; the // newest alone before the thread is read, which a reply reads. const read: u32 = @intCast(self.messages.conversation(gmail_id).len); if (read == 0 and self.messages.fetcher != null) { list.appendAssumeCapacity(.of(.reply)); list.appendAssumeCapacity(.of(.reply_all)); } // The newest alone: an earlier one has its button. if (read > 0 or self.messages.fetcher != null) list.appendAssumeCapacity(.of(.forward)); var answered = read; while (answered > 0 and read - answered <= max_replies) { answered -= 1; const which: ?u32 = if (answered + 1 == read) null else answered; list.appendAssumeCapacity(.{ .action = .reply, .label = .inbox, .message = which }); list.appendAssumeCapacity(.{ .action = .reply_all, .label = .inbox, .message = which }); } if (self.messages.withAction(gmail_id) != null) list.appendAssumeCapacity(.of(.follow_action)); if (self.messages.withInvite(gmail_id) != null) list.appendAssumeCapacity(.of(.add_event)); if (self.messages.fromList(gmail_id) != null) list.appendAssumeCapacity(.of(.unsubscribe)); var i: u16 = 0; while (i < max_files and self.messages.file(self.mail.get(id).id, i) != null) : (i += 1) { list.appendAssumeCapacity(.{ .action = .open_file, .label = .inbox, .file = i }); } } const labels = self.mail.labelCount(); for (0..labels) |i| list.appendAssumeCapacity(.{ .action = .show_only, .label = @fromBackingInt(@intCast(i)) }); if (self.filters.items.len < max_filters) { for (0..labels) |i| { const label: Label = @fromBackingInt(@intCast(i)); if (!self.filtered(label)) list.appendAssumeCapacity(.{ .action = .add_filter, .label = label }); } } for (self.filters.items) |label| list.appendAssumeCapacity(.{ .action = .remove_filter, .label = label }); if (self.filters.items.len > 0) list.appendAssumeCapacity(.of(.clear_filters)); if (has_thread) { for (0..labels) |i| { const label: Label = @fromBackingInt(@intCast(i)); if (self.labelInfo(label).kind == .user) list.appendAssumeCapacity(.{ .action = .toggle, .label = label }); } } return list.items; } /// Keeps the commands whose title holds the filter, ignoring case. fn filterCommands(self: *Desk) void { var n: u32 = 0; // Over the draft, Vim's name for a command, typed with its : or not, // puts it first. const named: ?Action = if (self.palette_from != .compose) null else if (Compose.vim_commands.get( mem.trimStart(u8, self.filter, ":"), )) |quit| switch (quit) { .write => .draft_write, .ask => .draft_ask, .write_ask => .draft_write_ask, .discard => .draft_discard, } else null; if (named) |a| { self.shown_buf[0] = .of(a); n = 1; } for (self.collect(self.all_buf)) |command| { var buf: [96]u8 = undefined; if (command.action == named) continue; if (ascii.findIgnoreCase(self.commandTitle(command, &buf), self.filter) == 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 Desk, command: Command, buf: []u8) []const u8 { const t = if (self.thread()) |id| self.mail.get(id) else null; const name = self.labelInfo(command.label).name; var one: [1]Mailbox.Id = undefined; const ids = self.targets(&one); return switch (command.action) { .open => "Open", .close => "Close the thread", .done => "Done", .archive => "Archive", .draft_write => "Save the draft", .draft_ask => "Close the draft…", .draft_write_ask => "Save and close the draft…", .draft_discard => "Discard the draft", .send => "Send", .attach => "Attach files", .add_cc => "Add Cc", .format => Compose.toolName(command.tool), .trash => "Trash", .star => if (self.target(command, ids)) "Star" else "Unstar", .unread => if (self.target(command, ids)) "Mark unread" else "Mark read", .mark => if (t != null and self.marked.contains(self.thread().?)) "Unselect this thread" else "Select this thread", .clear_marks => "Clear the selection", .unfold_all => if (self.messages.all) "Fold the messages read" else "Unfold every message", .original => if (self.messages.original) "Show mail in the theme's colors" else "Show mail in its own colors", .zoom_in => "Make the text larger", .zoom_out => "Make the text smaller", .auto_images => if (self.messages.web_images.auto) "Ask before loading images from the web" else "Load images from the web, except in Spam", .zoom_reset => mem.print(buf, "Show the text at its own size, not {d:.0}%", .{ self.messages.zoom.scale * 100, }) catch "", .show_only => mem.print(buf, "Show only {s}", .{name}) catch "", .add_filter => mem.print(buf, "Add filter {s}", .{name}) catch "", .remove_filter => mem.print(buf, "Remove filter {s}", .{name}) catch "", .clear_filters => "Clear filters", .toggle => mem.print(buf, "Toggle label {s}", .{name}) catch "", .compose => if (self.compose.kept) "Open the draft" else "Write a message", .search => "Search mail", .search_server => mem.print(buf, "Search {s} for older mail", .{self.server}) catch "Search older mail", .follow_action => blk: { const m = self.messages.withAction(if (t) |th| th.id else "") orelse break :blk "Open the page"; break :blk m.action.?.name; }, .add_event => blk: { const m = self.messages.withInvite(if (t) |th| th.id else "") orelse break :blk "Add to calendar"; break :blk mem.print(buf, "Add “{s}” to the calendar", .{m.invite.?.summary}) catch "Add to calendar"; }, .unsubscribe => blk: { const m = self.messages.fromList(if (t) |th| th.id else "") orelse break :blk "Unsubscribe"; break :blk mem.print(buf, "Unsubscribe from {s}", .{m.from}) catch "Unsubscribe"; }, .reply, .reply_all, .forward => self.replyTitle(command, buf), .open_file => blk: { const f = self.messages.file(if (t) |th| th.id else "", command.file) orelse break :blk "Open"; break :blk mem.print(buf, "Open {s}", .{f.attachment.name}) catch "Open the file"; }, .undo => blk: { const last = self.undos.getLastOrNull() orelse break :blk "Undo"; break :blk mem.print(buf, "Undo: {s}", .{last.what}) catch "Undo"; }, .switch_account => blk: { const s = self.switcher orelse break :blk "Switch account"; var others: [max_accounts]Other = undefined; for (s.others(s.context, &others)) |o| { if (o.index == command.account) break :blk mem.print(buf, "Switch to {s}", .{o.name}) catch ""; } break :blk "Switch account"; }, .add_account => "Add account", }; } /// "Reply" and "Reply to everyone" for the newest message; for one before /// it, "Reply to Ana’s message, 6h ago" and "Reply to everyone on Ana’s /// message, 6h ago". fn replyTitle(self: *const Desk, command: Command, buf: []u8) []const u8 { const plain: []const u8 = switch (command.action) { .reply_all => "Reply to everyone", .forward => "Forward", else => "Reply", }; const which = command.message orelse return plain; const m = self.message(which) orelse return plain; var age_buf: [64]u8 = undefined; return mem.print(buf, "{s} {s}{s} message, {s}", .{ switch (command.action) { .reply_all => "Reply to everyone on", .forward => "Forward", else => "Reply to", }, if (m.sent) "your" else m.from, if (m.sent) "" else "’s", kit.ago(&age_buf, siv.windowGetApp(self.window), &self.zone, self.now(), m.date), }) catch plain; } /// The keys that do the same thing without the palette: none, one or two /// caps. A label without a key is reachable only through the palette, and /// removing a filter has a key only for the last one added. fn caps(self: *const Desk, command: Command) [2][]const u8 { const key = self.keyCap(command.label); return switch (command.action) { .open => .{ "⏎", "" }, .close => .{ "esc", "" }, .done => .{ "e", "" }, .archive => .{ "", "" }, .draft_write => .{ ":w", "" }, .draft_ask => .{ ":q", "" }, .draft_write_ask => .{ ":wq", "" }, .draft_discard => .{ ":q!", "" }, .send => .{ "^⏎", "" }, .attach => .{ "^⇧A", "" }, .add_cc => .{ "^⇧C", "" }, .format => .{ Compose.toolCap(command.tool), "" }, .trash => .{ "#", "" }, .star => .{ "s", "" }, .unread => .{ "u", "" }, .show_only => if (key.len == 0) .{ "", "" } else .{ "g", key }, .add_filter => if (key.len == 0) .{ "", "" } else .{ "+", key }, .remove_filter => .{ if (self.filters.getLastOrNull() == command.label) "-" else "", "" }, .clear_filters => .{ "", "" }, .toggle => if (key.len == 0) .{ "", "" } else .{ "t", key }, .compose => .{ "c", "" }, .search => .{ "/", "" }, .search_server => .{ "F", "" }, .unsubscribe, .follow_action, .add_event => .{ "", "" }, .reply => .{ if (command.message == null) "r" else "", "" }, .reply_all => .{ if (command.message == null) "a" else "", "" }, .forward => .{ if (command.message == null) "f" else "", "" }, .open_file => .{ "", "" }, .undo => .{ "z", "" }, .mark => .{ "x", "" }, .clear_marks => .{ "esc", "" }, .unfold_all => .{ "x", "" }, .original => .{ "v", "" }, .zoom_in => .{ "^+", "" }, .zoom_out => .{ "^-", "" }, .zoom_reset => .{ "^0", "" }, .auto_images => .{ "", "" }, .switch_account => .{ ctrl_digits[command.account], "" }, .add_account => .{ "", "" }, }; } const ctrl_digits = [max_accounts][]const u8{ "^1", "^2", "^3", "^4", "^5", "^6", "^7", "^8", "^9" }; fn syncPalette(self: *Desk, txn: siv.Txn) void { siv.setHidden(txn, self.nothing, self.shown.len > 0); const t = if (self.thread()) |id| self.mail.get(id) else null; var context_buf: [160]u8 = undefined; const n = self.marked.count(); const context = if (self.palette_from == .compose) "On the draft" else if (self.batching()) mem.print(&context_buf, "On {d} selected thread{s}", .{ n, if (n == 1) "" else "s" }) catch "" else if (t) |th| mem.print(&context_buf, "On “{s}” from {s}", .{ th.subject, th.participants }) 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; self.fillEntry(txn, entry, self.shown[i], i == self.selected); } if (self.selected < self.shown.len) siv.scrollIntoView(txn, self.entries[self.selected].node, .nearest); } fn fillEntry(self: *const Desk, txn: siv.Txn, entry: *Entry, command: Command, selected: bool) void { siv.setVisual(txn, entry.node, .{ .background_token = if (selected) .selection_bg else .none, .radius = kit.corners(3), }); var buf: [96]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 = switch (command.action) { .show_only, .add_filter, .remove_filter, .toggle => true, else => false, }; const hue = if (labeled) kit.hue(self.labelInfo(command.label).color) else .none; // A gray label's dot is the muted text's, as its chip is neutral. const dot: siv.ColorToken = if (labeled and hue == .none) .muted_fg else hue; siv.setVisual(txn, entry.dot, .{ .background_token = dot, .radius = kit.corners(4) }); var one: [1]Mailbox.Id = undefined; const ids = self.targets(&one); const on = command.action == .toggle and ids.len > 0 and self.allHave(ids, command.label); siv.setText(txn, entry.state, if (command.action != .toggle) "" else if (on) "✓ on" else "off"); siv.setTextStyle(txn, entry.state, .{ .size_role = .caption, .color_token = if (selected) .selection_fg else .muted_fg, }); const key = self.caps(command); siv.setHidden(txn, entry.cap1_box, key[0].len == 0); siv.setText(txn, entry.cap1, key[0]); siv.setText(txn, entry.cap2, key[1]); siv.setHidden(txn, entry.cap2_box, key[1].len == 0); } // Events. fn runEntry(self: *Desk, index: u32, _: u32) !void { if (index < self.shown.len) _ = try self.run(self.shown[index]); } fn runCommand(self: *Desk, command: Command, _: u32) !void { _ = try self.run(command); } /// A click beside the open thread closes it, but not the second click of /// a double click on a row, whose first one opened it. fn clickScrim(self: *Desk, _: void, clicks: u32) !void { if (clicks < 2) _ = try self.onKey(.named(.escape)); } fn onFilterChange(self: *Desk, event: *const siv.Event) !bool { const typed: []const u8 = if (event.text) |t| t[0..event.text_len] else ""; const n = @min(typed.len, self.filter_buf.len); @memcpy(self.filter_buf[0..n], typed[0..n]); self.filter = self.filter_buf[0..n]; self.selected = 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 onSelect(self: *Desk, event: *const siv.Event) !bool { if (event.count > 0) self.current = event.index; return true; } fn onActivate(self: *Desk, event: *const siv.Event) !bool { self.current = event.index; return self.run(.of(.open)); } /// A click on a row opens its thread, as Enter does: the list selected /// it on the press. With Ctrl it selects the thread, or lets it go, and /// with Shift it selects the rows from the cursor to it. A chip or the /// checkbox in the row takes its own clicks first. fn onRowPointer(user_data: ?*anyopaque, node: siv.Node, event: *const siv.Event) callconv(.c) siv.EventResult { const self: *Desk = @ptrCast(@alignCast(user_data)); const used = self.rowPointer(node, event) catch |err| blk: { log.err("row click: {t}", .{err}); break :blk false; }; return if (used) .handled else .pass; } fn rowPointer(self: *Desk, node: siv.Node, event: *const siv.Event) !bool { if (event.button != .primary) return false; if (self.overlay != .none and self.overlay != .search) return false; const mods = event.modifiers; if (mods.ctrl or mods.shift) { // On the press, before the list moves its cursor there; the // release is taken too, so it opens nothing. if (event.phase != .down) return event.phase == .up; const row = self.rowOf(node) orelse return false; if (mods.shift) try self.markRows(self.current orelse row, row) else self.toggleMark(self.ids[row]); self.current = row; try self.refresh(); return true; } if (event.phase != .up) return false; return self.run(.of(.open)); } /// A click on a row's checkbox, shown while threads are selected, /// selects its thread or lets it go. fn onMarkPointer(user_data: ?*anyopaque, node: siv.Node, event: *const siv.Event) callconv(.c) siv.EventResult { if (event.button != .primary) return .pass; // The press too: the row would take it and move the cursor. if (event.phase == .down) return .handled; if (event.phase != .up) return .pass; const self: *Desk = @ptrCast(@alignCast(user_data)); if (self.overlay != .none and self.overlay != .search) return .handled; const row = self.rowOf(node) orelse return .pass; self.toggleMark(self.ids[row]); self.refresh() catch |err| log.err("checkbox click: {t}", .{err}); return .handled; } /// More rows than a window shows, however tall, for `rowOf` to look in. const rows_scanned = 128; /// The row of the list holding `node`, of those in view. fn rowOf(self: *const Desk, node: siv.Node) ?u32 { const top: u32 = @intFromFloat(@max(0, self.viewport.y / row_height)); const first = top -| 2; const end: u32 = @intCast(@min(self.ids.len, first + rows_scanned)); var n = node; while (n != .null and n != self.list) : (n = siv.nodeGetRelated(n, .parent)) { for (first..end) |i| { if (siv.viewGetRow(self.list, @intCast(i)) == n) return @intCast(i); } } return null; } fn bindRow( user_data: ?*anyopaque, txn: siv.Txn, row: siv.Node, item: u64, _: u32, _: u32, recycled: bool, ) callconv(.c) void { const self: *Desk = @ptrCast(@alignCast(user_data)); if (!recycled) self.makeRow(txn, row); const t = self.mail.get(self.ids[@intCast(item)]); const content = siv.nodeGetRelated(row, .first_child); const mark = siv.nodeGetRelated(content, .first_child); const unread_dot = siv.nodeGetRelated(mark, .next); const who = siv.nodeGetRelated(unread_dot, .next); const from = siv.nodeGetRelated(who, .first_child); const count = siv.nodeGetRelated(from, .next); const chips = siv.nodeGetRelated(who, .next); const line = siv.nodeGetRelated(chips, .next); const age = siv.nodeGetRelated(line, .next); self.bindMark(txn, content, mark, self.ids[@intCast(item)]); const dot_color: siv.ColorToken = if (t.unread) .accent else .none; siv.setVisual(txn, unread_dot, .{ .background_token = dot_color, .radius = kit.corners(3) }); siv.setText(txn, from, t.participants); var found_buf: [max_marks]Query.Range = undefined; var spans_buf: [max_cuts]siv.TextSpan = undefined; const found = self.search_query.found(.who, t.participants, &found_buf); siv.setTextSpans(txn, from, .{ .spans = markSpans(&.{}, found, t.participants.len, &spans_buf) }); var count_buf: [12]u8 = undefined; siv.setText(txn, count, if (t.messages > 1) mem.print(&count_buf, "{d}", .{t.messages}) catch "" else ""); siv.setTextStyle(txn, from, .{ .weight = if (t.unread) .bold else .regular, .color_token = if (t.unread) .view_fg else .muted_fg, .line_height = line_height, .flags = kit.one_line, }); // A recycled row holds another thread's chips: make this one's afresh. removeChildren(txn, chips); self.addChips(txn, chips, t); var age_buf: [64]u8 = undefined; siv.setText(txn, age, kit.age(&age_buf, siv.windowGetApp(self.window), &self.zone, self.now(), t.date)); self.bindLine(txn, line, t); } /// "★ Subject snippet": the star in its gold, the subject as bright as /// the sender, the snippet quiet, and what a search matched in the /// accent. fn bindLine(self: *Desk, txn: siv.Txn, bottom: siv.Node, t: *const Thread) void { var line_buf: [512]u8 = undefined; const starred = hasLabel(t, .starred); const snippet = unquoted(t.snippet); const printed = mem.print(&line_buf, "{s}{s} {s}", .{ if (starred) "★ " else "", t.subject, snippet }); // Too long for the buffer, the subject goes alone. const star = if (starred and printed != error.NoSpaceLeft) "★ " else ""; const line = printed catch t.subject; siv.setText(txn, bottom, line); const subject_end: u32 = @intCast(@min(line.len, star.len + t.subject.len)); var marks_buf: [2 * max_marks]Query.Range = undefined; var marks: ArrayList(Query.Range) = .initBuffer(&marks_buf); var found_buf: [max_marks]Query.Range = undefined; const subject_at: u32 = @intCast(star.len); for (self.search_query.found(.subject, t.subject, &found_buf)) |r| { marks.appendAssumeCapacity(.{ .start = r.start + subject_at, .end = r.end + subject_at }); } const snippet_at: u32 = subject_end + 2; if (line.len > snippet_at) for (self.search_query.found(.snippet, snippet, &found_buf)) |r| { marks.appendAssumeCapacity(.{ .start = r.start + snippet_at, .end = r.end + snippet_at }); }; const spans = [_]siv.TextSpan{ .{ .start = 0, .end = @intCast(star.len), .flags = .{ .color = true }, .color = kit.color(self.labelInfo(.starred).color), }, .{ .start = @intCast(star.len), .end = subject_end, .flags = .{ .color = true, .weight = true }, .color_token = .view_fg, .weight = if (t.unread) .bold else .regular, }, }; var spans_buf: [max_cuts]siv.TextSpan = undefined; const base = if (star.len > 0) &spans else spans[1..]; siv.setTextSpans(txn, bottom, .{ .spans = markSpans(base, marks.items, line.len, &spans_buf) }); } /// A snippet without the quote it runs into, from the line that /// introduces it ("On Fri, Nov 6, 2026 at 2:44 PM Ana /// wrote:") on. A snippet that is all quote stays whole. fn unquoted(snippet: []const u8) []const u8 { const wrote = mem.find(u8, snippet, " wrote:") orelse return snippet; const first = wrote -| 200; var end = wrote; // The nearest On before it with a date in between starts the line; // an earlier one is the writer's own sentence. while (mem.findLast(u8, snippet[first..end], "On ")) |found| : (end = first + found) { const at = first + found; if (at > 0 and snippet[at - 1] != ' ') continue; if (mem.findAny(u8, snippet[at..wrote], "0123456789") == null) continue; const kept = mem.trimEnd(u8, snippet[0..at], " "); return if (kept.len == 0) snippet else kept; } return snippet; } /// How many matches of a search a row marks in one field. const max_marks = 16; /// The edges of a line's own styles, two, and of its marks, in two /// fields, and its start. const max_cuts = 1 + 2 * 2 + 2 * 2 * max_marks; /// `base`, a row's own styles, with a search's matches, `marks`, over /// them in the accent and bold: a span for each stretch between their /// edges, in the first `len` bytes. Both are in order and apart. fn markSpans( base: []const siv.TextSpan, marks: []const Query.Range, len: usize, out: *[max_cuts]siv.TextSpan, ) []const siv.TextSpan { var cuts_buf: [max_cuts]u32 = undefined; var cuts: ArrayList(u32) = .initBuffer(&cuts_buf); cuts.appendAssumeCapacity(0); for (base) |b| cuts.appendSliceAssumeCapacity(&.{ b.start, b.end }); for (marks) |m| cuts.appendSliceAssumeCapacity(&.{ m.start, m.end }); for (cuts.items) |*c| c.* = @min(c.*, len); mem.sort(u32, cuts.items, {}, std.sort.asc(u32)); var n: usize = 0; for (cuts.items[0 .. cuts.items.len - 1], cuts.items[1..]) |from, to| { if (from == to) continue; const own: ?siv.TextSpan = for (base) |b| { if (b.start <= from and from < b.end) break b; } else null; const marked = for (marks) |m| { if (m.start <= from and from < m.end) break true; } else false; if (own == null and !marked) continue; var span = own orelse siv.TextSpan{}; span.start = from; span.end = to; if (marked) { span.flags.color = true; span.flags.weight = true; span.color_token = .accent; span.weight = .bold; } out[n] = span; n += 1; } return out[0..n]; } /// While threads are selected, every row shows a checkbox, and a /// selected one is checked and its row tinted in the accent. fn bindMark(self: *Desk, txn: siv.Txn, content: siv.Node, mark: siv.Node, id: Mailbox.Id) void { const marked = self.marked.contains(id); siv.setHidden(txn, mark, self.marked.count() == 0); siv.setHidden(txn, siv.nodeGetRelated(mark, .first_child), !marked); siv.setVisual(txn, mark, .{ .background_token = if (marked) .accent else .none, .border_token = if (marked) .accent else .muted_fg, .border_width = 1.5, .radius = kit.corners(3), }); var name_buf: [160]u8 = undefined; const name = mem.print(&name_buf, "Select “{s}”", .{self.mail.get(id).subject}) catch "Select"; siv.setSemantics(txn, mark, .{ .role = .checkbox, .label = name, .checked = if (marked) .on else .off }); var tint: siv.Color = .{}; if (marked) { tint = siv.windowGetColor(self.window, .accent) catch tint; // Faint, so the cursor's row, in the selection's color, stands // out. siv blends in linear light, where a tint shows far more // over a dark background than over a light one. const bg = siv.windowGetColor(self.window, .view_bg) catch tint; tint.a = if (bg.r + bg.g + bg.b < 1.5) 0.035 else 0.14; } siv.setVisual(txn, content, .{ .background = tint }); } /// The thread's user labels, but those filtered on, which go without /// saying; a click on one adds it as a filter. Starred is a star by the /// subject. fn addChips(self: *Desk, txn: siv.Txn, parent: siv.Node, t: *const Thread) void { var labels = t.labels.iterator(.{}); while (labels.next()) |i| { const label: Label = @fromBackingInt(@intCast(i)); const info = self.labelInfo(label); if (info.kind != .user) continue; if (self.filtered(label)) continue; const chip = kit.chip(txn, parent, info, .{}); siv.setSemantics(txn, chip, .{ .role = .button, .label = info.name }); siv.setCursor(txn, chip, .pointer); siv.on(txn, chip, .pointer, ChipClick.call, null, &self.chip_clicks[i]); } } fn makeRow(self: *Desk, txn: siv.Txn, row: siv.Node) void { const content = siv.flex(txn, row, .{ .gap = 10, .align_items = .center }); siv.setLayout(txn, content, .{ .width = kit.whole, .height = kit.whole, .padding = .{ .start = 18, .end = 22 } }); siv.on(txn, content, .pointer, onRowPointer, null, self); const mark = siv.flex(txn, content, .{ .justify = .center, .align_items = .center }); siv.setLayout(txn, mark, .{ .width = kit.px(14), .height = kit.px(14), .shrink = 0 }); siv.setCursor(txn, mark, .pointer); siv.on(txn, mark, .pointer, onMarkPointer, null, self); _ = kit.text(txn, mark, "✓", .{ .size_role = .caption, .weight = .bold, .color_token = .accent_fg }); const unread_dot = siv.box(txn, content); siv.setLayout(txn, unread_dot, .{ .width = kit.px(6), .height = kit.px(6), .shrink = 0 }); const who = siv.flex(txn, content, .{ .gap = 6, .align_items = .center }); siv.setLayout(txn, who, .{ .width = kit.px(who_width), .shrink = 0 }); const from = kit.text(txn, who, "", .{}); // The names shrink first; the count stays beside them, as in Gmail. siv.setLayout(txn, from, .{ .shrink = 1 }); _ = kit.text(txn, who, "", .{ .size_role = .caption, .color_token = .muted_fg }); const chips = siv.flex(txn, content, .{ .gap = 6, .align_items = .center }); siv.setLayout(txn, chips, .{ .shrink = 0 }); const line = kit.text(txn, content, "", .{ .color_token = .muted_fg, .line_height = line_height, .flags = kit.one_line, }); siv.setLayout(txn, line, .{ .grow = 1, .shrink = 1, .basis = kit.px(0) }); _ = kit.text(txn, content, "", .{ .size_role = .caption, .color_token = .muted_fg }); } /// A desk on sample mail in a headless window, for the tests. const TestDesk = struct { mail: Mailbox, zoom: Zoom, images: Images, window: siv.Window, desk: *Desk, /// A desk on sample mail, or on none yet when `mail` is .later. fn init(self: *TestDesk, app: siv.App, settings: ?Settings, mail: enum { now, later }) !void { self.mail = try .init(testing.allocator); errdefer self.mail.deinit(); if (mail == .now) try loadSample(&self.mail); self.zoom = .{ .scale = 1, .settings = null }; self.images = .{ .auto = false, .settings = null }; self.window = try siv.windowCreate(app, .{ .title = "Parcel", .width = 1200, .height = 760 }); errdefer siv.windowDestroy(self.window); const root = siv.windowGetRoot(self.window); const gpa = testing.allocator; self.desk = try Desk.create( gpa, testing.io, zeit.utc, self.window, root, &self.mail, settings, &self.zoom, &self.images, ); try testing.expect(try siv.testSettle(app, 5000)); } /// Has the reader read made-up messages, as the app does signed out. fn answerWithSamples(self: *TestDesk) void { self.desk.messages.fetcher = .{ .context = self, .fetch = fetchSample, .attachment = null, .web = null }; } fn fetchSample(context: *anyopaque, gmail_id: []const u8) void { const self: *TestDesk = @ptrCast(@alignCast(context)); var arena: ArenaAllocator = .init(testing.allocator); defer arena.deinit(); const id = self.mail.findThread(gmail_id).?; const list = sample.conversation(arena.allocator(), self.mail.get(id)) catch null; self.desk.messages.deliver(gmail_id, list) catch |err| std.debug.panic("deliver: {t}", .{err}); } /// Opens the selected thread and waits for its messages. fn openAndRead(self: *TestDesk, app: siv.App) !void { try siv.testKey(self.window, .@"return", .{}, ""); var waited: u32 = 0; while (self.desk.newest() == null and waited < 20) : (waited += 1) try siv.appDispatch(app, 100); try testing.expect(self.desk.newest() != null); try testing.expect(try siv.testSettle(app, 5000)); } fn loadSample(mail: *Mailbox) !void { try sample.load(mail, Io.Timestamp.now(testing.io, .real).toSeconds()); } fn deinit(self: *TestDesk) void { siv.windowDestroy(self.window); self.desk.destroy(); self.mail.deinit(); self.* = undefined; } /// The sample label with Gmail id `id`. fn label(self: *const TestDesk, id: []const u8) Label { return self.mail.findLabel(id).?; } /// Types `keys`, where a `%` stands for the key of the label next in /// `labels`, since keys follow from the user's label names. fn press(self: *TestDesk, app: siv.App, keys: []const u8, labels: []const Label) !void { var next: usize = 0; for (keys) |c| { const key = if (c == '%') blk: { defer next += 1; break :blk self.desk.keys[@backingInt(labels[next])]; } else c; const typed = [1]u8{key}; try siv.testKey(self.window, @fromBackingInt(Key.char(key).code), .{}, &typed); try testing.expect(try siv.testSettle(app, 5000)); } } fn has(self: *const TestDesk, query: siv.TestQuery.Options) !bool { var found = false; var node: siv.TestNode = .{}; try siv.testFind(self.window, query, &found, &node); return found; } /// Where the first node `query` matches is in the window. fn bounds(self: *const TestDesk, query: siv.TestQuery.Options) !siv.Rect { var found = false; var node: siv.TestNode = .{}; try siv.testFind(self.window, query, &found, &node); try testing.expect(found); return node.bounds; } fn find(self: *const TestDesk, subject: []const u8) Mailbox.Id { var id: Mailbox.Id = 0; while (!mem.eql(u8, self.mail.get(id).subject, subject)) id += 1; return id; } }; test "a row's snippet leaves out the quote it runs into" { try testing.expectEqualStrings("awesome, i will wait eagerly.", unquoted( "awesome, i will wait eagerly. On Fri, Nov 6, 2026 at 2:44 PM Quinn wrote: Hey there", )); try testing.expectEqualStrings("On second thought, yes.", unquoted( "On second thought, yes. On Tue, Nov 3, 2026 at 12:57 PM Matt wrote: The order", )); // No date: the writer's own words. const sentence = "Sure. On Monday we wrote: the plan, and it held"; try testing.expectEqualStrings(sentence, unquoted(sentence)); // All quote: better than nothing. const quote = "On Fri, Nov 6, 2026 at 2:44 PM Ana wrote: Hi"; try testing.expectEqualStrings(quote, unquoted(quote)); } test "new mail waits while the palette is open, and shows once it closes" { const app = try siv.appCreate(.{ .backend = .headless, .flags = .{ .no_system_fonts = true, .no_system_icons = true, .reduced_motion = true }, }); defer siv.appDestroy(app); var t: TestDesk = undefined; try t.init(app, null, .later); defer t.deinit(); try t.press(app, ":", &.{}); try testing.expectEqual(Overlay.palette, t.desk.overlay); var later: Mailbox = try .init(testing.allocator); TestDesk.loadSample(&later) catch |err| { later.deinit(); return err; }; try t.desk.replaceMail(later); try testing.expect(try siv.testSettle(app, 5000)); try testing.expectEqual(Overlay.palette, t.desk.overlay); try testing.expectEqual(0, t.mail.threads.items.len); try siv.testKey(t.window, .escape, .{}, ""); try testing.expect(try siv.testSettle(app, 5000)); try testing.expectEqual(Overlay.none, t.desk.overlay); try testing.expect(t.mail.threads.items.len > 0); } test "Ctrl+D and Ctrl+U move half a page of rows" { const app = try siv.appCreate(.{ .backend = .headless, .flags = .{ .no_system_fonts = true, .no_system_icons = true, .reduced_motion = true }, }); defer siv.appDestroy(app); var t: TestDesk = undefined; try t.init(app, null, .now); defer t.deinit(); try testing.expectEqual(0, t.desk.current.?); try siv.testKey(t.window, @fromBackingInt('d'), .{ .ctrl = true }, ""); try testing.expect(try siv.testSettle(app, 5000)); const down = t.desk.current.?; try testing.expect(down > 1 and down < t.desk.ids.len - 1); try siv.testKey(t.window, @fromBackingInt('u'), .{ .ctrl = true }, ""); try testing.expect(try siv.testSettle(app, 5000)); try testing.expectEqual(0, t.desk.current.?); } test "Gmail's changes show in place: the open thread stays open, and one gone gives its row to the next" { const app = try siv.appCreate(.{ .backend = .headless, .flags = .{ .no_system_fonts = true, .no_system_icons = true, .reduced_motion = true }, }); defer siv.appDestroy(app); var t: TestDesk = undefined; try t.init(app, null, .now); defer t.deinit(); t.answerWithSamples(); try t.press(app, "j", &.{}); try t.openAndRead(app); const open = t.desk.thread().?; var id_buf: [32]u8 = undefined; const gmail_id = try mem.print(&id_buf, "{s}", .{t.mail.get(open).id}); // Newer mail takes the top row; the open thread moves down with it. _ = try t.mail.addThread(.{ .id = "newest", .participants = "Ana", .subject = "Just in", .snippet = "", .date = math.maxInt(i32), .messages = 1, .labels = &.{.inbox}, .unread = true, .notes = &.{}, }); try t.desk.updated(&.{}); try testing.expect(try siv.testSettle(app, 5000)); try testing.expectEqual(open, t.desk.thread().?); try testing.expectEqual(2, t.desk.current.?); try testing.expectEqual(Overlay.open, t.desk.overlay); // Gone from Gmail, it leaves the mail, the reader closes, and the next // thread has its row. The last thread took its place in the mail. const next = t.mail.get(t.desk.ids[3]).id; const count = t.mail.threads.items.len; try t.desk.updated(&.{gmail_id}); try testing.expect(try siv.testSettle(app, 5000)); try testing.expect(t.mail.findThread(gmail_id) == null); try testing.expectEqual(count - 1, t.mail.threads.items.len); try testing.expectEqual(Overlay.none, t.desk.overlay); try testing.expectEqual(2, t.desk.current.?); try testing.expectEqualStrings(next, t.mail.get(t.desk.thread().?).id); } test "what is selected, and what z undoes, follows a thread into a gone thread's place" { const app = try siv.appCreate(.{ .backend = .headless, .flags = .{ .no_system_fonts = true, .no_system_icons = true, .reduced_motion = true }, }); defer siv.appDestroy(app); var t: TestDesk = undefined; try t.init(app, null, .now); defer t.deinit(); // The newest thread, at the top of the list, is the mail's last. _ = try t.mail.addThread(.{ .id = "newest", .participants = "Ana", .subject = "Just in", .snippet = "", .date = math.maxInt(i32), .messages = 1, .labels = &.{.inbox}, .unread = false, .notes = &.{}, }); try t.desk.updated(&.{}); try testing.expect(try siv.testSettle(app, 5000)); try t.press(app, "k", &.{}); try testing.expectEqualStrings("newest", t.mail.get(t.desk.thread().?).id); try t.press(app, "xs", &.{}); // Gone from Gmail, the first thread leaves, and the newest takes its // place in the mail. var id_buf: [32]u8 = undefined; const gone = try mem.print(&id_buf, "{s}", .{t.mail.get(0).id}); try t.desk.updated(&.{gone}); try testing.expect(try siv.testSettle(app, 5000)); const moved = t.mail.findThread("newest").?; try testing.expectEqual(0, moved); try testing.expectEqualSlices(Mailbox.Id, &.{moved}, t.desk.marked.keys()); try testing.expect(t.mail.has(moved, .starred)); try t.press(app, "z", &.{}); try testing.expect(!t.mail.has(moved, .starred)); } test "an open thread reads its messages once, folds the read ones but the newest, and unfolds one alone" { const app = try siv.appCreate(.{ .backend = .headless, .flags = .{ .no_system_fonts = true, .no_system_icons = true, .reduced_motion = true }, }); defer siv.appDestroy(app); var t: TestDesk = undefined; try t.init(app, null, .now); defer t.deinit(); const Asked = struct { const Self = @This(); count: u32 = 0, files: u32 = 0, fn fetch(context: *anyopaque, _: []const u8) void { const asked: *Self = @ptrCast(@alignCast(context)); asked.count += 1; } fn attachment(context: *anyopaque, _: []const u8, _: []const u8, want: *Reader.Want) void { const asked: *Self = @ptrCast(@alignCast(context)); asked.files += 1; want.receive(null); } }; var asked: Asked = .{}; t.desk.messages.fetcher = .{ .context = &asked, .fetch = Asked.fetch, .attachment = Asked.attachment, .web = null }; const id = t.desk.thread().?; const open = t.mail.get(id); try siv.testKey(t.window, .@"return", .{}, ""); var waited: u32 = 0; while (asked.count == 0 and waited < 20) : (waited += 1) try siv.appDispatch(app, 100); try testing.expectEqual(1, asked.count); // Not read yet: marked read now, Gmail would say no message was unread. try testing.expect(open.unread); // Ana's is read and old: folded. Bo's is unread and the newest is the // newest: whole. var list: [8]Mailbox.Message = undefined; const senders = [_][]const u8{ "Ana", "Bo", "Cy", "Di", "Ed", "Flo", "Gus", "Hal" }; const n = open.messages; for (list[0..n], senders[0..n], 0..) |*m, from, i| m.* = .{ .id = from, .from = from, .address = "", .date = open.date, .html = "

Hello

", .text = "Hello", .snippet = "Hello", .attachments = &.{}, .unread = i == 1, .message_id = "", .references = "", .reply_to = from, .to = "", .cc = "", .sent = false, .unsubscribe = "", .one_click = false, .action = null, .invite = null, }; // The newest shows an image of its own, read when its document asks. const logo: Mailbox.Attachment = .{ .name = "", .mime = "image/png", .content_id = "logo", .id = "logo-part", .data = "", .inline_shown = true, }; list[n - 1].html = "

Hello

"; list[n - 1].attachments = &.{logo}; try t.desk.messages.deliver(open.id, list[0..n]); try testing.expect(try siv.testSettle(app, 5000)); try testing.expect(!t.mail.get(id).unread); try testing.expectEqual(1, asked.files); try testing.expect(try t.has(.{ .name = "Ana", .role = .button })); try testing.expect(!try t.has(.{ .name = "Bo", .role = .button })); try testing.expect(!try t.has(.{ .name = senders[n - 1], .role = .button })); try clickOn(app, t.window, .{ .name = "Ana", .role = .button }); try testing.expect(!try t.has(.{ .name = "Ana", .role = .button })); // Only Ana's was made again: the newest keeps its document, and with // it its place and its image, which is not read again. try testing.expect(try siv.testSettle(app, 5000)); try testing.expectEqual(1, asked.files); // Open again: the messages are kept, so nothing is read, and Bo's, // seen now, folds. try siv.testKey(t.window, .escape, .{}, ""); try siv.testKey(t.window, .@"return", .{}, ""); waited = 0; while (waited < 5) : (waited += 1) try siv.appDispatch(app, 100); try testing.expectEqual(1, asked.count); try testing.expect(try t.has(.{ .name = "Bo", .role = .button })); } test "f forwards the open thread's newest message, with its files, and Forward any" { const app = try siv.appCreate(.{ .backend = .headless, .flags = .{ .no_system_fonts = true, .no_system_icons = true, .reduced_motion = true }, }); defer siv.appDestroy(app); var t: TestDesk = undefined; try t.init(app, null, .now); defer t.deinit(); t.answerWithSamples(); // The agenda's newest message holds a file. var tries: u32 = 0; while (mem.find(u8, t.mail.get(t.desk.thread().?).subject, "agenda") == null and tries < 20) : (tries += 1) { try t.press(app, "j", &.{}); } const open = t.mail.get(t.desk.thread().?); try t.openAndRead(app); const read = t.desk.messages.conversation(open.id); var arena_state: ArenaAllocator = .init(testing.allocator); defer arena_state.deinit(); const a = arena_state.allocator(); const last = &read[read.len - 1]; try testing.expect(try t.has(.{ .name = try a.print("Forward {s}’s message", .{last.from}), .role = .button })); try t.press(app, "f", &.{}); try testing.expectEqual(Overlay.compose, t.desk.overlay); try testing.expect(try t.has(.{ .name = "Forward", .role = .dialog })); const d = &t.desk.compose.draft; try testing.expectEqualStrings(try a.print("Fwd: {s}", .{open.subject}), d.subject.items); try testing.expectEqualStrings("", d.to.items); try testing.expectEqualStrings(last.message_id, d.in_reply_to.items); try testing.expect(mem.find(u8, d.body.items, "---------- Forwarded message ---------") != null); try testing.expectEqual(1, d.attachments.items.len); try testing.expectEqualStrings("agenda.txt", d.attachments.items[0].name); try testing.expect(try t.has(.{ .name = try a.print("agenda.txt · {d} B", .{d.attachments.items[0].data.len}) })); } test "r in the list reads a thread not opened, and replies once it comes if still selected" { const app = try siv.appCreate(.{ .backend = .headless, .flags = .{ .no_system_fonts = true, .no_system_icons = true, .reduced_motion = true }, }); defer siv.appDestroy(app); var t: TestDesk = undefined; try t.init(app, null, .now); defer t.deinit(); // Answers only when the test says, as Gmail answers later. const Later = struct { const Self = @This(); asked: u32 = 0, buf: [64]u8 = undefined, thread: []const u8 = "", fn fetch(context: *anyopaque, gmail_id: []const u8) void { const self: *Self = @ptrCast(@alignCast(context)); self.asked += 1; @memcpy(self.buf[0..gmail_id.len], gmail_id); self.thread = self.buf[0..gmail_id.len]; } fn answer(self: *Self, tt: *TestDesk, app_: siv.App) !void { var arena: ArenaAllocator = .init(testing.allocator); defer arena.deinit(); const list = try sample.conversation(arena.allocator(), tt.mail.get(tt.mail.findThread(self.thread).?)); try tt.desk.messages.deliver(self.thread, list); try testing.expect(try siv.testSettle(app_, 5000)); } }; var later: Later = .{}; t.desk.messages.fetcher = .{ .context = &later, .fetch = Later.fetch, .attachment = null, .web = null }; // Moved on before the thread came: no reply opens. const first = t.mail.get(t.desk.thread().?).id; try t.press(app, "r", &.{}); try testing.expectEqual(1, later.asked); try testing.expectEqualStrings(first, later.thread); try testing.expectEqual(Overlay.none, t.desk.overlay); try testing.expect(mem.startsWith(u8, t.desk.notice, "Reading")); try t.press(app, "j", &.{}); try later.answer(&t, app); try testing.expectEqual(Overlay.none, t.desk.overlay); try testing.expectEqual(1, later.asked); // Still selected when it comes: the reply to its newest message opens. const second = t.mail.get(t.desk.thread().?); try t.press(app, "a", &.{}); try testing.expectEqual(2, later.asked); try testing.expectEqualStrings(second.id, later.thread); try later.answer(&t, app); try testing.expectEqual(Overlay.compose, t.desk.overlay); var subject_buf: [160]u8 = undefined; const subject = try mem.print(&subject_buf, "Re: {s}", .{second.subject}); try testing.expectEqualStrings(subject, t.desk.compose.draft.subject.items); try testing.expectEqualStrings("", t.desk.notice); } test "r replies to the open thread's newest message, Reply to any, and Ctrl+Enter sends it" { const app = try siv.appCreate(.{ .backend = .headless, .flags = .{ .no_system_fonts = true, .no_system_icons = true, .reduced_motion = true }, }); defer siv.appDestroy(app); var t: TestDesk = undefined; try t.init(app, null, .now); defer t.deinit(); const Outbox = struct { const Self = @This(); sent: u32 = 0, saved: u32 = 0, buf: [256]u8 = undefined, line: []const u8 = "", body_buf: [512]u8 = undefined, body: []const u8 = "", fn send(context: *anyopaque, m: Compose.Outgoing) void { const box: *Self = @ptrCast(@alignCast(context)); box.sent += 1; box.line = mem.print(&box.buf, "{s}|{s}|{s}|{s}", .{ m.to, m.subject, m.thread, m.in_reply_to }) catch ""; box.body = mem.print(&box.body_buf, "{s}", .{m.body}) catch ""; } fn save(context: *anyopaque, _: Compose.Outgoing) void { const box: *Self = @ptrCast(@alignCast(context)); box.saved += 1; } fn discard(_: *anyopaque) void {} }; var outbox: Outbox = .{}; t.desk.compose.keeper = .{ .context = &outbox, .save = Outbox.save, .send = Outbox.send, .discard = Outbox.discard, }; t.answerWithSamples(); const open = t.mail.get(t.desk.thread().?); try t.openAndRead(app); var arena_state: ArenaAllocator = .init(testing.allocator); defer arena_state.deinit(); try t.press(app, "r", &.{}); try testing.expectEqual(Overlay.compose, t.desk.overlay); // Vim's insert mode first; Esc leaves it, and a second one does not // close the card. for ("iYes") |c| try siv.testKey(t.window, @fromBackingInt(c), .{}, &.{c}); for (0..2) |_| try siv.testKey(t.window, .escape, .{}, ""); try testing.expect(try siv.testSettle(app, 5000)); try testing.expectEqual(Overlay.compose, t.desk.overlay); try siv.testKey(t.window, .@"return", .{ .ctrl = true }, ""); try testing.expect(try siv.testSettle(app, 5000)); try testing.expectEqual(1, outbox.sent); const expected = try arena_state.allocator().print("me@example.com|Re: {s}|{s}|<{s}.{d}@sample>", .{ open.subject, open.id, open.id, open.messages - 1, }); try testing.expectEqualStrings(expected, outbox.line); // Typed above the quote of what it answers. try testing.expect(mem.startsWith(u8, outbox.body, "Yes\n\nOn ")); const quoted = try arena_state.allocator().print(" me wrote:\n> {s}\n>\n> Thanks,\n> me\n", .{open.snippet}); try testing.expect(mem.endsWith(u8, outbox.body, quoted)); try testing.expectEqual(Overlay.open, t.desk.overlay); try t.desk.sent(true); // An earlier message's own Reply answers it, quoted, and not the // newest: Ana's first, unfolded. const read = t.desk.messages.conversation(open.id); try testing.expect(read.len >= 3); const first = &read[0]; try t.press(app, "x", &.{}); const reply_name = try arena_state.allocator().print("Reply to {s}", .{first.from}); try clickOn(app, t.window, .{ .name = reply_name, .role = .button }); try testing.expectEqual(Overlay.compose, t.desk.overlay); try siv.testKey(t.window, .@"return", .{ .ctrl = true }, ""); try testing.expect(try siv.testSettle(app, 5000)); try testing.expectEqual(2, outbox.sent); const to_first = try arena_state.allocator().print("{s}|Re: {s}|{s}|{s}", .{ first.reply_to, open.subject, open.id, first.message_id, }); try testing.expectEqualStrings(to_first, outbox.line); try testing.expect(mem.startsWith(u8, outbox.body, "\n\nOn ")); const quoted_first = try arena_state.allocator().print( " {s} wrote:\n> Message 1 of “{s}”.\n>\n> Thanks,\n> {s}\n", .{ first.from, open.subject, first.from }, ); try testing.expect(mem.endsWith(u8, outbox.body, quoted_first)); try t.desk.sent(true); // The palette reaches the others by key: the user's own second one. try t.press(app, ":", &.{}); for ("everyone on your") |c| try siv.testKey(t.window, @fromBackingInt(c), .{}, &.{c}); try testing.expect(try siv.testSettle(app, 5000)); try testing.expectEqual(1, t.desk.shown.len); try siv.testKey(t.window, .@"return", .{}, ""); try testing.expect(try siv.testSettle(app, 5000)); try siv.testKey(t.window, .@"return", .{ .ctrl = true }, ""); try testing.expect(try siv.testSettle(app, 5000)); try testing.expectEqual(3, outbox.sent); try testing.expect(mem.endsWith(u8, outbox.line, read[1].message_id)); const quoted_mine = try arena_state.allocator().print(" me wrote:\n> Message 2 of “{s}”.\n", .{open.subject}); try testing.expect(mem.find(u8, outbox.body, quoted_mine) != null); try t.desk.sent(true); // The palette took the reader's place; open it again. try t.press(app, "o", &.{}); // Put away, a new message is kept, and comes back as it was left. try t.press(app, "c", &.{}); for ("ana@example.com") |c| try siv.testKey(t.window, @fromBackingInt(c), .{}, &.{c}); try siv.testKey(t.window, .escape, .{}, ""); try testing.expect(try siv.testSettle(app, 5000)); try testing.expectEqual(Overlay.open, t.desk.overlay); try testing.expectEqual(1, outbox.saved); try t.press(app, "c", &.{}); try testing.expectEqualStrings("ana@example.com", t.desk.compose.draft.to.items); // Ctrl+Shift+A asks the desktop for files to attach, whatever field // has the focus; headless, there is no portal to answer. try siv.testKey(t.window, @fromBackingInt('A'), .{ .ctrl = true, .shift = true }, ""); const missing: siv.TestQuery.Options = .{ .name = "There is no file chooser: xdg-desktop-portal is not running" }; var waited: u32 = 0; while (!try t.has(missing) and waited < 50) : (waited += 1) { try siv.appDispatch(app, 20); try testing.expect(try siv.testSettle(app, 5000)); } try testing.expect(try t.has(missing)); try testing.expectEqualStrings("ana@example.com", t.desk.compose.draft.to.items); } fn clickOn(app: siv.App, window: siv.Window, query: siv.TestQuery.Options) !void { const at = try centre(window, query); try siv.testPointerMove(window, at[0], at[1]); try siv.testPointerButton(window, .primary, true); try siv.testPointerButton(window, .primary, false); try testing.expect(try siv.testSettle(app, 5000)); } /// Presses on `from`, moves past the slop to start a drag, and releases /// over `to`, which may only show once the drag has started. fn centre(window: siv.Window, query: siv.TestQuery.Options) ![2]f32 { var found = false; var node: siv.TestNode = .{}; try siv.testFind(window, query, &found, &node); try testing.expect(found); return .{ node.bounds.x + node.bounds.width / 2, node.bounds.y + node.bounds.height / 2 }; } test "a message's file opens from its button and from the palette, saved under a free name" { const app = try siv.appCreate(.{ .backend = .headless, .flags = .{ .no_system_fonts = true, .no_system_icons = true, .reduced_motion = true }, }); defer siv.appDestroy(app); var t: TestDesk = undefined; try t.init(app, null, .now); defer t.deinit(); var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); var path_buf: [64]u8 = undefined; t.desk.messages.downloads = try mem.print(&path_buf, ".zig-cache/tmp/{s}", .{tmp.sub_path}); t.desk.messages.opener = "true"; t.answerWithSamples(); // The newest sample thread is the agenda, which carries one. try testing.expect(mem.find(u8, t.mail.get(t.desk.thread().?).subject, "agenda") != null); try t.openAndRead(app); try clickOn(app, t.window, .{ .name = "Open agenda.txt", .role = .button }); var buf: [256]u8 = undefined; const saved = try tmp.dir.readFile(testing.io, "agenda.txt", &buf); try testing.expect(mem.startsWith(u8, saved, "Offsite agenda, final\n")); try testing.expect(mem.startsWith(u8, t.desk.notice, "Saved agenda.txt in ")); try t.press(app, ":", &.{}); for ("agenda.txt") |c| try siv.testKey(t.window, @fromBackingInt(c), .{}, &.{c}); try testing.expect(try siv.testSettle(app, 5000)); try testing.expectEqual(1, t.desk.shown.len); try siv.testKey(t.window, .@"return", .{}, ""); try testing.expect(try siv.testSettle(app, 5000)); try testing.expectEqual(Overlay.open, t.desk.overlay); try testing.expectEqualStrings(saved, try tmp.dir.readFile(testing.io, "agenda (1).txt", &buf)); } test "images from the web load as a thread opens once allowed, but spam's wait for i" { const app = try siv.appCreate(.{ .backend = .headless, .flags = .{ .no_system_fonts = true, .no_system_icons = true, .reduced_motion = true }, }); defer siv.appDestroy(app); var t: TestDesk = undefined; try t.init(app, null, .now); defer t.deinit(); const Web = struct { const Self = @This(); desk: *Desk, asked: u32 = 0, url_buf: [64]u8 = undefined, url: []const u8 = "", /// Gravatar's asks, apart from the documents', and the last. photos: u32 = 0, photo_buf: [128]u8 = undefined, photo: []const u8 = "", fn fetch(context: *anyopaque, gmail_id: []const u8) void { const self: *Self = @ptrCast(@alignCast(context)); const t_ = self.desk.mail.get(self.desk.mail.findThread(gmail_id).?); var list: [8]Mailbox.Message = undefined; for (list[0..t_.messages]) |*m| m.* = .{ .id = "m", .from = "Ana", .address = " Ana@Example.com", .date = t_.date, .html = "

Look

", .text = "Look", .snippet = "Look", .attachments = &.{}, .unread = false, .message_id = "", .references = "", .reply_to = "Ana", .to = "", .cc = "", .sent = false, .unsubscribe = "", .one_click = false, .action = null, .invite = null, }; self.desk.messages.deliver(gmail_id, list[0..t_.messages]) catch |err| std.debug.panic("{t}", .{err}); } fn resource(context: *anyopaque, url: []const u8, _: ?[]const u8, want: *Reader.Want) void { const self: *Self = @ptrCast(@alignCast(context)); self.asked += 1; if (mem.startsWith(u8, url, "https://gravatar.com/")) { self.photos += 1; self.photo = mem.print(&self.photo_buf, "{s}", .{url}) catch ""; // A PNG of one red pixel. return want.receive("\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02" ++ "\x00\x00\x00\x90\x77\x53\xde\x00\x00\x00\x0cIDAT\x78\x9c\x63\xf8\xcf\xc0\x00\x00\x03\x01" ++ "\x01\x00\xc9\xfe\x92\xef\x00\x00\x00\x00IEND\xae\x42\x60\x82"); } self.url = mem.print(&self.url_buf, "{s}", .{url}) catch ""; want.receive(null); } }; var web: Web = .{ .desk = t.desk }; t.desk.messages.fetcher = .{ .context = &web, .fetch = Web.fetch, .attachment = null, .web = Web.resource }; // Asking first, as the user chose, the open thread's wait. try t.openAndRead(app); try testing.expectEqual(0, web.asked); try testing.expect(try t.has(.{ .name = "Show images", .role = .button })); try testing.expect(!try t.has(.{ .name = "Photo of Ana", .role = .image })); // Allowed from the palette, they load as the thread opens again. try t.press(app, ":", &.{}); for ("images") |c| try siv.testKey(t.window, @fromBackingInt(c), .{}, &.{c}); try testing.expect(try siv.testSettle(app, 5000)); try testing.expectEqual(Action.auto_images, t.desk.shown[0].action); try siv.testKey(t.window, .@"return", .{}, ""); try testing.expect(try siv.testSettle(app, 5000)); try testing.expect(t.images.auto); try t.openAndRead(app); try testing.expect(web.asked > 0); try testing.expectEqualStrings("https://example.com/a.png", web.url); try testing.expect(!try t.has(.{ .name = "Show images", .role = .button })); // So does the sender's photo, once, by the hash of the address // trimmed and in lowercase, in place of the initials. try testing.expectEqual(1, web.photos); try testing.expectEqualStrings( "https://gravatar.com/avatar/8e43ca37701228e74983efdbd0cff5c16b3b1e5d4e29a7c05626d4d25a018e11?s=64&d=404", web.photo, ); try testing.expect(try t.has(.{ .name = "Photo of Ana", .role = .image })); // Another thread's load as it opens; the photo shows, not asked again. try siv.testKey(t.window, .escape, .{}, ""); try t.press(app, "j", &.{}); var asked = web.asked; try t.openAndRead(app); try testing.expect(web.asked > asked); try testing.expect(!try t.has(.{ .name = "Show images", .role = .button })); try testing.expectEqual(1, web.photos); try testing.expect(try t.has(.{ .name = "Photo of Ana", .role = .image })); // Spam's wait for i. try siv.testKey(t.window, .escape, .{}, ""); try t.press(app, "g%", &.{.spam}); asked = web.asked; try t.openAndRead(app); try testing.expectEqual(asked, web.asked); try testing.expect(try t.has(.{ .name = "Show images", .role = .button })); try t.press(app, "i", &.{}); try testing.expect(try siv.testSettle(app, 5000)); try testing.expect(web.asked > asked); try testing.expect(!try t.has(.{ .name = "Show images", .role = .button })); } test "a newsletter's Unsubscribe asks to be clicked again, then drafts the message its list wants" { const app = try siv.appCreate(.{ .backend = .headless, .flags = .{ .no_system_fonts = true, .no_system_icons = true, .reduced_motion = true }, }); defer siv.appDestroy(app); var t: TestDesk = undefined; try t.init(app, null, .now); defer t.deinit(); t.answerWithSamples(); try t.press(app, "/", &.{}); for ("zig weekly") |c| try siv.testKey(t.window, @fromBackingInt(c), .{}, &.{c}); try siv.testKey(t.window, .@"return", .{}, ""); try testing.expect(try siv.testSettle(app, 5000)); try testing.expectEqual(1, t.desk.ids.len); try t.openAndRead(app); try clickOn(app, t.window, .{ .name = "Unsubscribe", .role = .button }); try testing.expectEqual(Overlay.open, t.desk.overlay); try clickOn(app, t.window, .{ .name = "Unsubscribe from Zig Weekly? Click again", .role = .button }); try testing.expectEqual(Overlay.compose, t.desk.overlay); try testing.expectEqualStrings("leave@zig-weekly.example", t.desk.compose.draft.to.items); try testing.expectEqualStrings("unsubscribe", t.desk.compose.draft.subject.items); } test "an invitation shows its event and goes to the calendar; a page the mail offers is in the palette" { const app = try siv.appCreate(.{ .backend = .headless, .flags = .{ .no_system_fonts = true, .no_system_icons = true, .reduced_motion = true }, }); defer siv.appDestroy(app); var t: TestDesk = undefined; try t.init(app, null, .now); defer t.deinit(); var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); var path_buf: [64]u8 = undefined; t.desk.messages.downloads = try mem.print(&path_buf, ".zig-cache/tmp/{s}", .{tmp.sub_path}); t.desk.messages.opener = "true"; t.answerWithSamples(); try t.press(app, "/", &.{}); for ("lunch tomorrow") |c| try siv.testKey(t.window, @fromBackingInt(c), .{}, &.{c}); try siv.testKey(t.window, .down, .{}, ""); try testing.expect(try siv.testSettle(app, 5000)); // The list is newest first: Sam's invitation, then the reply to it. try t.press(app, "j", &.{}); try testing.expectEqualStrings("Lunch tomorrow", t.mail.get(t.desk.thread().?).subject); try t.openAndRead(app); try siv.testKey(t.window, @fromBackingInt('x'), .{}, "x"); try testing.expect(try siv.testSettle(app, 5000)); try testing.expect(try t.has(.{ .name = "Lunch", .role = .label })); try clickOn(app, t.window, .{ .name = "Add to calendar", .role = .button }); var buf: [512]u8 = undefined; const ics = try tmp.dir.readFile(testing.io, "invite.ics", &buf); try testing.expect(mem.find(u8, ics, "SUMMARY:Lunch") != null); try siv.testKey(t.window, .escape, .{}, ""); try t.press(app, "q", &.{}); try t.press(app, "/", &.{}); for ("vkQueueSubmit2") |c| try siv.testKey(t.window, @fromBackingInt(c), .{}, &.{c}); try siv.testKey(t.window, .down, .{}, ""); try testing.expect(try siv.testSettle(app, 5000)); try t.openAndRead(app); try testing.expect(try t.has(.{ .name = "View workflow run", .role = .button })); try t.press(app, ":", &.{}); for ("workflow") |c| try siv.testKey(t.window, @fromBackingInt(c), .{}, &.{c}); try testing.expect(try siv.testSettle(app, 5000)); try testing.expectEqual(1, t.desk.shown.len); try testing.expectEqual(Action.follow_action, t.desk.shown[0].action); } test "filters stack, and leave out a thread in Trash unless one names it" { const app = try siv.appCreate(.{ .backend = .headless, .flags = .{ .no_system_fonts = true, .no_system_icons = true, .reduced_motion = true }, }); defer siv.appDestroy(app); var t: TestDesk = undefined; try t.init(app, null, .now); defer t.deinit(); const work = t.label("Label_1"); try testing.expectEqualSlices(Label, &.{.inbox}, t.desk.filters.items); try testing.expectEqual(13, t.desk.ids.len); try t.press(app, "+%", &.{work}); try testing.expectEqualSlices(Label, &.{ .inbox, work }, t.desk.filters.items); try testing.expectEqual(6, t.desk.ids.len); for (t.desk.ids) |id| try testing.expect(t.mail.has(id, .inbox) and t.mail.has(id, work)); try testing.expect(try t.has(.{ .name = "Inbox + Work", .role = .heading })); // The agenda, newest, goes to Trash and out of every view but Trash. const agenda = t.desk.thread().?; try t.press(app, "#", &.{}); try testing.expectEqual(5, t.desk.ids.len); try t.press(app, "g%", &.{work}); try testing.expectEqualSlices(Label, &.{work}, t.desk.filters.items); try testing.expectEqual(10, t.desk.ids.len); try testing.expect(mem.findScalar(Mailbox.Id, t.desk.ids, agenda) == null); try t.press(app, "g#", &.{}); try testing.expectEqualSlices(Mailbox.Id, &.{agenda}, t.desk.ids); } test "+ and a label's key add a filter; Backspace, -, or a click on its chip removes one" { const app = try siv.appCreate(.{ .backend = .headless, .flags = .{ .no_system_fonts = true, .no_system_icons = true, .reduced_motion = true }, }); defer siv.appDestroy(app); var t: TestDesk = undefined; try t.init(app, null, .now); defer t.deinit(); const waiting = t.label("Label_7"); try t.press(app, "+%", &.{waiting}); try testing.expectEqualSlices(Label, &.{ .inbox, waiting }, t.desk.filters.items); try testing.expectEqual(Overlay.none, t.desk.overlay); try testing.expectEqual(1, t.desk.ids.len); try siv.testKey(t.window, .backspace, .{}, ""); try testing.expect(try siv.testSettle(app, 5000)); try testing.expectEqualSlices(Label, &.{.inbox}, t.desk.filters.items); try t.press(app, "+%", &.{waiting}); try clickOn(app, t.window, .{ .name = "Inbox", .role = .button }); try testing.expectEqualSlices(Label, &.{waiting}, t.desk.filters.items); try testing.expect(try t.has(.{ .name = "Waiting", .role = .heading })); // No filter is All Mail, which leaves out spam. try t.press(app, "-", &.{}); try testing.expectEqual(0, t.desk.filters.items.len); try testing.expect(try t.has(.{ .name = "All Mail", .role = .heading })); try testing.expectEqual(t.mail.threads.items.len - 1, t.desk.ids.len); } test "a click on a row opens its thread; a click on a chip in it adds that filter instead" { const app = try siv.appCreate(.{ .backend = .headless, .flags = .{ .no_system_fonts = true, .no_system_icons = true, .reduced_motion = true }, }); defer siv.appDestroy(app); var t: TestDesk = undefined; try t.init(app, null, .now); defer t.deinit(); const family = t.label("Label_3"); const first = t.desk.thread().?; try clickOn(app, t.window, .{ .name = "Family", .role = .button }); try testing.expectEqualSlices(Label, &.{ .inbox, family }, t.desk.filters.items); try testing.expectEqual(Overlay.none, t.desk.overlay); try testing.expect(t.mail.get(first).unread); const mom = t.find("Sunday dinner?"); try testing.expectEqual(null, t.desk.opened()); try clickOn(app, t.window, .{ .name = "Mom, me", .role = .label }); try testing.expectEqual(Overlay.open, t.desk.overlay); try testing.expectEqual(mom, t.desk.thread().?); // So new mail in it is not told of. try testing.expectEqualStrings(t.mail.get(mom).id, t.desk.opened().?); } test "Done in a view of user labels takes them off; in the Inbox it only archives" { const app = try siv.appCreate(.{ .backend = .headless, .flags = .{ .no_system_fonts = true, .no_system_icons = true, .reduced_motion = true }, }); defer siv.appDestroy(app); var t: TestDesk = undefined; try t.init(app, null, .now); defer t.deinit(); const work = t.label("Label_1"); const hiring = t.label("Label_2"); const agenda = t.desk.thread().?; try t.press(app, "e", &.{}); try testing.expect(!t.mail.has(agenda, .inbox)); try testing.expect(t.mail.has(agenda, work) and t.mail.has(agenda, .starred)); try testing.expectEqualStrings("Done: “Offsite agenda, final”", t.desk.notice); // Still in Work, and newest there. try t.press(app, "g%", &.{work}); try testing.expectEqual(agenda, t.desk.thread().?); try t.press(app, "e", &.{}); try testing.expect(!t.mail.has(agenda, work) and t.mail.has(agenda, .starred)); try testing.expect(mem.findScalar(Mailbox.Id, t.desk.ids, agenda) == null); // Both of Work + Hiring come off, and Inbox, as ever. try t.press(app, "+%", &.{hiring}); const packet = t.desk.thread().?; try testing.expect(t.mail.has(packet, .inbox)); try t.press(app, "e", &.{}); try testing.expect(!t.mail.has(packet, .inbox) and !t.mail.has(packet, work) and !t.mail.has(packet, hiring)); } test "a notification's Archive only archives, whatever the list shows, and z undoes it" { const app = try siv.appCreate(.{ .backend = .headless, .flags = .{ .no_system_fonts = true, .no_system_icons = true, .reduced_motion = true }, }); defer siv.appDestroy(app); var t: TestDesk = undefined; try t.init(app, null, .now); defer t.deinit(); const work = t.label("Label_1"); // In Work, where Done would take Work off too. const agenda = t.desk.thread().?; try t.press(app, "g%", &.{work}); try testing.expect(t.mail.has(agenda, .inbox) and t.mail.has(agenda, work)); try t.desk.archiveNamed(&.{ "no-such-thread", t.mail.get(agenda).id }); try testing.expect(!t.mail.has(agenda, .inbox)); try testing.expect(t.mail.has(agenda, work) and t.mail.has(agenda, .starred)); try testing.expectEqualStrings("Done: “Offsite agenda, final”", t.desk.notice); try t.press(app, "z", &.{}); try testing.expect(t.mail.has(agenda, .inbox) and t.mail.has(agenda, work)); } test "z undoes Done, Trash, a label, a star, and read, the last first, and the status line's undo does too" { const app = try siv.appCreate(.{ .backend = .headless, .flags = .{ .no_system_fonts = true, .no_system_icons = true, .reduced_motion = true }, }); defer siv.appDestroy(app); var t: TestDesk = undefined; try t.init(app, null, .now); defer t.deinit(); var heard: Heard = .{}; defer heard.changes.deinit(testing.allocator); t.mail.listener = heard.listener(); const work = t.label("Label_1"); const agenda = t.desk.thread().?; // Undone, it is back in the list, selected. try t.press(app, "e", &.{}); try testing.expect(!t.mail.has(agenda, .inbox)); try t.press(app, "jz", &.{}); try testing.expect(t.mail.has(agenda, .inbox)); try testing.expectEqual(agenda, t.desk.thread().?); try testing.expectEqualStrings("Undone: Done: “Offsite agenda, final”", t.desk.notice); try testing.expect(!try t.has(.{ .name = "undo", .role = .button })); // Out of the trash, its labels come back, and Gmail hears it should // be in the Inbox again. try t.press(app, "#", &.{}); try testing.expect(t.mail.has(agenda, .trash) and !t.mail.has(agenda, work)); try clickOn(app, t.window, .{ .name = "undo", .role = .button }); try testing.expect(!t.mail.has(agenda, .trash)); try testing.expect(t.mail.has(agenda, .inbox) and t.mail.has(agenda, work) and t.mail.has(agenda, .starred)); const last = heard.changes.getLast(); try testing.expect(last == .untrash and last.untrash.thread == agenda and last.untrash.inbox); // The last first: the star, then read. try testing.expect(t.mail.get(agenda).unread); try t.press(app, "us", &.{}); try testing.expect(!t.mail.get(agenda).unread and !t.mail.has(agenda, .starred)); try t.press(app, "z", &.{}); try testing.expect(!t.mail.get(agenda).unread and t.mail.has(agenda, .starred)); try t.press(app, "z", &.{}); try testing.expect(t.mail.get(agenda).unread); try t.press(app, "t%", &.{work}); try siv.testKey(t.window, .escape, .{}, ""); try testing.expect(!t.mail.has(agenda, work)); try t.press(app, "z", &.{}); try testing.expect(t.mail.has(agenda, work)); try t.press(app, "z", &.{}); try testing.expectEqualStrings("Nothing to undo", t.desk.notice); } test "x, Shift+j, and Ctrl or Shift clicks select threads; an action applies to them all, z undoes it, Esc lets go" { const app = try siv.appCreate(.{ .backend = .headless, .flags = .{ .no_system_fonts = true, .no_system_icons = true, .reduced_motion = true }, }); defer siv.appDestroy(app); var t: TestDesk = undefined; try t.init(app, null, .now); defer t.deinit(); const agenda = t.desk.thread().?; const dinner = t.find("Sunday dinner?"); try t.press(app, "xJ", &.{}); try testing.expectEqual(2, t.desk.marked.count()); try testing.expect(try t.has(.{ .name = "2 selected", .role = .label })); // Ctrl selects the row clicked rather than opening it. try siv.testModifiers(t.window, .{ .ctrl = true }); try clickOn(app, t.window, .{ .name = "Priya, me", .role = .label }); try siv.testModifiers(t.window, .{}); try testing.expectEqual(Overlay.none, t.desk.overlay); try testing.expectEqual(3, t.desk.marked.count()); var found = false; var node: siv.TestNode = .{}; const packet = "Select “Candidate packet: Jonas Weber”"; try siv.testFind(t.window, .{ .name = packet, .role = .checkbox }, &found, &node); try testing.expect(found and node.checked == .on); try siv.testFind(t.window, .{ .name = "Select “Sunday dinner?”", .role = .checkbox }, &found, &node); try testing.expect(found and node.checked == .off); // Done takes all three out of the Inbox, and z brings all three back. var picked: [3]Mailbox.Id = undefined; @memcpy(&picked, t.desk.marked.keys()); try t.press(app, "e", &.{}); for (picked) |id| try testing.expect(!t.mail.has(id, .inbox)); try testing.expectEqualStrings("Done: 3 threads", t.desk.notice); try testing.expectEqual(0, t.desk.marked.count()); try t.press(app, "z", &.{}); for (picked) |id| try testing.expect(t.mail.has(id, .inbox)); try testing.expectEqual(agenda, t.desk.thread().?); // Shift selects from the cursor to the row clicked. Not all four are // starred, so s stars them all, and z unstars those it starred. try siv.testModifiers(t.window, .{ .shift = true }); try clickOn(app, t.window, .{ .name = "Priya, me", .role = .label }); try siv.testModifiers(t.window, .{}); try testing.expectEqual(4, t.desk.marked.count()); try t.press(app, "s", &.{}); for (t.desk.marked.keys()) |id| try testing.expect(t.mail.has(id, .starred)); try t.press(app, "z", &.{}); try testing.expect(t.mail.has(agenda, .starred) and !t.mail.has(dinner, .starred)); // A checkbox's click lets its thread go; Esc lets the rest go. try clickOn(app, t.window, .{ .name = "Select “Sunday dinner?”", .role = .checkbox }); try testing.expectEqual(3, t.desk.marked.count()); try testing.expect(!t.desk.marked.contains(dinner)); try siv.testKey(t.window, .escape, .{}, ""); try testing.expect(try siv.testSettle(app, 5000)); try testing.expectEqual(0, t.desk.marked.count()); try testing.expect(!try t.has(.{ .role = .checkbox })); } test "a search marks what its words matched in each row's sender, subject, and snippet" { const app = try siv.appCreate(.{ .backend = .headless, .flags = .{ .no_system_fonts = true, .no_system_icons = true, .reduced_motion = true }, }); defer siv.appDestroy(app); var t: TestDesk = undefined; try t.init(app, null, .now); defer t.deinit(); const sender: siv.TestQuery.Options = .{ .name = "Ravi, me", .role = .label }; const line: siv.TestQuery.Options = .{ .name = "Re: render thread priorities " ++ "I tried SCHED_FIFO for the compositor thread and frame pacing got worse, not better.", .role = .label, }; try testing.expectEqual(0, try accentPixels(t.window, sender)); try testing.expectEqual(0, try accentPixels(t.window, line)); try t.press(app, "/", &.{}); for ("from:ravi render") |c| try siv.testKey(t.window, @fromBackingInt(c), .{}, &.{c}); try testing.expect(try siv.testSettle(app, 5000)); try testing.expect(try accentPixels(t.window, sender) > 0); const subject = try accentPixels(t.window, line); try testing.expect(subject > 0); // A term that leaves out marks nothing; the snippet's word is marked // with the subject's. for ("from:ravi render") |_| try siv.testKey(t.window, .backspace, .{}, ""); for ("-subject:ravi fifo render") |c| try siv.testKey(t.window, @fromBackingInt(c), .{}, &.{c}); try testing.expect(try siv.testSettle(app, 5000)); try testing.expectEqual(0, try accentPixels(t.window, sender)); try testing.expect(try accentPixels(t.window, line) > subject); } /// How many pixels of the node `query` finds are drawn in the accent, as /// the text a search matched is. fn accentPixels(window: siv.Window, query: siv.TestQuery.Options) !u32 { var found = false; var node: siv.TestNode = .{}; try siv.testFind(window, query, &found, &node); try testing.expect(found); var len: usize = 0; var w: u32 = 0; var h: u32 = 0; try siv.testCapture(window, &.{}, &len, &w, &h); const pixels = try testing.allocator.alloc(u8, len); defer testing.allocator.free(pixels); try siv.testCapture(window, pixels, &len, &w, &h); const accent = try siv.windowGetColor(window, .accent); const want = [3]f32{ accent.r * 255, accent.g * 255, accent.b * 255 }; const b = node.bounds; var count: u32 = 0; var y: u32 = @intFromFloat(@max(0, b.y)); while (y < @min(h, @as(u32, @intFromFloat(b.y + b.height)))) : (y += 1) { var x: u32 = @intFromFloat(@max(0, b.x)); while (x < @min(w, @as(u32, @intFromFloat(b.x + b.width)))) : (x += 1) { const px = pixels[(y * w + x) * 4 ..][0..3]; var far: f32 = 0; for (px, want) |got, c| far += @abs(@as(f32, @floatFromInt(got)) - c); if (far < 24) count += 1; } } return count; } /// Every change the mailbox tells, in order, as Gmail would hear them. const Heard = struct { changes: ArrayList(Mailbox.Change) = .empty, fn listener(self: *Heard) Mailbox.Listener { return .{ .context = self, .changed = changed }; } fn changed(context: *anyopaque, _: *const Mailbox, change: Mailbox.Change) void { const self: *Heard = @ptrCast(@alignCast(context)); self.changes.append(testing.allocator, change) catch |err| std.debug.panic("{t}", .{err}); } }; test "the filters come back as they were left, once their labels load" { var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); const settings: Settings = .{ .io = testing.io, .dir = tmp.dir }; const app = try siv.appCreate(.{ .backend = .headless, .flags = .{ .no_system_fonts = true, .no_system_icons = true, .reduced_motion = true }, }); defer siv.appDestroy(app); var first: TestDesk = undefined; try first.init(app, settings, .now); try first.press(app, "g%+%", &.{ first.label("Label_1"), first.label("Label_7") }); first.deinit(); var buf: [64]u8 = undefined; try testing.expectEqualStrings("Label_1\nLabel_7\n", try tmp.dir.readFile(testing.io, "filters", &buf)); // The mail comes after the window, as Gmail's does: until then // neither label is known. var second: TestDesk = undefined; try second.init(app, settings, .later); defer second.deinit(); try testing.expectEqual(0, second.desk.filters.items.len); var later: Mailbox = try .init(testing.allocator); TestDesk.loadSample(&later) catch |err| { later.deinit(); return err; }; try second.desk.replaceMail(later); try testing.expect(try siv.testSettle(app, 5000)); const work = second.label("Label_1"); const waiting = second.label("Label_7"); try testing.expectEqualSlices(Label, &.{ work, waiting }, second.desk.filters.items); try testing.expect(try second.has(.{ .name = "Work + Waiting", .role = .heading })); } test "/ searches the loaded mail as it is typed, then with what Gmail finds once typing pauses; Enter takes it" { const app = try siv.appCreate(.{ .backend = .headless, .flags = .{ .no_system_fonts = true, .no_system_icons = true, .reduced_motion = true }, }); defer siv.appDestroy(app); var t: TestDesk = undefined; try t.init(app, null, .now); defer t.deinit(); var asked: PagesAsked = .{}; t.desk.pages = asked.pages(); const travel = t.label("Label_5"); const work = t.label("Label_1"); try t.press(app, "/", &.{}); try testing.expectEqual(Overlay.search, t.desk.overlay); for ("lisbon") |c| try siv.testKey(t.window, @fromBackingInt(c), .{}, &.{c}); try testing.expect(try siv.testSettle(app, 5000)); // Three sample threads speak of Lisbon, by subject or by snippet, one // of them out of the Inbox. try testing.expectEqual(0, t.desk.filters.items.len); try testing.expectEqual(3, t.desk.ids.len); try testing.expect(try t.has(.{ .name = "Search", .role = .heading })); // Once typing pauses, Gmail is asked, and the field stays open. try waitPast(app, 10); try testing.expectEqualStrings("lisbon", asked.search); try testing.expect(asked.first and asked.labels == 0); try testing.expectEqual(Overlay.search, t.desk.overlay); // Not again for the same search. const count = asked.count; try siv.testKey(t.window, .down, .{}, ""); try testing.expect(try siv.testSettle(app, 5000)); try testing.expectEqual(Overlay.none, t.desk.overlay); try t.press(app, "jj", &.{}); try testing.expectEqualStrings("Lisbon dinner spots", t.mail.get(t.desk.thread().?).subject); // Gmail's answer joins the local one, which it may lack, as Gmail // matches whole words; the selection stays. An older search's is // dropped. try t.desk.paged(&.{}, "lisb", &.{"sample-0"}, false, null); try testing.expectEqual(3, t.desk.ids.len); try t.desk.paged(&.{}, "lisbon", &.{ "sample-0", "sample-1" }, false, null); try testing.expectEqual(4, t.desk.ids.len); try testing.expectEqualStrings("Gmail found 2", t.desk.notice); try testing.expectEqualStrings("Lisbon dinner spots", t.mail.get(t.desk.thread().?).subject); try testing.expectEqual(count, asked.count); // A filter narrows what was found: of the four, Delta's and Ravi's // dinner spots are Travel. try t.press(app, "+%", &.{travel}); try testing.expectEqualSlices(Label, &.{travel}, t.desk.filters.items); try testing.expectEqual(2, t.desk.ids.len); try testing.expectEqualStrings("sample-4", t.mail.get(t.desk.ids[0]).id); try testing.expect(try t.has(.{ .name = "“lisbon”", .role = .button })); // Escape goes back to the search's words, a second ends the search, // and the filters from before it come back. try siv.testKey(t.window, .escape, .{}, ""); try testing.expect(try siv.testSettle(app, 5000)); try testing.expectEqual(Overlay.search, t.desk.overlay); try testing.expectEqualStrings("lisbon", t.desk.search); try siv.testKey(t.window, .escape, .{}, ""); try testing.expect(try siv.testSettle(app, 5000)); try testing.expectEqualStrings("", t.desk.search); try testing.expectEqualSlices(Label, &.{.inbox}, t.desk.filters.items); try testing.expectEqual(13, t.desk.ids.len); // So does the search's chip, even with the list drawn again between // the press and the release, as when the search's pause comes; and // showing only a label ends it too. try t.press(app, "/", &.{}); for ("lisbon") |c| try siv.testKey(t.window, @fromBackingInt(c), .{}, &.{c}); try testing.expect(try siv.testSettle(app, 5000)); const chip = try centre(t.window, .{ .name = "“lisbon”", .role = .button }); try siv.testPointerMove(t.window, chip[0], chip[1]); try siv.testPointerButton(t.window, .primary, true); try testing.expect(try siv.testSettle(app, 5000)); try t.desk.refresh(); try siv.testPointerButton(t.window, .primary, false); try testing.expect(try siv.testSettle(app, 5000)); try testing.expectEqualStrings("", t.desk.search); try testing.expectEqualSlices(Label, &.{.inbox}, t.desk.filters.items); try t.press(app, "/", &.{}); for ("lisbon") |c| try siv.testKey(t.window, @fromBackingInt(c), .{}, &.{c}); try siv.testKey(t.window, .down, .{}, ""); try testing.expect(try siv.testSettle(app, 5000)); try t.press(app, "g%", &.{work}); try testing.expectEqualStrings("", t.desk.search); try testing.expectEqualSlices(Label, &.{work}, t.desk.filters.items); // Too short a search waits for Enter, which asks at once and goes to // the results; q ends the search. try t.press(app, "/", &.{}); const before_short = asked.count; for ("qz") |c| try siv.testKey(t.window, @fromBackingInt(c), .{}, &.{c}); try waitPast(app, 10); try testing.expectEqual(before_short, asked.count); try siv.testKey(t.window, .@"return", .{}, ""); try testing.expect(try siv.testSettle(app, 5000)); try testing.expectEqualStrings("qz", asked.search); try testing.expectEqual(Overlay.none, t.desk.overlay); try t.press(app, "q", &.{}); try testing.expectEqualStrings("", t.desk.search); // Enter takes a search that matched and goes to the results, not // into a thread; i goes back to its words, and Enter there opens the // selected thread. try t.press(app, "/", &.{}); for ("lisbon") |c| try siv.testKey(t.window, @fromBackingInt(c), .{}, &.{c}); try siv.testKey(t.window, .@"return", .{}, ""); try testing.expect(try siv.testSettle(app, 5000)); try testing.expectEqual(Overlay.none, t.desk.overlay); try testing.expect(kit.focusWithin(t.window, t.desk.list)); try testing.expectEqualStrings("lisbon", t.desk.search); try t.press(app, "i", &.{}); try testing.expectEqual(Overlay.search, t.desk.overlay); try testing.expect(kit.focusWithin(t.window, t.desk.search_field)); try siv.testKey(t.window, .@"return", .{}, ""); try testing.expect(try siv.testSettle(app, 5000)); try testing.expectEqualStrings("lisbon", t.desk.search); try siv.testKey(t.window, .@"return", .{}, ""); try testing.expect(try siv.testSettle(app, 5000)); try testing.expectEqual(Overlay.open, t.desk.overlay); } test "a search the index answers from part of the mail says since when, and F asks the server for older" { const app = try siv.appCreate(.{ .backend = .headless, .flags = .{ .no_system_fonts = true, .no_system_icons = true, .reduced_motion = true }, }); defer siv.appDestroy(app); var t: TestDesk = undefined; try t.init(app, null, .now); defer t.deinit(); var asked: PagesAsked = .{}; t.desk.pages = asked.pages(); try t.press(app, "/", &.{}); for ("lisbon") |c| try siv.testKey(t.window, @fromBackingInt(c), .{}, &.{c}); try testing.expect(try siv.testSettle(app, 5000)); try waitPast(app, 10); try testing.expectEqual(Reach.any, asked.reach); try siv.testKey(t.window, .down, .{}, ""); try testing.expect(try siv.testSettle(app, 5000)); try testing.expect(!try t.has(.{ .name = "search older mail", .role = .button })); // The index answers from the mail it holds, which goes back to 2001. try t.desk.paged(&.{}, "lisbon", &.{"sample-0"}, false, 1_000_000_000); try testing.expect(try siv.testSettle(app, 5000)); try testing.expectEqualStrings("Found 1 in the mail since Sep 9, 2001", t.desk.notice); try testing.expectEqual(3, t.desk.ids.len); try testing.expect(try t.has(.{ .name = "search older mail", .role = .button })); // F asks the server from the top, whose answer joins the list. const count = asked.count; try t.press(app, "F", &.{}); try testing.expectEqual(count + 1, asked.count); try testing.expect(asked.first); try testing.expectEqual(Reach.server, asked.reach); try testing.expectEqualStrings("lisbon", asked.search); try testing.expect(!try t.has(.{ .name = "search older mail", .role = .button })); try t.desk.paged(&.{}, "lisbon", &.{ "sample-0", "sample-1" }, true, null); try testing.expect(try siv.testSettle(app, 5000)); try testing.expectEqualStrings("Gmail found 2, and more as you scroll", t.desk.notice); try testing.expectEqual(4, t.desk.ids.len); // More of it comes from the server too, and a new search starts // with the index again. try t.press(app, "G", &.{}); try testing.expectEqual(count + 2, asked.count); try testing.expect(!asked.first); try testing.expectEqual(Reach.server, asked.reach); try t.press(app, "/", &.{}); for ("x") |c| try siv.testKey(t.window, @fromBackingInt(c), .{}, &.{c}); try testing.expect(try siv.testSettle(app, 5000)); try waitPast(app, 10); try testing.expectEqualStrings("lisbonx", asked.search); try testing.expectEqual(Reach.any, asked.reach); } test "search suggests operators, and after one people, labels, or values, but never completes a bare name" { const app = try siv.appCreate(.{ .backend = .headless, .flags = .{ .no_system_fonts = true, .no_system_icons = true, .reduced_motion = true }, }); defer siv.appDestroy(app); var t: TestDesk = undefined; try t.init(app, null, .now); defer t.deinit(); const contacts = &t.desk.compose.contacts; try contacts.note(.heard, "Maya Okafor ", 2000); try contacts.note(.heard, "Mark Ito ", 1000); const typed = struct { fn text(desk: *TestDesk, a: siv.App, s: []const u8) !void { for (s) |c| try siv.testKey(desk.window, @fromBackingInt(c), .{}, &.{c}); try testing.expect(try siv.testSettle(a, 5000)); } }.text; const key = struct { fn press(desk: *TestDesk, a: siv.App, k: siv.Key) !void { try siv.testKey(desk.window, k, .{}, ""); try testing.expect(try siv.testSettle(a, 5000)); } }.press; try t.press(app, "/", &.{}); try typed(&t, app, "lisbon fro"); try testing.expectEqual(1, t.desk.suggested_count); try testing.expect(try siv.nodeIsShown(t.desk.suggestions)); // Tab takes the operator, with no space, and goes on to its people. try key(&t, app, .tab); try testing.expectEqualStrings("lisbon from:", t.desk.search_text); try testing.expectEqual(2, t.desk.suggested_count); // Enter, while they show, takes the highlighted one, and stays. try typed(&t, app, "ma"); try testing.expectEqualStrings("from:mark@example.com", t.desk.suggested[1].text()); // Ctrl+N and Ctrl+P move as Down and Up do, wrapping. try siv.testKey(t.window, @fromBackingInt('p'), .{ .ctrl = true }, ""); try testing.expect(try siv.testSettle(app, 5000)); try testing.expectEqual(1, try siv.widgetGetValue(t.desk.suggestions)); try siv.testKey(t.window, @fromBackingInt('n'), .{ .ctrl = true }, ""); try testing.expect(try siv.testSettle(app, 5000)); try key(&t, app, .down); try key(&t, app, .@"return"); try testing.expectEqualStrings("lisbon from:mark@example.com ", t.desk.search_text); try testing.expectEqual(Overlay.search, t.desk.overlay); try testing.expect(!try siv.nodeIsShown(t.desk.suggestions)); // A name alone is a word to search, not a person to complete. try typed(&t, app, "maya"); try testing.expectEqual(0, t.desk.suggested_count); try testing.expect(!try siv.nodeIsShown(t.desk.suggestions)); try typed(&t, app, " -label:tr"); try testing.expectEqualStrings("-label:travel", t.desk.suggested[0].text()); try typed(&t, app, "a is:s"); try testing.expectEqualStrings("is:starred", t.desk.suggested[0].text()); // Esc closes them, and a second Esc ends the search. try key(&t, app, .escape); try testing.expect(!try siv.nodeIsShown(t.desk.suggestions)); try testing.expectEqual(Overlay.search, t.desk.overlay); try key(&t, app, .escape); try testing.expectEqualStrings("", t.desk.search); } /// Runs the app's loop until `ms` have passed, so the timers due run. fn waitPast(app: siv.App, ms: i32) !void { const start = Io.Timestamp.now(testing.io, .awake); while (start.durationTo(.now(testing.io, .awake)).toMilliseconds() < ms * 3) try siv.appDispatch(app, ms); _ = try siv.testSettle(app, 5000); } test "the list's end asks Gmail for more of what it shows, once until the answer comes" { const app = try siv.appCreate(.{ .backend = .headless, .flags = .{ .no_system_fonts = true, .no_system_icons = true, .reduced_motion = true }, }); defer siv.appDestroy(app); var t: TestDesk = undefined; try t.init(app, null, .now); defer t.deinit(); // Older mail in the Inbox, more than the window shows. for (0..60) |i| try addOlder(&t.mail, i, &.{.inbox}); var asked: PagesAsked = .{}; t.desk.pages = asked.pages(); try t.desk.grew(); try testing.expect(try siv.testSettle(app, 5000)); try testing.expectEqual(73, t.desk.ids.len); try testing.expectEqual(0, asked.count); // Near the end, it asks for more of the Inbox, and not again while // it waits. const sixty: [60]u8 = @splat('j'); try t.press(app, &sixty, &.{}); try testing.expectEqual(1, asked.count); try testing.expectEqualStrings("INBOX", asked.label()); try testing.expect(!asked.first and asked.search.len == 0); try testing.expect(!try siv.nodeIsHidden(t.desk.spinner)); try t.press(app, "j", &.{}); try testing.expectEqual(1, asked.count); // The session added what Gmail had; the list shows it, the same // thread still selected, and asks on, still near the end. const selected = t.desk.thread().?; try addOlder(&t.mail, 60, &.{.inbox}); try t.desk.paged(&.{"INBOX"}, "", &.{"older-60"}, true, null); try testing.expect(try siv.testSettle(app, 5000)); try testing.expectEqual(74, t.desk.ids.len); try testing.expectEqual(selected, t.desk.thread().?); try testing.expectEqual(2, asked.count); try testing.expect(!try siv.nodeIsHidden(t.desk.spinner)); // At the end of Gmail's list it stops asking, and says it waits no // more. try t.desk.paged(&.{"INBOX"}, "", &.{}, false, null); try testing.expect(try siv.testSettle(app, 5000)); try testing.expect(try siv.nodeIsHidden(t.desk.spinner)); try t.press(app, "jk", &.{}); try testing.expectEqual(2, asked.count); // Another view asks for its own; a failure says so, and scrolling // asks again. const work = t.label("Label_1"); try t.press(app, "g%", &.{work}); try testing.expectEqual(3, asked.count); try testing.expectEqualStrings("Label_1", asked.label()); try t.desk.paged(&.{"Label_1"}, "", null, false, null); try testing.expectEqualStrings("Could not load more from Gmail", t.desk.notice); try t.press(app, "j", &.{}); try testing.expectEqual(3, asked.count); } /// Records what the desk asks Gmail for. const PagesAsked = struct { count: u32 = 0, labels: u32 = 0, label_buf: [32]u8 = undefined, label_len: u32 = 0, search_buf: [32]u8 = undefined, search: []const u8 = "", first: bool = false, reach: Reach = .any, fn pages(self: *PagesAsked) Pages { return .{ .context = self, .more = more, .pause_ms = 10, .shortest = 3 }; } fn label(self: *const PagesAsked) []const u8 { return self.label_buf[0..self.label_len]; } fn more(context: *anyopaque, labels: []const []const u8, search: []const u8, first: bool, reach: Reach) void { const self: *PagesAsked = @ptrCast(@alignCast(context)); self.count += 1; self.labels = @intCast(labels.len); const first_label: []const u8 = if (labels.len > 0) labels[0] else ""; const printed: []const u8 = mem.print(&self.label_buf, "{s}", .{first_label}) catch ""; self.label_len = @intCast(printed.len); self.search = mem.print(&self.search_buf, "{s}", .{search}) catch ""; self.first = first; self.reach = reach; } }; /// Adds an old thread, older the greater `i`. fn addOlder(mail: *Mailbox, i: usize, labels: []const Label) !void { var id_buf: [16]u8 = undefined; _ = try mail.addThread(.{ .id = try mem.print(&id_buf, "older-{d}", .{i}), .participants = "Ana", .subject = "Old news", .snippet = "", .date = 1_000_000 - @as(i64, @intCast(i)), .messages = 1, .labels = labels, .unread = false, .notes = &.{}, }); } test "Ctrl+= and Ctrl+- zoom the open thread's messages, which still wrap to the card; Ctrl+0 puts them back" { const app = try siv.appCreate(.{ .backend = .headless, .flags = .{ .no_system_fonts = true, .no_system_icons = true, .reduced_motion = true }, }); defer siv.appDestroy(app); var t: TestDesk = undefined; try t.init(app, null, .now); defer t.deinit(); t.answerWithSamples(); try t.openAndRead(app); const file: siv.TestQuery.Options = .{ .role = .button, .name = "Open agenda.txt" }; const before = try t.bounds(file); const dialog = try t.bounds(.{ .role = .dialog }); for (0..2) |_| { try siv.testKey(t.window, @fromBackingInt('='), .{ .ctrl = true }, ""); try testing.expect(try siv.testSettle(app, 5000)); } try testing.expectEqual(1.25, t.zoom.scale); try testing.expectApproxEqAbs(before.height * 1.25, (try t.bounds(file)).height, 0.5); // Laid out again to fit, the message is no wider than the card, as // it would be by a quarter scaled alone. const doc = try t.bounds(.{ .role = .document }); try testing.expect(doc.x + doc.width <= dialog.x + dialog.width); try siv.testKey(t.window, @fromBackingInt('-'), .{ .ctrl = true }, ""); try testing.expect(try siv.testSettle(app, 5000)); try testing.expectEqual(1.1, t.zoom.scale); try siv.testKey(t.window, @fromBackingInt('0'), .{ .ctrl = true }, ""); try testing.expect(try siv.testSettle(app, 5000)); try testing.expectEqual(1, t.zoom.scale); try testing.expectApproxEqAbs(before.height, (try t.bounds(file)).height, 0.5); // Ctrl+wheel, like a pinch, zooms in siv; the reader takes the scale. try siv.testPointerMove(t.window, doc.x + 20, doc.y + 20); try siv.testModifiers(t.window, .{ .ctrl = true }); try siv.testScroll(t.window, 0, -10); try testing.expect(try siv.testSettle(app, 5000)); try siv.testModifiers(t.window, .{}); try testing.expect(try siv.testSettle(app, 5000)); try testing.expect(t.zoom.scale > 1 and t.zoom.scale < 1.1); const wheeled = try t.bounds(.{ .role = .document }); try testing.expect(wheeled.x + wheeled.width <= dialog.x + dialog.width); try siv.testKey(t.window, @fromBackingInt('='), .{ .ctrl = true }, ""); try testing.expect(try siv.testSettle(app, 5000)); try testing.expectEqual(1.1, t.zoom.scale); // Zoomed out, the message reflows to fill the card again, rather // than shrinking into its left part. for (0..3) |_| { try siv.testKey(t.window, @fromBackingInt('-'), .{ .ctrl = true }, ""); try testing.expect(try siv.testSettle(app, 5000)); } try testing.expect(t.zoom.scale < 1); const out = try t.bounds(.{ .role = .document }); try testing.expect(out.width >= doc.width and out.x + out.width <= dialog.x + dialog.width); } test "Ctrl+N and Ctrl+P move in the palette, the list, and To's suggestions; Ctrl+K kills to a one-line field's end" { const app = try siv.appCreate(.{ .backend = .headless, .flags = .{ .no_system_fonts = true, .no_system_icons = true, .reduced_motion = true }, }); defer siv.appDestroy(app); var t: TestDesk = undefined; try t.init(app, null, .now); defer t.deinit(); const ctrl = struct { fn key(desk: *TestDesk, a: siv.App, c: u8) !void { try siv.testKey(desk.window, @fromBackingInt(c), .{ .ctrl = true }, ""); try testing.expect(try siv.testSettle(a, 5000)); } }.key; const typed = struct { fn text(desk: *TestDesk, a: siv.App, s: []const u8) !void { for (s) |c| try siv.testKey(desk.window, @fromBackingInt(c), .{}, &.{c}); try testing.expect(try siv.testSettle(a, 5000)); } }.text; try t.press(app, ":", &.{}); try ctrl(&t, app, 'n'); try testing.expectEqual(1, t.desk.selected); try ctrl(&t, app, 'p'); try testing.expectEqual(0, t.desk.selected); try siv.testKey(t.window, .escape, .{}, ""); try testing.expect(try siv.testSettle(app, 5000)); const first = t.desk.current.?; try ctrl(&t, app, 'n'); try testing.expectEqual(first + 1, t.desk.current.?); try ctrl(&t, app, 'p'); try testing.expectEqual(first, t.desk.current.?); const c = &t.desk.compose; try c.contacts.note(.heard, "Mark Ito ", 2000); try c.contacts.note(.heard, "Maya Okafor ", 1000); try c.contacts.note(.heard, "Mia Wong ", 900); try t.press(app, "c", &.{}); try typed(&t, app, "m"); try testing.expectEqual(3, c.suggestions.count); // From the first, three down wrap round to it, and two up wrap back // past it to the second. for ("nnnpp") |k| try ctrl(&t, app, k); var buf: [128]u8 = undefined; const second = try mem.print(&buf, "{f}", .{c.suggestions.found[1]}); try siv.testKey(t.window, .@"return", .{}, ""); try testing.expect(try siv.testSettle(app, 5000)); try testing.expect(mem.startsWith(u8, c.draft.to.items, second)); try siv.nodeFocus(c.subject_field); try typed(&t, app, "Hello world"); for (0..5) |_| try siv.testKey(t.window, .left, .{}, ""); try ctrl(&t, app, 'k'); try testing.expectEqualStrings("Hello ", c.draft.subject.items); // Not in the body, where a rich text editor's Ctrl+K inserts a link. try siv.nodeFocus(c.body_field); try typed(&t, app, "ione two"); try siv.testKey(t.window, .home, .{}, ""); try ctrl(&t, app, 'k'); try testing.expectEqualStrings("one two", c.draft.body.items); } test "Vim's : over the draft opens the palette of the draft's commands, by Vim's names too" { const app = try siv.appCreate(.{ .backend = .headless, .flags = .{ .no_system_fonts = true, .no_system_icons = true, .reduced_motion = true }, }); defer siv.appDestroy(app); var t: TestDesk = undefined; try t.init(app, null, .now); defer t.deinit(); const c = &t.desk.compose; const typed = struct { fn text(desk: *TestDesk, a: siv.App, s: []const u8) !void { for (s) |ch| try siv.testKey(desk.window, @fromBackingInt(ch), .{}, &.{ch}); // The editor says which mode it is in once a key is done. try siv.appDispatch(a, 20); try testing.expect(try siv.testSettle(a, 5000)); } }.text; const key = struct { fn press(desk: *TestDesk, a: siv.App, k: siv.Key) !void { try siv.testKey(desk.window, k, .{}, ""); try siv.appDispatch(a, 20); try testing.expect(try siv.testSettle(a, 5000)); } }.press; try t.press(app, "c", &.{}); try c.focusBody(); try typed(&t, app, ":"); try testing.expectEqual(Overlay.palette, t.desk.overlay); try testing.expectEqual(Action.send, t.desk.shown[0].action); for (t.desk.shown) |command| try testing.expect(command.action != .open and command.action != .compose); // Esc goes back to writing. try key(&t, app, .escape); try testing.expectEqual(Overlay.compose, t.desk.overlay); try testing.expect(kit.focusWithin(t.window, c.body_field)); // In insert mode, : types. try typed(&t, app, "iHi:"); try testing.expectEqual(Overlay.compose, t.desk.overlay); try testing.expectEqualStrings("Hi:", c.draft.body.items); try key(&t, app, .escape); // :w saves, and the draft stays open. try typed(&t, app, ":w"); try testing.expectEqual(Action.draft_write, t.desk.shown[0].action); try key(&t, app, .@"return"); try testing.expectEqual(Overlay.compose, t.desk.overlay); try testing.expectEqualStrings("Draft saved", t.desk.notice); // :q asks; Esc on the question goes back to writing. try typed(&t, app, ":q"); try testing.expectEqual(Action.draft_ask, t.desk.shown[0].action); try key(&t, app, .@"return"); try testing.expect(c.asking != .null); try key(&t, app, .escape); try testing.expect(c.asking == .null); try testing.expectEqual(Overlay.compose, t.desk.overlay); // :q! throws it away. try typed(&t, app, ":q!"); try key(&t, app, .@"return"); try testing.expect(t.desk.overlay != .compose); try testing.expectEqualStrings("Draft discarded", t.desk.notice); // The server telling of the change at once leaves it, as it takes a // session's news away. try t.desk.updated(&.{}); try testing.expectEqualStrings("Draft discarded", t.desk.notice); try t.desk.tell("Could not load mail from the server", .{}); try t.desk.updated(&.{}); try testing.expectEqualStrings("", t.desk.notice); }