Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634//! Columns: three Miller columns, as in Finder: labels, threads, message.//! `h` and `l` move between them and `j` and `k` within one; whatever the//! cursor rests on fills the column to its right at once.
const std = @import("std");const siv = @import("siv");const kit = @import("kit.zig");const sample = @import("sample.zig");const Prototype = @import("Prototype.zig");const Settings = @import("Settings.zig");const Store = @import("Store.zig");const enums = std.enums;const math = std.math;const mem = std.mem;const Allocator = std.mem.Allocator;const ArrayList = std.ArrayList;const Key = kit.Key;const Label = sample.Label;const log = std.log.scoped(.proto);
const Columns = @This();
pub const prototype: Prototype = .{ .name = "columns", .title = "Columns", .idea = "Three Miller columns, labels | threads | message; h and l walk across, j and k walk down.", .build = build, .shots = &.{ .{ .name = "threads", .keys = &kit.keys("ljj") }, .{ .name = "message", .keys = &kit.keys("l") }, .{ .name = "labeling", .keys = &kit.keys("l") }, .{ .name = "labeled", .keys = &(kit.keys("a") ++ [_]Key{.named(.escape)}) }, .{ .name = "work", .keys = &kit.keys("hhjjjjjjj") }, .{ .name = "hiring", .keys = &kit.keys("j") }, .{ .name = "starred", .keys = &kit.keys("ls") }, },};
const Column = enum { labels, threads, message };
/// What keys mean right now: moving around, or toggling labels.const Mode = enum { browse, label };
/// A row of the labels column. A null label is All Mail.const Place = struct { label: ?Label, /// 1 for a label nested under the one before it. depth: u8,};
const system_places = [_]Place{ .{ .label = .inbox, .depth = 0 }, .{ .label = .starred, .depth = 0 }, .{ .label = .sent, .depth = 0 }, .{ .label = .drafts, .depth = 0 }, .{ .label = null, .depth = 0 }, .{ .label = .spam, .depth = 0 }, .{ .label = .trash, .depth = 0 },};
/// Every user label in sample's order; a slash in the name nests it.const user_places = blk: { var n = 0; for (enums.values(Label)) |label| { if (label.info().kind == .user) n += 1; } var out: [n]Place = undefined; var i = 0; for (enums.values(Label)) |label| { if (label.info().kind != .user) continue; const nested = mem.findScalar(u8, label.info().name, '/') != null; out[i] = .{ .label = label, .depth = if (nested) 1 else 0 }; i += 1; } break :blk out;};
const places = system_places ++ user_places;
const row_height = 58;const dimmed = 0.72;
/// The nodes of a labels-column row that change as the mailbox does.const PlaceRow = struct { node: siv.Node, name: siv.Node, pill: siv.Node, unread: siv.Node, total: siv.Node,};
/// The nodes of a column's frame that show whether it has focus.const Frame = struct { node: siv.Node, edge: siv.Node, title: siv.Node,};
/// A click on a labels-column row selects that label.const PlaceClick = struct { owner: *Columns, index: u8,
fn call(user_data: ?*anyopaque, _: siv.Node, event: *const siv.Event) callconv(.c) siv.EventResult { if (event.phase != .up or event.button != .primary) return .pass; const click: *PlaceClick = @ptrCast(@alignCast(user_data)); click.owner.selectPlace(click.index) catch |err| { log.err("label click: {t}", .{err}); return .pass; }; return .handled; }};
/// A press anywhere in a column focuses it.const ColumnPress = struct { owner: *Columns, column: Column,
fn call(user_data: ?*anyopaque, _: siv.Node, event: *const siv.Event) callconv(.c) siv.EventResult { if (event.phase != .down or event.button != .primary) return .pass; const press: *ColumnPress = @ptrCast(@alignCast(user_data)); _ = press.owner.focusColumn(press.column) catch |err| { log.err("column press: {t}", .{err}); }; return .pass; }};
arena: Allocator,window: siv.Window,store: *Store,mode: Mode,focus: Column,/// Index into `places`.place: u8,/// The threads shown, newest first; the view's items index this.ids: []Store.Id,ids_buf: [Store.capacity]Store.Id,/// The selected thread, or null when the label is empty.current: ?u32,
frames: enums.EnumArray(Column, Frame),place_rows: [places.len]PlaceRow,list: siv.Node,empty: siv.Node,counts: siv.Node,message: siv.Node,message_empty: siv.Node,subject: siv.Node,from: siv.Node,chips_slot: siv.Node,chips: siv.Node,body: siv.Node,bar_slot: siv.Node,bar: siv.Node,
fn build(arena: Allocator, window: siv.Window, store: *Store, _: ?Settings) anyerror!void { const self = try arena.create(Columns); self.* = .{ .arena = arena, .window = window, .store = store, .mode = .browse, .focus = .labels, .place = 0, .ids = &.{}, .ids_buf = undefined, .current = 0, .frames = undefined, .place_rows = undefined, .list = .null, .empty = .null, .counts = .null, .message = .null, .message_empty = .null, .subject = .null, .from = .null, .chips_slot = .null, .chips = .null, .body = .null, .bar_slot = .null, .bar = .null, };
const txn = try siv.txnBegin(window); errdefer siv.txnAbort(txn); const root = siv.windowGetRoot(window); siv.setVisual(txn, root, .{ .background_token = .view_bg }); const column = siv.flex(txn, root, .{ .axis = .column, .align_items = .stretch }); siv.setLayout(txn, column, .{ .width = kit.whole, .height = kit.whole }); const browser = siv.flex(txn, column, .{ .align_items = .stretch }); siv.setLayout(txn, browser, .{ .grow = 1, .basis = kit.px(0) });
try self.buildLabels(txn, try self.frame(txn, browser, .labels, "Labels", 240)); rule(txn, browser); self.buildThreads(txn, try self.frame(txn, browser, .threads, "Threads", 420)); rule(txn, browser); self.buildMessage(txn, try self.frame(txn, browser, .message, "Message", 0));
const footer = siv.flex(txn, column, .{ .axis = .column, .align_items = .stretch }); siv.setVisual(txn, footer, .{ .background_token = .window_bg, .border_token = .separator }); self.bar_slot = footer; kit.keymap(Columns, txn, root, self); try siv.txnCommit(txn); try self.sync();}
/// A column: an accent edge along the top when it has focus, a title, and/// the content box this returns. A width of 0 takes what is left.fn frame(self: *Columns, txn: siv.Txn, parent: siv.Node, column: Column, title: []const u8, width: f32) !siv.Node { const node = siv.flex(txn, parent, .{ .axis = .column, .align_items = .stretch }); if (width > 0) siv.setLayout(txn, node, .{ .width = kit.px(width), .flags = .{ .no_shrink = true } }) else siv.setLayout(txn, node, .{ .grow = 1, .basis = kit.px(0) }); if (column == .labels) siv.setVisual(txn, node, .{ .background_token = .sidebar_bg }); siv.setSemantics(txn, node, .{ .role = .group, .label = title }); const press = try self.arena.create(ColumnPress); press.* = .{ .owner = self, .column = column }; siv.on(txn, node, .pointer, ColumnPress.call, null, press);
const edge = siv.box(txn, node); siv.setLayout(txn, edge, .{ .height = kit.px(3) }); const header = siv.flex(txn, node, .{ .gap = 10, .align_items = .end }); // A fixed height, so the focused column's larger title does not push its rows down. siv.setLayout(txn, header, .{ .height = kit.px(46), .padding = .{ .start = 16, .end = 16, .bottom = 8 } }); const title_node = kit.text(txn, header, title, .{ .flags = kit.one_line }); if (column == .threads) { self.counts = kit.text(txn, header, "", .{ .size_role = .caption, .color_token = .muted_fg }); } self.frames.set(column, .{ .node = node, .edge = edge, .title = title_node }); const content = siv.flex(txn, node, .{ .axis = .column, .align_items = .stretch }); siv.setLayout(txn, content, .{ .grow = 1, .basis = kit.px(0) }); return content;}
fn rule(txn: siv.Txn, parent: siv.Node) void { const node = siv.box(txn, parent); siv.setLayout(txn, node, .{ .width = kit.px(1), .flags = .{ .no_shrink = true } }); siv.setVisual(txn, node, .{ .background_token = .separator });}
fn buildLabels(self: *Columns, txn: siv.Txn, parent: siv.Node) !void { const list = siv.flex(txn, parent, .{ .axis = .column, .gap = 1, .align_items = .stretch }); siv.setLayout(txn, list, .{ .padding = .{ .start = 8, .end = 8 } }); siv.setSemantics(txn, list, .{ .role = .list, .label = "Labels" }); for (places, 0..) |place, i| { if (i == 0) heading(txn, list, "MAILBOXES"); if (i == system_places.len) heading(txn, list, "LABELS"); const click = try self.arena.create(PlaceClick); click.* = .{ .owner = self, .index = @intCast(i) }; self.place_rows[i] = placeRow(txn, list, place); siv.on(txn, self.place_rows[i].node, .pointer, PlaceClick.call, null, click); }}
fn heading(txn: siv.Txn, parent: siv.Node, s: []const u8) void { const node = kit.text(txn, parent, s, .{ .size_role = .caption, .weight = .semibold, .color_token = .muted_fg }); siv.setLayout(txn, node, .{ .padding = .{ .start = 10, .top = 10, .bottom = 4 } });}
fn placeRow(txn: siv.Txn, parent: siv.Node, place: Place) PlaceRow { const node = siv.flex(txn, parent, .{ .gap = 8, .align_items = .center }); const indent: f32 = 10 + 18 * @as(f32, @floatFromInt(place.depth)); siv.setLayout(txn, node, .{ .padding = .{ .start = indent, .end = 10, .top = 5, .bottom = 5 } }); siv.setCursor(txn, node, .pointer); const dot = siv.box(txn, node); siv.setLayout(txn, dot, .{ .width = kit.px(8), .height = kit.px(8) }); const color: siv.Color = if (place.label) |l| kit.color(l.info().color) else kit.tint(gray, 1); siv.setVisual(txn, dot, .{ .background = color, .radius = kit.corners(4) }); const name = kit.text(txn, node, placeName(place), .{}); siv.setLayout(txn, name, .{ .grow = 1, .shrink = 1, .basis = kit.px(0) }); const pill = siv.flex(txn, node, .{}); siv.setLayout(txn, pill, .{ .padding = .{ .start = 6, .end = 6 } }); siv.setVisual(txn, pill, .{ .background_token = .accent, .radius = kit.corners(8) }); const unread = kit.text(txn, pill, "", .{ .size_role = .caption, .weight = .semibold, .color_token = .accent_fg }); const total = kit.text(txn, node, "", .{ .size_role = .caption, .color_token = .muted_fg }); siv.setLayout(txn, total, .{ .min_width = 18 }); siv.setTextStyle(txn, total, .{ .size_role = .caption, .color_token = .muted_fg, .@"align" = .end }); return .{ .node = node, .name = name, .pill = pill, .unread = unread, .total = total };}
const gray: sample.Rgb = .{ .r = 0x9c, .g = 0xa3, .b = 0xaf };
/// "All Mail" for null; a nested label shows only its own part, "Hiring".fn placeName(place: Place) []const u8 { const label = place.label orelse return "All Mail"; const name = label.info().name; const slash = mem.findScalarLast(u8, name, '/') orelse return name; return name[slash + 1 ..];}
fn buildThreads(self: *Columns, txn: siv.Txn, parent: siv.Node) void { const body = siv.stack(txn, parent, .{}); siv.setLayout(txn, body, .{ .grow = 1, .basis = kit.px(0) }); self.list = siv.listView(txn, body, .{ .label = "Threads", .user_data = self, .bind = bindRow, .item_height = row_height, .selection = .single, }); siv.setLayout(txn, self.list, .{ .width = kit.whole, .height = kit.whole }); // The list keeps keyboard focus whichever column has it; the accent edge shows which does. siv.setVisual(txn, self.list, .{ .flags = .{ .no_focus_ring = true } }); kit.on(Columns, txn, self.list, .select, self, onSelect); kit.on(Columns, txn, self.list, .activate, self, onActivate); self.empty = kit.text(txn, body, "Nothing here", .{ .color_token = .muted_fg }); siv.setLayout(txn, self.empty, .{ .align_x = .center, .align_y = .center });}
fn buildMessage(self: *Columns, txn: siv.Txn, parent: siv.Node) void { self.message = siv.flex(txn, parent, .{ .axis = .column, .gap = 10, .align_items = .start }); siv.setLayout(txn, self.message, .{ .padding = .{ .start = 28, .end = 28, .top = 8, .bottom = 16 } }); const heading_style: siv.TextStyleDesc.Options = .{ .size_role = .heading, .weight = .bold, .color_token = .view_fg, }; self.subject = kit.text(txn, self.message, "", heading_style); self.from = kit.text(txn, self.message, "", .{ .color_token = .muted_fg }); self.chips_slot = siv.box(txn, self.message); siv.setLayout(txn, self.chips_slot, .{ .width = kit.whole }); self.body = kit.text(txn, self.message, "", .{ .color_token = .view_fg }); siv.setLayout(txn, self.body, .{ .max_width = 560, .padding = .{ .top = 8 } }); self.message_empty = kit.text(txn, parent, "No thread", .{ .color_token = .muted_fg }); siv.setLayout(txn, self.message_empty, .{ .grow = 1, .align_x = .center, .align_y = .center });}
pub fn onKey(self: *Columns, key: Key) !bool { if (self.mode == .label) return self.labelKey(key); if (key.is('h') or key.isNamed(.left)) return self.step(.left); if (key.is('l') and self.focus == .message) return self.startLabeling(); if (key.is('l') or key.isNamed(.right) or key.isNamed(.@"return")) return self.step(.right); if (key.is('j') or key.isNamed(.down)) return self.move(1); if (key.is('k') or key.isNamed(.up)) return self.move(-1); if (self.focus == .labels) return false; if (key.is('e')) return self.act(.archive); if (key.is('s')) return self.act(.star); if (key.is('u')) return self.act(.unread); return false;}
fn labelKey(self: *Columns, key: Key) !bool { if (key.isNamed(.escape) or key.isNamed(.@"return")) { self.mode = .browse; try self.sync(); return true; } const c = key.printable() orelse return true; const label = Label.fromKey(c) orelse return true; if (label.info().kind != .user) return true; const current = self.current orelse return true; self.store.toggleLabel(self.ids[current], label); try self.sync(); return true;}
/// Moves focus one column left or right.fn step(self: *Columns, direction: enum { left, right }) !bool { const next: ?Column = switch (self.focus) { .labels => if (direction == .right) .threads else null, .threads => if (direction == .right) .message else .labels, .message => if (direction == .right) null else .threads, }; return self.focusColumn(next orelse return true);}
fn focusColumn(self: *Columns, column: Column) !bool { if (column == .message and self.current == null) return true; self.mode = .browse; self.focus = column; if (column == .message) self.markRead(); try self.sync(); return true;}
fn startLabeling(self: *Columns) !bool { if (self.current == null) return true; self.mode = .label; try self.sync(); return true;}
/// Moves the cursor in the focused column; in the message column it steps/// through the threads, so the message follows.fn move(self: *Columns, delta: i32) !bool { if (self.focus == .labels) { const last: i32 = places.len - 1; return self.selectPlaceKeepingFocus(@intCast(math.clamp(@as(i32, self.place) + delta, 0, last))); } const current = self.current orelse return true; const last: i32 = @intCast(self.ids.len - 1); self.current = @intCast(math.clamp(@as(i32, @intCast(current)) + delta, 0, last)); if (self.focus == .message) self.markRead(); try self.sync(); return true;}
fn selectPlace(self: *Columns, index: u8) !void { self.focus = .labels; _ = try self.selectPlaceKeepingFocus(index);}
fn selectPlaceKeepingFocus(self: *Columns, index: u8) !bool { if (index != self.place) self.current = 0; self.place = index; self.mode = .browse; try self.sync(); return true;}
const Action = enum { archive, star, unread };
fn act(self: *Columns, action: Action) !bool { const current = self.current orelse return true; const id = self.ids[current]; switch (action) { .archive => self.store.archive(id), .star => self.store.toggleLabel(id, .starred), .unread => self.store.setUnread(id, !self.store.get(id).unread), } try self.sync(); return true;}
fn markRead(self: *Columns) void { const current = self.current orelse return; self.store.setUnread(self.ids[current], false);}
/// Runs the label's query again, keeps the cursor near where it was, and/// redraws every column and the key bar.fn sync(self: *Columns) !void { self.ids = self.store.query(.{ .label = places[self.place].label }, &self.ids_buf); self.current = if (self.ids.len == 0) null else @min(self.current orelse 0, self.ids.len - 1); if (self.current == null) { self.mode = .browse; if (self.focus == .message) self.focus = .threads; } 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); siv.setHidden(txn, self.empty, self.ids.len > 0); if (self.current) |current| { siv.viewSetSelected(txn, self.list, current, true); siv.viewScrollTo(txn, self.list, current); } self.showFrames(txn); self.showPlaces(txn); self.showMessage(txn); try self.showBar(txn); try siv.txnCommit(txn); try siv.nodeFocus(self.list);}
fn showFrames(self: *Columns, txn: siv.Txn) void { for (enums.values(Column)) |column| { const f = self.frames.get(column); const focused = column == self.focus; siv.setVisual(txn, f.edge, .{ .background_token = if (focused) .accent else .none }); siv.setTextStyle(txn, f.title, .{ .size_role = if (focused) .heading else .body, .weight = if (focused) .bold else .medium, .color_token = if (focused) .view_fg else .muted_fg, .flags = kit.one_line, }); siv.setOpacity(txn, f.node, if (focused) 1 else dimmed); } const name = placeName(places[self.place]); siv.setText(txn, self.frames.get(.threads).title, name); const c = self.store.count(.{ .label = places[self.place].label }); var buf: [64]u8 = undefined; siv.setText(txn, self.counts, mem.print(&buf, "{d} threads, {d} unread", .{ c.total, c.unread }) catch "");}
fn showPlaces(self: *Columns, txn: siv.Txn) void { for (places, &self.place_rows, 0..) |place, row, i| { const selected = i == self.place; const strong = selected and self.focus == .labels; siv.setVisual(txn, row.node, .{ .background_token = if (strong) .selection_bg else if (selected) .control_active else .none, .radius = kit.corners(6), }); siv.setSemantics(txn, row.node, .{ .role = .list_item, .label = placeName(place), .selected = if (selected) .on else .off, }); const c = self.store.count(.{ .label = place.label }); siv.setTextStyle(txn, row.name, .{ .weight = if (c.unread > 0) .semibold else .regular, .color_token = if (strong) .selection_fg else .view_fg, .flags = kit.one_line, }); var unread_buf: [16]u8 = undefined; var total_buf: [16]u8 = undefined; siv.setText(txn, row.unread, mem.print(&unread_buf, "{d}", .{c.unread}) catch ""); siv.setHidden(txn, row.pill, c.unread == 0); siv.setText(txn, row.total, mem.print(&total_buf, "{d}", .{c.total}) catch ""); }}
fn showMessage(self: *Columns, txn: siv.Txn) void { siv.setHidden(txn, self.message, self.current == null); siv.setHidden(txn, self.message_empty, self.current != null); const current = self.current orelse return; const thread = self.store.get(self.ids[current]); siv.setText(txn, self.subject, thread.subject); var age_buf: [16]u8 = undefined; var buf: [160]u8 = undefined; const age = kit.age(&age_buf, thread.minutes_ago); const plural = if (thread.messages == 1) "" else "s"; const args = .{ thread.from, thread.messages, plural, age }; const from = mem.print(&buf, "{s} · {d} message{s} · {s} ago", args) catch thread.from; siv.setText(txn, self.from, from); siv.setText(txn, self.body, thread.snippet); if (self.chips != .null) siv.remove(txn, self.chips); self.chips = siv.flex(txn, self.chips_slot, .{ .gap = 6, .flags = .{ .wrap = true } }); siv.setLayout(txn, self.chips, .{ .width = kit.whole }); var it = thread.labels.iterator(); while (it.next()) |label| _ = kit.chip(txn, self.chips, label);}
/// The bottom bar: the keys of the focused column, every one clickable.fn showBar(self: *Columns, txn: siv.Txn) !void { if (self.bar != .null) siv.remove(txn, self.bar); var hints: ArrayList(kit.Hint) = .empty; if (self.mode == .label) { try self.labelHints(&hints); } else switch (self.focus) { .labels => try hints.appendSlice(self.arena, &.{ .{ .key = .char('j'), .cap = "j", .action = "next label" }, .{ .key = .char('k'), .cap = "k", .action = "previous label" }, .{ .key = .char('l'), .cap = "l", .action = "to threads" }, }), .threads => try hints.appendSlice(self.arena, &.{ .{ .key = .char('h'), .cap = "h", .action = "to labels" }, .{ .key = .char('j'), .cap = "j", .action = "next thread" }, .{ .key = .char('k'), .cap = "k", .action = "previous thread" }, .{ .key = .char('l'), .cap = "l", .action = "to message" }, }), .message => try hints.appendSlice(self.arena, &.{ .{ .key = .char('h'), .cap = "h", .action = "to threads" }, .{ .key = .char('j'), .cap = "j", .action = "next thread" }, .{ .key = .char('k'), .cap = "k", .action = "previous thread" }, .{ .key = .char('l'), .cap = "l", .action = "label" }, }), } if (self.mode == .browse and self.focus != .labels) try hints.appendSlice(self.arena, &.{ .{ .key = .char('e'), .cap = "e", .action = "archive" }, .{ .key = .char('s'), .cap = "s", .action = "star" }, .{ .key = .char('u'), .cap = "u", .action = "unread" }, }); self.bar = try kit.hintBar(Columns, self.arena, txn, self.bar_slot, self, hints.items);}
fn labelHints(self: *Columns, hints: *ArrayList(kit.Hint)) !void { const thread = if (self.current) |c| self.store.get(self.ids[c]) else null; for (enums.values(Label)) |label| { const info = label.info(); if (info.kind != .user) continue; const on = if (thread) |t| t.labels.contains(label) else false; const action = if (on) try self.arena.print("{s} ✓", .{info.name}) else info.name; try hints.append(self.arena, .{ .key = .char(info.key), .cap = label.keyCap(), .action = action }); } try hints.append(self.arena, .{ .key = .named(.escape), .cap = "Esc", .action = "done" });}
/// A click on a thread focuses the threads column and selects it.fn onSelect(self: *Columns, event: *const siv.Event) !bool { if (event.count == 0) return true; self.current = event.index; self.focus = .threads; self.mode = .browse; try self.sync(); return true;}
fn onActivate(self: *Columns, event: *const siv.Event) !bool { self.current = event.index; return self.focusColumn(.message);}
fn bindRow( user_data: ?*anyopaque, txn: siv.Txn, row: siv.Node, item: u64, _: u32, _: u32, recycled: bool,) callconv(.c) void { const self: *Columns = @ptrCast(@alignCast(user_data)); if (!recycled) makeRow(txn, row); const thread = self.store.get(self.ids[@intCast(item)]); const content = siv.nodeGetRelated(row, .first_child); const top = siv.nodeGetRelated(content, .first_child); const unread_dot = siv.nodeGetRelated(top, .first_child); const from = siv.nodeGetRelated(unread_dot, .next); const age = siv.nodeGetRelated(from, .next); const bottom = siv.nodeGetRelated(top, .next);
const dot_color: siv.ColorToken = if (thread.unread) .accent else .none; siv.setVisual(txn, unread_dot, .{ .background_token = dot_color, .radius = kit.corners(4) }); siv.setText(txn, from, thread.from); siv.setTextStyle(txn, from, .{ .weight = if (thread.unread) .bold else .regular, .color_token = .view_fg, .flags = kit.one_line, }); var age_buf: [16]u8 = undefined; siv.setText(txn, age, kit.age(&age_buf, thread.minutes_ago)); var line_buf: [256]u8 = undefined; const star = if (thread.labels.contains(.starred)) "★ " else ""; const line = mem.print(&line_buf, "{s}{s} — {s}", .{ star, thread.subject, thread.snippet }) catch thread.subject; siv.setText(txn, bottom, line);}
fn makeRow(txn: siv.Txn, row: siv.Node) void { const content = siv.flex(txn, row, .{ .axis = .column, .gap = 2, .justify = .center, .align_items = .stretch }); siv.setLayout(txn, content, .{ .width = kit.whole, .height = kit.whole, .padding = .{ .start = 14, .end = 16 } }); const top = siv.flex(txn, content, .{ .gap = 8, .align_items = .center }); const unread_dot = siv.box(txn, top); siv.setLayout(txn, unread_dot, .{ .width = kit.px(8), .height = kit.px(8) }); const from = kit.text(txn, top, "", .{}); siv.setLayout(txn, from, .{ .grow = 1, .shrink = 1, .basis = kit.px(0) }); _ = kit.text(txn, top, "", .{ .size_role = .caption, .color_token = .muted_fg }); const bottom = kit.text(txn, content, "", .{ .color_token = .muted_fg, .flags = kit.one_line }); siv.setLayout(txn, bottom, .{ .padding = .{ .start = 16 } });}