Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286//! The accounts while the app runs: a desk each in the one window, one//! shown at a time. Each keeps its own mail, filters, selection, open//! thread, and draft while another shows. With more than one, the shown//! desk's header names its account and offers the others with their//! Inbox's unread count, marked while mail has come since the user last//! looked; Ctrl and a digit, a click there, or the palette shows one.
const std = @import("std");const siv = @import("siv");const zeit = @import("zeit");const Desk = @import("Desk.zig");const JmapSession = @import("JmapSession.zig");const Mailbox = @import("Mailbox.zig");const Session = @import("Session.zig");const Settings = @import("Settings.zig");const Images = @import("Images.zig");const Zoom = @import("Zoom.zig");const kit = @import("kit.zig");const sample = @import("sample.zig");const assert = std.debug.assert;const mem = std.mem;const Allocator = std.mem.Allocator;const Io = std.Io;const testing = std.testing;const log = std.log.scoped(.accounts);
const Accounts = @This();
gpa: Allocator,io: Io,window: siv.Window,list: []Account,/// The account whose desk shows.shown: u8,/// How large every account's reader shows mail, kept in `top`.zoom: Zoom,images: Images,
pub const Account = struct { accounts: *Accounts, index: u8, name: []const u8, settings: ?Settings, mail: Mailbox, /// The node its desk fills, hidden with it: shown, though empty, it /// would take the pointer from the desk under it. slot: siv.Node, desk: ?*Desk, /// What runs it: Gmail's session or a JMAP server's, once signed in. session: ?*Session, jmap: ?*JmapSession, /// When the user last left its desk, in seconds since the epoch: /// Inbox mail since is news. left_at: i64,};
/// The accounts named, in that order, with no mail and no desk yet. Each/// keeps its settings in its own directory under `top`; an empty name is/// the one account there is, as with the samples, and keeps them in/// `top` itself.pub fn create(gpa: Allocator, io: Io, window: siv.Window, names: []const []const u8, top: ?Settings) !*Accounts { assert(names.len > 0); assert(names.len <= Desk.max_accounts); const self = try gpa.create(Accounts); errdefer gpa.destroy(self); const list = try gpa.alloc(Account, names.len); errdefer gpa.free(list); self.* = .{ .gpa = gpa, .io = io, .window = window, .list = list, .shown = 0, .zoom = .load(top), .images = .load(top), }; const now = Io.Timestamp.now(io, .real).toSeconds(); for (list, names, 0..) |*a, name, i| { a.* = .{ .accounts = self, .index = @intCast(i), .name = name, .settings = if (name.len == 0) top else if (top) |t| t.account(name) else null, .mail = try .init(gpa), .slot = .null, .desk = null, .session = null, .jmap = null, .left_at = now, }; } return self;}
/// Saves the zoom, and frees the desks and the mail. The sessions must/// be gone, and the window, whose handlers hold the desks.pub fn destroy(self: *Accounts) void { self.zoom.save(); for (self.list) |*a| { assert(a.session == null); assert(a.jmap == null); if (a.desk) |d| d.destroy(); a.mail.deinit(); if (a.name.len > 0) if (a.settings) |s| s.close(); } self.gpa.free(self.list); self.gpa.destroy(self);}
/// Builds each account's desk on its mail, the first shown. The desks/// take `zones`, one each.pub fn build(self: *Accounts, zones: []const zeit.TimeZone) !void { const txn = try siv.txnBegin(self.window); errdefer siv.txnAbort(txn); const stack = siv.stack(txn, siv.windowGetRoot(self.window), .{}); siv.setLayout(txn, stack, .{ .width = kit.whole, .height = kit.whole }); for (self.list) |*a| { a.slot = siv.stack(txn, stack, .{}); siv.setLayout(txn, a.slot, .{ .width = kit.whole, .height = kit.whole }); } try siv.txnCommit(txn); for (self.list, zones) |*a, zone| { a.desk = try Desk.create( self.gpa, self.io, zone, self.window, a.slot, &a.mail, a.settings, &self.zoom, &self.images, ); } if (self.list.len == 1) return; for (self.list) |*a| a.desk.?.switcher = .{ .context = a, .name = a.name, .index = a.index, .others = others, .show = show, .changed = changed, }; for (self.list) |*a| try a.desk.?.show(a.index == self.shown); // Only now: a desk focuses its list as it is made, which a hidden // node cannot take. const hide = try siv.txnBegin(self.window); errdefer siv.txnAbort(hide); for (self.list) |*a| siv.setHidden(hide, a.slot, a.index != self.shown); try siv.txnCommit(hide);}
/// The account `index` names, if there is one.pub fn get(self: *Accounts, index: u8) ?*Account { return if (index < self.list.len) &self.list[index] else null;}
fn others(context: *anyopaque, buf: []Desk.Other) []const Desk.Other { const account: *Account = @ptrCast(@alignCast(context)); var n: usize = 0; for (account.accounts.list) |*a| { if (a == account) continue; const desk = a.desk orelse continue; const count = inboxCount(desk.mail, a.left_at); buf[n] = .{ .index = a.index, .name = a.name, .unread = count.unread, .fresh = count.fresh }; n += 1; } return buf[0..n];}
const Count = struct { unread: u32, fresh: bool };
/// How many threads in `mail`'s Inbox are unread, and whether one of/// them came after `since`.fn inboxCount(mail: *const Mailbox, since: i64) Count { var count: Count = .{ .unread = 0, .fresh = false }; for (mail.threads.items, 0..) |t, i| { if (!t.unread or !mail.has(@intCast(i), .inbox)) continue; count.unread += 1; if (t.date > since) count.fresh = true; } return count;}
fn show(context: *anyopaque, index: u8) void { const account: *Account = @ptrCast(@alignCast(context)); const self = account.accounts; if (index >= self.list.len or index == self.shown) return; const leaving = &self.list[self.shown]; leaving.left_at = Io.Timestamp.now(self.io, .real).toSeconds(); self.shown = index; const coming = &self.list[index]; if (siv.txnBegin(self.window)) |txn| { siv.setHidden(txn, leaving.slot, true); siv.setHidden(txn, coming.slot, false); siv.txnCommit(txn) catch |err| log.err("showing {s}: {t}", .{ coming.name, err }); } else |err| log.err("showing {s}: {t}", .{ coming.name, err }); leaving.desk.?.show(false) catch |err| log.err("hiding {s}: {t}", .{ leaving.name, err }); coming.desk.?.show(true) catch |err| log.err("showing {s}: {t}", .{ coming.name, err });}
/// Another account's mail changed: the shown desk counts it again.fn changed(context: *anyopaque) void { const account: *Account = @ptrCast(@alignCast(context)); const self = account.accounts; if (account.index == self.shown) return; const desk = self.list[self.shown].desk orelse return; desk.refreshAccounts() catch |err| log.err("counting {s}: {t}", .{ account.name, err });}
test "Ctrl and a digit shows another account's desk, which kept its own place; the header counts the others" { const app = try siv.appCreate(.{ .backend = .headless, .flags = .{ .no_system_fonts = true, .no_system_icons = true, .reduced_motion = true }, }); defer siv.appDestroy(app); const window = try siv.windowCreate(app, .{ .title = "Parcel", .width = 1200, .height = 760 }); defer siv.windowDestroy(window); const self = try create(testing.allocator, testing.io, window, &.{ "Personal", "Work" }, null); defer self.destroy(); for (self.list) |*a| try sample.load(&a.mail, 1_800_000_000); // Work's Inbox has one thread fewer unread than Personal's. const work = &self.list[1].mail; for (work.threads.items, 0..) |t, i| if (t.unread and work.has(@intCast(i), .inbox)) { work.setUnread(@intCast(i), false); break; }; const unread = inboxCount(&self.list[0].mail, 0).unread; try testing.expect(unread > 1); // An unread thread in the Inbox newer than the user's last look is news. try testing.expect(inboxCount(&self.list[0].mail, 0).fresh); try testing.expect(!inboxCount(&self.list[0].mail, 1_800_000_000).fresh); try self.build(&.{ zeit.utc, zeit.utc }); try testing.expect(try siv.testSettle(app, 5000));
var buf: [64]u8 = undefined; const work_button = try mem.print(&buf, "Work, {d} unread", .{unread - 1}); try testing.expect(try hasButton(window, work_button)); const personal = self.list[0].desk.?; try testing.expect(try personal.onKey(.char('j'))); try testing.expectEqual(1, personal.current.?);
try siv.testKey(window, @fromBackingInt('2'), .{ .ctrl = true }, ""); try testing.expect(try siv.testSettle(app, 5000)); try testing.expectEqual(1, self.shown); const work_desk = self.list[1].desk.?; try testing.expect(!work_desk.hidden and personal.hidden); try testing.expectEqual(0, work_desk.current.?); var personal_buf: [64]u8 = undefined; const personal_button = try mem.print(&personal_buf, "Personal, {d} unread", .{unread}); try testing.expect(try hasButton(window, personal_button));
// Keys go to the shown desk alone. try siv.testKey(window, @fromBackingInt('j'), .{}, "j"); try testing.expect(try siv.testSettle(app, 5000)); try testing.expectEqual(1, work_desk.current.?); try testing.expectEqual(1, personal.current.?);
// A click on the other account in the header shows it too. var found = false; var node: siv.TestNode = .{}; try siv.testFind(window, .{ .role = .button, .name = personal_button }, &found, &node); try siv.testPointerMove(window, node.bounds.x + node.bounds.width / 2, node.bounds.y + node.bounds.height / 2); try siv.testPointerButton(window, .primary, true); try siv.testPointerButton(window, .primary, false); try testing.expect(try siv.testSettle(app, 5000)); try testing.expectEqual(0, self.shown); try testing.expectEqual(1, personal.current.?);
// The first desk takes clicks too, under the hidden one after it. try siv.testFind(window, .{ .role = .button, .name = work_button }, &found, &node); try testing.expect(found); try siv.testPointerMove(window, node.bounds.x + node.bounds.width / 2, node.bounds.y + node.bounds.height / 2); try siv.testPointerButton(window, .primary, true); try siv.testPointerButton(window, .primary, false); try testing.expect(try siv.testSettle(app, 5000)); try testing.expectEqual(1, self.shown);}
fn hasButton(window: siv.Window, name: []const u8) !bool { var found = false; var node: siv.TestNode = .{}; try siv.testFind(window, .{ .role = .button, .name = name }, &found, &node); return found;}